diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae9b68d05..b9f741296 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,11 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
+- `Chunk.slice(offset, length)` gives a zero-copy view of a row range. ([#550](https://github.com/dfa1/vortex-java/pull/550))
+- The Calcite adapter tells the planner each table's row count, so join order and costing use the real table size. ([#550](https://github.com/dfa1/vortex-java/pull/550))
- `vortex.json` and the `vortex.st.*` geometry columns open without a decoder and read as their storage, as vortex-jni reads them. ([#547](https://github.com/dfa1/vortex-java/pull/547))
- The CLI takes `--allow-unknown` to open files with extension types it has no decoder for, and CSV `ExportOptions.withReadRegistry` picks the registry an export opens the file with. ([#547](https://github.com/dfa1/vortex-java/pull/547))
- Custom extension types: register an `ExtensionDecoder` on `ReadRegistry` and an `ExtensionEncoder` on `WriteRegistry`, with a `validate` hook on both sides, and `Chunk.as` decodes any registered type. ([#530](https://github.com/dfa1/vortex-java/issues/530))
### Changed
+- The Calcite adapter pushes `IN` lists, comparisons with the literal on the left and `IS NULL` into the scan, so they prune chunks instead of being re-checked row by row. ([#550](https://github.com/dfa1/vortex-java/pull/550))
+- The Calcite adapter answers a filtered `COUNT`, `MIN` or `MAX` from Rust's `vortex.zoned` zone maps, the default, decoding only the zones the filter cuts. ([#550](https://github.com/dfa1/vortex-java/pull/550))
+- The Calcite adapter reads a table's schema and row count once instead of reopening the file for every planning call. ([#550](https://github.com/dfa1/vortex-java/pull/550))
- **Breaking:** opening a file with an extension id that has no registered decoder fails unless `ReadRegistry.Builder#allowUnknown()` is set, as in Rust; the column then reads as its storage array. ([#530](https://github.com/dfa1/vortex-java/issues/530))
- **Breaking:** `vortex.date`, `vortex.time`, `vortex.timestamp` and `vortex.uuid` dtypes are checked as Rust checks them (time unit, storage type, metadata) when writing and reading, and a file whose extension dtype has no metadata field is refused, as Rust refuses it. ([#547](https://github.com/dfa1/vortex-java/pull/547))
- **Breaking:** `DateExtensionDecoder.decode` and `decodeAll` take the column's `DType.Extension`, which carries the time unit. ([#547](https://github.com/dfa1/vortex-java/pull/547))
@@ -20,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Breaking:** `ExtensionId` is a sealed `WellKnown`/`Custom` type like `EncodingId`, so third-party extension ids can be named, and `ExtensionId.parse` returns the id instead of an `Optional`. ([#530](https://github.com/dfa1/vortex-java/issues/530))
### Fixed
+- The Calcite adapter reads decimal, binary, date, time, timestamp and uuid columns, and a list, struct, map, union or variant column no longer stops the other columns of its table from being queried. ([#550](https://github.com/dfa1/vortex-java/pull/550))
+- An extension dtype read from a file stays usable after its `VortexReader` is closed, instead of failing with "Already closed". ([#550](https://github.com/dfa1/vortex-java/pull/550))
- `vortex count`, `schema`, `stats` and `export` report a malformed file as an error instead of crashing with a stack trace. ([#547](https://github.com/dfa1/vortex-java/pull/547))
- `vortex.date` columns in milliseconds, as Rust writes an Arrow `Date64`, decode to the right dates instead of failing or reading the count as days. ([#547](https://github.com/dfa1/vortex-java/pull/547))
- An extension column with no metadata no longer writes a file Rust refuses to read. ([#547](https://github.com/dfa1/vortex-java/pull/547))
diff --git a/calcite/pom.xml b/calcite/pom.xml
index 2f0eff1e5..c41131f9b 100644
--- a/calcite/pom.xml
+++ b/calcite/pom.xml
@@ -10,7 +10,7 @@
vortex-calcitevortex-calcite
- Apache Calcite SQL adapter over the Vortex columnar file format (demo: filter/project/aggregate push-down).
+ Apache Calcite SQL adapter over the Vortex columnar file format with projection, filter and aggregate push-down.
diff --git a/calcite/src/main/java/io/github/dfa1/vortex/calcite/RexFilterTranslator.java b/calcite/src/main/java/io/github/dfa1/vortex/calcite/RexFilterTranslator.java
index 454f52f2f..ff83817f5 100644
--- a/calcite/src/main/java/io/github/dfa1/vortex/calcite/RexFilterTranslator.java
+++ b/calcite/src/main/java/io/github/dfa1/vortex/calcite/RexFilterTranslator.java
@@ -3,6 +3,7 @@
import io.github.dfa1.vortex.core.model.ColumnName;
import io.github.dfa1.vortex.core.model.DType;
import io.github.dfa1.vortex.reader.RowFilter;
+import io.github.dfa1.vortex.reader.compute.Predicate;
import org.apache.calcite.jdbc.JavaTypeFactoryImpl;
import org.apache.calcite.rex.RexBuilder;
@@ -32,9 +33,10 @@ final class RexFilterTranslator {
private RexFilterTranslator() {
}
- /// Translates the Calcite predicates into a zone-map [RowFilter], keeping only the comparisons
- /// we can prune on (`=`, `<>`, `<`, `<=`, `>`, `>=` between a column and a literal, plus `AND`).
- /// Predicates we don't understand are simply not pushed — Calcite still applies them.
+ /// Translates the Calcite predicates into a zone-map [RowFilter], keeping only what we can prune
+ /// on: `=`, `<>`, `<`, `<=`, `>`, `>=` between a column and a literal, `IS [NOT] NULL`, a
+ /// single-column `OR` (such as an `IN` list), and `AND`. Predicates we don't understand are
+ /// simply not pushed — Calcite still applies them.
///
/// @param filters the predicates Calcite pushed onto the scan
/// @param names the table's column names, by field index
@@ -108,9 +110,8 @@ private static Optional toComparison(RexNode node, List n
return comparison(node, names, types, false);
}
- /// Strict counterpart of [#toComparison]: the same column-vs-literal / `AND` grammar, but a
- /// single unrecognized node (or one `AND` conjunct) collapses the whole result to empty rather
- /// than being dropped, and bare `IS NULL` / `IS NOT NULL` are also translatable. Used by
+ /// Strict counterpart of [#toComparison]: the same grammar, but a single unrecognized node (or
+ /// one `AND` conjunct) collapses the whole result to empty rather than being dropped. Used by
/// [#translateStrict] so aggregate push-down answers from stats only when the [RowFilter]
/// captures the predicate in full. Delegates to the shared [#comparison] dispatch with
/// `strict = true`.
@@ -119,14 +120,14 @@ private static Optional strictComparison(RexNode node, List`, `<`, `<=`, `>`, `>=`) translate identically
- /// in both walks through [#binary].
+ /// (strict). The two differ only in how an untranslatable `AND` conjunct is handled: a `strict`
+ /// walk abandons the whole predicate on the first one, while a lenient walk drops it and keeps
+ /// the rest. Comparisons (`=`, `<>`, `<`, `<=`, `>`, `>=`, either operand order) and the null
+ /// tests translate identically in both walks.
+ ///
+ /// An `OR` translates only when every disjunct does and all of them test one column, into one
+ /// column predicate (an `IN` list arrives this way once `SEARCH` is expanded). Dropping a
+ /// disjunct would narrow the filter and lose rows, so an `OR` is all-or-nothing in both walks.
///
/// @param node the (already `SEARCH`-expanded) predicate node
/// @param names the table's column names, by field index
@@ -153,13 +154,50 @@ private static Optional comparison(RexNode node, List nam
yield parts.isEmpty() ? Optional.empty()
: Optional.of(RowFilter.and(parts.toArray(RowFilter[]::new)));
}
+ case OR -> {
+ ColumnName column = null;
+ Predicate predicate = null;
+ for (RexNode operand : call.getOperands()) {
+ Optional part = comparison(operand, names, types, true)
+ .flatMap(RexFilterTranslator::singleColumn);
+ if (part.isEmpty() || column != null && !column.equals(part.get().column())) {
+ yield Optional.empty();
+ }
+ column = part.get().column();
+ predicate = predicate == null ? part.get().predicate()
+ : new Predicate.Or(predicate, part.get().predicate());
+ }
+ yield Optional.of(new RowFilter.Column(column, predicate));
+ }
case EQUALS, NOT_EQUALS, LESS_THAN, LESS_THAN_OR_EQUAL, GREATER_THAN, GREATER_THAN_OR_EQUAL ->
binary(call, names, types);
- case IS_NULL, IS_NOT_NULL -> strict ? nullCheck(call, names) : Optional.empty();
+ case IS_NULL, IS_NOT_NULL -> nullCheck(call, names);
default -> Optional.empty();
};
}
+ /// The filter as one column's predicate: itself for a column filter, the conjunction of an `AND`
+ /// whose parts all test the same column, else empty.
+ private static Optional singleColumn(RowFilter filter) {
+ return switch (filter) {
+ case RowFilter.Column column -> Optional.of(column);
+ case RowFilter.And(var parts) -> {
+ ColumnName column = null;
+ Predicate predicate = null;
+ for (RowFilter part : parts) {
+ Optional one = singleColumn(part);
+ if (one.isEmpty() || column != null && !column.equals(one.get().column())) {
+ yield Optional.empty();
+ }
+ column = one.get().column();
+ predicate = predicate == null ? one.get().predicate()
+ : new Predicate.And(predicate, one.get().predicate());
+ }
+ yield Optional.of(new RowFilter.Column(column, predicate));
+ }
+ };
+ }
+
/// Translates `col IS NULL` / `col IS NOT NULL` over a bare column reference into the matching
/// [RowFilter], which the zone-map fold answers from each zone's null count. Anything other than
/// a direct [RexInputRef] operand (e.g. `IS NULL` over an expression) abandons the translation —
@@ -177,7 +215,21 @@ private static Optional nullCheck(RexCall call, List name
private static Optional binary(RexCall call, List names, List types) {
List ops = call.getOperands();
- if (ops.size() != 2 || !(ops.get(0) instanceof RexInputRef ref) || !(ops.get(1) instanceof RexLiteral lit)) {
+ if (ops.size() != 2) {
+ return Optional.empty();
+ }
+ SqlKind kind = call.getKind();
+ RexInputRef ref;
+ RexLiteral lit;
+ if (ops.get(0) instanceof RexInputRef r && ops.get(1) instanceof RexLiteral l) {
+ ref = r;
+ lit = l;
+ } else if (ops.get(0) instanceof RexLiteral l && ops.get(1) instanceof RexInputRef r) {
+ // `5 < x` is `x > 5`
+ ref = r;
+ lit = l;
+ kind = kind.reverse();
+ } else {
return Optional.empty();
}
Object val = literalValue(lit, types.get(ref.getIndex()));
@@ -186,14 +238,14 @@ private static Optional binary(RexCall call, List names,
}
ColumnName col = names.get(ref.getIndex());
Comparable> cmp = (Comparable>) val;
- return Optional.of(switch (call.getKind()) {
+ return Optional.of(switch (kind) {
case EQUALS -> RowFilter.eq(col, val);
case NOT_EQUALS -> RowFilter.neq(col, val);
case LESS_THAN -> RowFilter.lt(col, cmp);
case LESS_THAN_OR_EQUAL -> RowFilter.lte(col, cmp);
case GREATER_THAN -> RowFilter.gt(col, cmp);
case GREATER_THAN_OR_EQUAL -> RowFilter.gte(col, cmp);
- default -> throw new IllegalStateException("unreachable kind: " + call.getKind());
+ default -> throw new IllegalStateException("unreachable kind: " + kind);
});
}
diff --git a/calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexEnumerator.java b/calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexEnumerator.java
index f8436228f..cb42762bb 100644
--- a/calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexEnumerator.java
+++ b/calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexEnumerator.java
@@ -3,14 +3,17 @@
import io.github.dfa1.vortex.core.error.VortexException;
import io.github.dfa1.vortex.core.model.ColumnName;
import io.github.dfa1.vortex.core.model.DType;
+import io.github.dfa1.vortex.core.model.ExtensionId;
import io.github.dfa1.vortex.reader.Chunk;
import io.github.dfa1.vortex.reader.RowFilter;
import io.github.dfa1.vortex.reader.ScanIterator;
import io.github.dfa1.vortex.reader.ScanOptions;
import io.github.dfa1.vortex.reader.VortexReader;
import io.github.dfa1.vortex.reader.compute.Compute;
+import io.github.dfa1.vortex.reader.array.Array;
import io.github.dfa1.vortex.reader.array.BoolArray;
import io.github.dfa1.vortex.reader.array.ByteArray;
+import io.github.dfa1.vortex.reader.array.DecimalArray;
import io.github.dfa1.vortex.reader.array.DoubleArray;
import io.github.dfa1.vortex.reader.array.Float16Array;
import io.github.dfa1.vortex.reader.array.FloatArray;
@@ -19,7 +22,12 @@
import io.github.dfa1.vortex.reader.array.MaskedArray;
import io.github.dfa1.vortex.reader.array.ShortArray;
import io.github.dfa1.vortex.reader.array.VarBinArray;
+import io.github.dfa1.vortex.reader.extension.DateExtensionDecoder;
+import io.github.dfa1.vortex.reader.extension.TimeExtensionDecoder;
+import io.github.dfa1.vortex.reader.extension.TimestampExtensionDecoder;
+import io.github.dfa1.vortex.reader.extension.UuidExtensionDecoder;
+import org.apache.calcite.avatica.util.ByteString;
import org.apache.calcite.linq4j.Enumerator;
import java.io.IOException;
@@ -168,8 +176,24 @@ private static Object value(Object array, DType type, long r) {
case I8, U8 -> ((ByteArray) array).getInt(r);
};
case DType.Utf8 _ -> ((VarBinArray) array).getString(r);
+ case DType.Binary _ -> new ByteString(((VarBinArray) array).getBytes(r));
case DType.Bool _ -> ((BoolArray) array).getBoolean(r);
- default -> throw new IllegalStateException("unsupported column dtype: " + type);
+ case DType.Null _ -> null;
+ case DType.Decimal _ -> ((DecimalArray) array).getDecimal(r);
+ // Calcite's internal forms: DATE is days since the epoch (int), TIME milliseconds of the
+ // day (int), TIMESTAMP milliseconds since the epoch (long)
+ case DType.Extension ext -> switch (ExtensionId.parse(ext.extensionId())) {
+ case ExtensionId.WellKnown.VORTEX_DATE ->
+ Math.toIntExact(DateExtensionDecoder.INSTANCE.decode(ext, (Array) array, r).toEpochDay());
+ case ExtensionId.WellKnown.VORTEX_TIME ->
+ (int) (TimeExtensionDecoder.INSTANCE.decode(ext, (Array) array, r).toNanoOfDay() / 1_000_000);
+ case ExtensionId.WellKnown.VORTEX_TIMESTAMP ->
+ TimestampExtensionDecoder.INSTANCE.instant(ext, (Array) array, r).toEpochMilli();
+ case ExtensionId.WellKnown.VORTEX_UUID -> UuidExtensionDecoder.INSTANCE.decode((Array) array, r);
+ default -> value(array, ext.storageDType(), r);
+ };
+ case DType.List _, DType.FixedSizeList _, DType.Struct _, DType.Map _, DType.Union _,
+ DType.Variant _ -> throw new VortexException("SQL cannot read a " + type + " column yet");
};
}
diff --git a/calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexTable.java b/calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexTable.java
index 99b3afc41..b761c8471 100644
--- a/calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexTable.java
+++ b/calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexTable.java
@@ -1,7 +1,9 @@
package io.github.dfa1.vortex.calcite;
+import io.github.dfa1.vortex.core.error.VortexException;
import io.github.dfa1.vortex.core.model.ColumnName;
import io.github.dfa1.vortex.core.model.DType;
+import io.github.dfa1.vortex.core.model.ExtensionId;
import io.github.dfa1.vortex.core.model.PType;
import io.github.dfa1.vortex.reader.ArrayStats;
import io.github.dfa1.vortex.reader.Chunk;
@@ -9,6 +11,7 @@
import io.github.dfa1.vortex.reader.ScanIterator;
import io.github.dfa1.vortex.reader.ScanOptions;
import io.github.dfa1.vortex.reader.VortexReader;
+import io.github.dfa1.vortex.reader.Zone;
import io.github.dfa1.vortex.reader.compute.Compare;
import io.github.dfa1.vortex.reader.compute.Compute;
import io.github.dfa1.vortex.reader.compute.FilteredAggregate;
@@ -25,6 +28,8 @@
import org.apache.calcite.rel.type.RelDataTypeFactory;
import org.apache.calcite.rex.RexNode;
import org.apache.calcite.schema.ProjectableFilterableTable;
+import org.apache.calcite.schema.Statistic;
+import org.apache.calcite.schema.Statistics;
import org.apache.calcite.schema.TranslatableTable;
import org.apache.calcite.schema.impl.AbstractTable;
import org.apache.calcite.sql.type.SqlTypeName;
@@ -50,10 +55,22 @@
/// only partially captures is left in `filters` for Calcite's own row check. The win is decoding
/// far fewer chunks when the filter is selective on a clustered column, and — for a fully captured
/// filter — not re-evaluating it a second time over the chunks that are decoded.
+///
+/// The schema and row count are read from the footer on first use and cached:
+/// a Vortex file is written once, so they cannot change under the table.
public final class VortexTable extends AbstractTable implements ProjectableFilterableTable, TranslatableTable {
private final Path file;
private final AtomicLong chunksScannedLastQuery = new AtomicLong();
+ // Read lazily so a missing file fails at query time, as before; a racing first read only
+ // reads the footer twice, so no lock.
+ private volatile Footer footer;
+
+ /// The footer facts every planning call needs, read once instead of reopening the file for each
+ /// `getRowType`, `scan` and statistic. Column statistics stay out: folding them walks every
+ /// column's zones, which only the aggregate rule needs.
+ private record Footer(DType.Struct struct, long totalRows) {
+ }
/// Creates a table backed by the Vortex file at `file`.
///
@@ -88,9 +105,9 @@ public record ColumnStats(ArrayStats stats, long totalRows) {
/// @param column the column name
/// @return the column's aggregated statistics paired with the file's total row count
public ColumnStats statsAndRows(ColumnName column) {
+ long totalRows = footer().totalRows();
try (VortexReader reader = VortexReader.open(file)) {
- ArrayStats stats = reader.columnStats().getOrDefault(column, ArrayStats.empty());
- return new ColumnStats(stats, countRows(reader));
+ return new ColumnStats(reader.columnStats().getOrDefault(column, ArrayStats.empty()), totalRows);
} catch (IOException e) {
throw new UncheckedIOException("cannot read stats of " + file, e);
}
@@ -120,11 +137,12 @@ public record ZoneSum(Number sum, Long nullCount, long totalRows) {
/// @param column the numeric column name
/// @return the folded sum with the null and row counts needed to interpret a zero
public ZoneSum zoneSum(ColumnName column) {
+ long totalRows = footer().totalRows();
try (VortexReader reader = VortexReader.open(file)) {
Number sum = new ZoneReducer(reader).sum(column);
Long nullCount = reader.columnStats()
.getOrDefault(column, ArrayStats.empty()).nullCount();
- return new ZoneSum(sum, nullCount, countRows(reader));
+ return new ZoneSum(sum, nullCount, totalRows);
} catch (IOException e) {
throw new UncheckedIOException("cannot read zone sum of " + file, e);
}
@@ -134,15 +152,39 @@ public ZoneSum zoneSum(ColumnName column) {
///
/// @return the number of rows in the file
public long totalRows() {
+ return footer().totalRows();
+ }
+
+ /// Gives the planner the file's row count, so join ordering and costing see the real table
+ /// size instead of Calcite's default guess.
+ ///
+ /// @return a statistic carrying the row count, with no declared keys
+ @Override
+ public Statistic getStatistic() {
+ return Statistics.of(footer().totalRows(), List.of());
+ }
+
+ private Footer footer() {
+ Footer f = footer;
+ if (f == null) {
+ f = readFooter();
+ footer = f;
+ }
+ return f;
+ }
+
+ private Footer readFooter() {
try (VortexReader reader = VortexReader.open(file)) {
- return countRows(reader);
+ if (!(reader.dtype() instanceof DType.Struct s)) {
+ throw new IllegalStateException("top-level Vortex dtype is not a struct: " + reader.dtype());
+ }
+ return new Footer(s, countRows(reader));
} catch (IOException e) {
- throw new UncheckedIOException("cannot count rows of " + file, e);
+ throw new UncheckedIOException("cannot read the footer of " + file, e);
}
}
- /// Sums the chunk row counts of `reader` from chunk metadata, without decoding data — shared by
- /// [#totalRows()] and [#zoneSum(ColumnName)] so both read the count from a single open reader.
+ /// Sums the chunk row counts of `reader` from chunk metadata, without decoding data.
private static long countRows(VortexReader reader) throws IOException {
long total = 0;
try (ScanIterator scan = reader.scan(ScanOptions.all())) {
@@ -183,14 +225,18 @@ public record FilteredFold(Number sum, Object min, Object max, Long nullCount, l
/// `aggColumn` is the column whose values are reduced over the selected rows (`null` for
/// `COUNT(*)`, which needs only the selected row count). The result is empty — abandoning to a
/// full scan, which is always correctness-safe — when the fold cannot be trusted: a `U64`
- /// column involved (whose stat order [#isU64] cannot classify — see the guard below), a
- /// zone-map zone count that does not align 1:1 with the chunks, or a boundary zone whose mask
- /// cannot be built.
+ /// column involved (whose stat order [#isU64] cannot classify — see the guard below), columns
+ /// whose zones lie on different rows, zones that do not tile the chunks' rows, or a boundary
+ /// zone whose columns cannot be sliced.
+ ///
+ /// Zones need not line up with chunks: Rust's `vortex.zoned` writes fixed 8192-row zones while a
+ /// chunk often spans many of them, so a boundary zone is folded from just its rows of the chunk
+ /// (or two chunks) holding it.
///
/// The fold also abandons for performance, not correctness, when there is at least one boundary
/// zone but no fully-selected (IN) zone: the only win the fold has over a scan is folding IN zones
/// from statistics without decoding them, so with zero IN zones it would decode exactly the chunks
- /// a scan decodes and only add the stats-table classify and mask-building overhead. The
+ /// a scan decodes and only add the stats-table classify overhead. The
/// zone-map-pruned scan is the cheaper answer. An all-OUT selection (no IN, no boundary) is not
/// this case — it folds to a correct empty aggregate with no decode and is kept.
///
@@ -224,30 +270,35 @@ public Optional filteredFold(RowFilter filter, ColumnName aggColum
return Optional.empty();
}
}
- long[] rowCounts = scan.chunkRowCounts();
- int zones = rowCounts.length;
+ long[] chunkRows = scan.chunkRowCounts();
+ // Every column must place its zones on the same rows, so one zone index classifies
+ // the whole filter. Rust's vortex.zoned uses fixed zones independent of the chunks;
+ // the legacy vortex.stats and the no-zone-map fallback have one zone per chunk.
+ List geometry = null;
java.util.Map> zoneStats = new java.util.HashMap<>();
for (ColumnName column : columns) {
- List perZone = scan.columnZoneStats(column);
- if (perZone.size() != zones) {
- return Optional.empty(); // zone-map zones don't align 1:1 with chunks — cannot classify
+ List zones = zonesOf(scan, column, chunkRows);
+ if (zones == null || geometry != null && !sameRows(geometry, zones)) {
+ return Optional.empty();
}
- zoneStats.put(column, perZone);
+ geometry = zones;
+ zoneStats.put(column, zones.stream().map(Zone::stats).toList());
+ }
+ if (!tiles(geometry, chunkRows)) {
+ return Optional.empty(); // zones and chunks disagree on the rows
}
// Classify every zone first, so the IN count is known before any boundary decode. The
- // fold's only win over a scan is folding IN zones from statistics with no decode; a
- // BOUNDARY zone is decoded exactly as a scan would decode it. So when there is at least
- // one BOUNDARY zone but no IN zone, the fold decodes the same chunks a scan does and adds
- // the stats-table classify + mask-building overhead on top — a net loss. Abandon to the
- // (zone-map-pruned) scan in that case. The all-OUT case (no IN, no BOUNDARY) is NOT this
- // case: it folds to a correct empty/zero aggregate with no decode at all, so it is kept.
- Match[] matches = new Match[zones];
+ // fold's only win over a scan is folding IN zones from statistics with no decode, so
+ // when there is at least one BOUNDARY zone but no IN zone, abandon to the
+ // (zone-map-pruned) scan. The all-OUT case (no IN, no BOUNDARY) folds to a correct
+ // empty/zero aggregate with no decode at all, so it is kept.
+ Match[] matches = new Match[geometry.size()];
int inZones = 0;
int boundaryZones = 0;
- for (int zone = 0; zone < zones; zone++) {
- Match match = classify(filter, zone, zoneStats, rowCounts[zone], struct);
- matches[zone] = match;
+ for (int z = 0; z < geometry.size(); z++) {
+ Match match = classify(filter, z, zoneStats, geometry.get(z).rowCount(), struct);
+ matches[z] = match;
if (match == Match.IN) {
inZones++;
} else if (match == Match.BOUNDARY) {
@@ -260,27 +311,32 @@ public Optional filteredFold(RowFilter filter, ColumnName aggColum
List decodeColumns = new ArrayList<>(columns);
Fold fold = new Fold();
- for (int zone = 0; zone < zones; zone++) {
- Match match = matches[zone];
- if (match == Match.OUT) {
- continue;
- }
- if (match == Match.BOUNDARY) {
- // Tier 2: a partially-selected zone. Decode this chunk (and the filter columns),
- // build a row mask from the filter, and reduce the masked rows. The chunk index
- // equals the zone index — the perZone.size() == zones guard above proved the
- // zone-map rows align 1:1 with the chunks.
- foldBoundaryZone(reader, zone, decodeColumns, filter, aggColumn, fold);
- continue;
- }
- // Tier 1 (Match.IN): every row matches — fold the whole zone from its statistics.
- fold.keptRows += rowCounts[zone];
- if (aggColumn != null) {
- ArrayStats stats = zoneStats.get(aggColumn).get(zone);
- fold.addSum(stats.sum());
- fold.addMin(stats.min());
- fold.addMax(stats.max());
- fold.addNulls(stats.nullCount());
+ try (BoundaryChunks chunks = new BoundaryChunks(reader, decodeColumns, chunkRows)) {
+ for (int z = 0; z < geometry.size(); z++) {
+ Zone zone = geometry.get(z);
+ if (matches[z] == Match.OUT) {
+ continue;
+ }
+ if (matches[z] == Match.BOUNDARY) {
+ // Tier 2: fold the rows the filter selects, from the zone's rows of the
+ // chunks holding it — a zone may straddle a chunk edge.
+ if (!chunks.fold(zone, filter, aggColumn, fold)) {
+ return Optional.empty();
+ }
+ continue;
+ }
+ // Tier 1 (Match.IN): every row matches — fold the whole zone from its statistics.
+ fold.keptRows += zone.rowCount();
+ if (aggColumn != null) {
+ ArrayStats stats = zoneStats.get(aggColumn).get(z);
+ // a zone with values but no min/max stat (a string zone holds only bounds)
+ // leaves the extreme unknown, rather than silently ignored
+ boolean hasValues = stats.nullCount() == null || stats.nullCount() < zone.rowCount();
+ fold.addSum(stats.sum());
+ fold.addMin(stats.min(), hasValues);
+ fold.addMax(stats.max(), hasValues);
+ fold.addNulls(stats.nullCount());
+ }
}
}
return Optional.of(fold.result());
@@ -289,30 +345,79 @@ public Optional filteredFold(RowFilter filter, ColumnName aggColum
}
}
- /// Decodes one boundary chunk and folds the aggregate over the rows the filter selects in it,
- /// contributing to the running [Fold]. The chunk is released per zone via try-with-resources, so
- /// nothing outlives the reduction.
- ///
- /// The whole filter — an n-ary `AND` of column-bound [Predicate] leaves — and the aggregate fold
- /// run in one fused pass over the chunk's rows via
- /// [Compute#filteredAggregate(Chunk, RowFilter, ColumnName)], with no intermediate selection bitmap.
- /// `SUM` is guarded: the fused kernel reports a `null` sum for a non-numeric aggregate column,
- /// which disables the sum (mirroring a zone that records no usable sum) rather than failing the
- /// whole fold — a `MIN`/`MAX` over the same column still folds. The aggregate column's null count
- /// among the selected rows is the selected count minus the non-null count.
- private static void foldBoundaryZone(VortexReader reader, int zone, List columns, RowFilter filter, ColumnName aggColumn, Fold fold) {
- try (Chunk chunk = reader.decodeChunk(zone, columns)) {
- FilteredAggregate aggregate = Compute.filteredAggregate(chunk, filter, aggColumn);
- fold.keptRows += aggregate.selectedRows();
- if (aggColumn == null) {
+ /// Decodes the chunks that hold boundary zones, each once, in row order, and folds a zone's rows
+ /// through a [Chunk#slice(long, long)] view. Zones arrive in row order, so only the chunk under
+ /// the current zone is kept open.
+ private static final class BoundaryChunks implements AutoCloseable {
+ private final VortexReader reader;
+ private final List columns;
+ private final long[] chunkRows;
+ private int index = -1;
+ private long chunkStart;
+ private Chunk chunk;
+
+ BoundaryChunks(VortexReader reader, List columns, long[] chunkRows) {
+ this.reader = reader;
+ this.columns = columns;
+ this.chunkRows = chunkRows;
+ }
+
+ /// Folds the rows `filter` selects in `zone`, in one fused pass per chunk part via
+ /// [Compute#filteredAggregate(Chunk, RowFilter, ColumnName)]. `SUM` is guarded: the kernel
+ /// reports a `null` sum for a non-numeric aggregate column, which disables only the sum.
+ ///
+ /// @return `false` when a column cannot be sliced, so the caller abandons to the scan
+ boolean fold(Zone zone, RowFilter filter, ColumnName aggColumn, Fold fold) {
+ long row = zone.firstRow();
+ long end = row + zone.rowCount();
+ while (row < end) {
+ moveTo(row);
+ long offset = row - chunkStart;
+ long length = Math.min(end, chunkStart + chunkRows[index]) - row;
+ FilteredAggregate aggregate;
+ try (Chunk part = chunk.slice(offset, length)) {
+ aggregate = Compute.filteredAggregate(part, filter, aggColumn);
+ } catch (VortexException e) {
+ // ponytail: some array types (F16, variant) have no slice yet; the scan answers
+ return false;
+ }
+ fold.keptRows += aggregate.selectedRows();
+ if (aggColumn != null) {
+ // the kernel's min/max are null only when no selected row has a value
+ fold.addSum(aggregate.sum());
+ fold.addMin(aggregate.min(), false);
+ fold.addMax(aggregate.max(), false);
+ fold.addNulls(aggregate.selectedRows() - aggregate.aggNonNullCount());
+ }
+ row += length;
+ }
+ return true;
+ }
+
+ /// Opens the chunk holding `row`; `tiles` proved every row lies in some chunk.
+ private void moveTo(long row) {
+ if (chunk != null && row < chunkStart + chunkRows[index]) {
return;
}
- // A null sum marks a non-numeric aggregate column — unanswerable, like a missing zone sum;
- // addSum(null) disables only the sum, leaving MIN/MAX to fold.
- fold.addSum(aggregate.sum());
- fold.addMin(aggregate.min());
- fold.addMax(aggregate.max());
- fold.addNulls(aggregate.selectedRows() - aggregate.aggNonNullCount());
+ if (chunk != null) {
+ chunk.close();
+ chunk = null;
+ }
+ if (index < 0) {
+ index = 0;
+ }
+ while (row >= chunkStart + chunkRows[index]) {
+ chunkStart += chunkRows[index];
+ index++;
+ }
+ chunk = reader.decodeChunk(index, columns);
+ }
+
+ @Override
+ public void close() {
+ if (chunk != null) {
+ chunk.close();
+ }
}
}
@@ -327,6 +432,7 @@ private static final class Fold {
private boolean sumUsable = true;
private Object min;
private Object max;
+ private boolean extremesKnown = true;
private long nullCount;
private boolean nullCountKnown = true;
private long keptRows;
@@ -348,11 +454,18 @@ private void addSum(Object contribution) {
}
}
- private void addMin(Object candidate) {
+ /// Adds a min candidate; a `null` one from rows that do hold values makes the min unknown.
+ private void addMin(Object candidate, boolean hasValues) {
+ if (candidate == null && hasValues) {
+ extremesKnown = false;
+ }
min = pickExtreme(min, candidate, true);
}
- private void addMax(Object candidate) {
+ private void addMax(Object candidate, boolean hasValues) {
+ if (candidate == null && hasValues) {
+ extremesKnown = false;
+ }
max = pickExtreme(max, candidate, false);
}
@@ -378,7 +491,10 @@ private FilteredFold result() {
foldedSum = longSum;
}
Long foldedNulls = nullCountKnown ? nullCount : null;
- return new FilteredFold(foldedSum, min, max, foldedNulls, keptRows);
+ // an unknown extreme is reported as absent, which the rule cannot mistake for an empty
+ // selection while rows were kept
+ return new FilteredFold(foldedSum, extremesKnown ? min : null, extremesKnown ? max : null,
+ foldedNulls, keptRows);
}
}
@@ -399,6 +515,55 @@ public Optional translatePushedFilters(List filters) {
return RexFilterTranslator.translateStrict(filters, names, types);
}
+ /// A column's zones: its zone map when the zones can be placed on rows, else one zone per chunk
+ /// from the chunks' own statistics (the legacy `vortex.stats` table with batches of mixed
+ /// length, or no zone map at all). `null` when neither lines up with the chunks.
+ private static List zonesOf(ScanIterator scan, ColumnName column, long[] chunkRows) {
+ List zones = scan.columnZones(column);
+ if (!zones.isEmpty()) {
+ return zones;
+ }
+ List perChunk = scan.columnZoneStats(column);
+ if (perChunk.size() != chunkRows.length) {
+ return null;
+ }
+ List out = new ArrayList<>(chunkRows.length);
+ long first = 0;
+ for (int c = 0; c < chunkRows.length; c++) {
+ out.add(new Zone(first, chunkRows[c], perChunk.get(c)));
+ first += chunkRows[c];
+ }
+ return out;
+ }
+
+ private static boolean sameRows(List a, List b) {
+ if (a.size() != b.size()) {
+ return false;
+ }
+ for (int i = 0; i < a.size(); i++) {
+ if (a.get(i).firstRow() != b.get(i).firstRow() || a.get(i).rowCount() != b.get(i).rowCount()) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /// Whether `zones` cover the chunks' rows exactly, in order and with no gap or overlap.
+ private static boolean tiles(List zones, long[] chunkRows) {
+ long next = 0;
+ for (Zone zone : zones) {
+ if (zone.firstRow() != next) {
+ return false;
+ }
+ next += zone.rowCount();
+ }
+ long total = 0;
+ for (long rows : chunkRows) {
+ total += rows;
+ }
+ return next == total;
+ }
+
/// How a zone relates to a filter: every row matches ([Match#IN]), no row matches
/// ([Match#OUT]), or the zone straddles the predicate ([Match#BOUNDARY]) and can be folded only
/// by decoding it.
@@ -455,10 +620,9 @@ private static Match classifyColumn(Predicate predicate, ArrayStats s, long rowC
/// [Predicate] unification: an unrecognized stat shape or a partially-overlapping zone is
/// [Match#BOUNDARY], a zone provably outside the predicate is [Match#OUT], and a zone every row
/// of which matches (which, for a value comparison, also requires the zone to carry no nulls) is
- /// [Match#IN]. The composite and range predicates ([Predicate.Between] / [Predicate.And] /
- /// [Predicate.Or]) cannot arise from the Calcite translation — `SEARCH` / `BETWEEN` expand to an
- /// `AND` of single-leaf columns — so they fall through to [Match#BOUNDARY], which decodes and
- /// applies the kernel mask and is therefore always correct.
+ /// [Match#IN]. A single-column [Predicate.And] / [Predicate.Or] (an `IN` list, a range union)
+ /// combines its sides' verdicts; [Predicate.Between] cannot arise from the Calcite translation
+ /// and stays [Match#BOUNDARY], which decodes and is therefore always correct.
///
/// @param predicate the column-bound value-test
/// @param s the zone's statistics for that column
@@ -519,8 +683,22 @@ private static Match classifyColumnRaw(Predicate predicate, ArrayStats s, long r
yield nulls == rowCount ? Match.OUT : Match.BOUNDARY;
}
case Predicate.Between _ -> Match.BOUNDARY;
- case Predicate.And _ -> Match.BOUNDARY;
- case Predicate.Or _ -> Match.BOUNDARY;
+ case Predicate.And(var left, var right) -> {
+ Match l = classifyColumnRaw(left, s, rowCount);
+ Match r = classifyColumnRaw(right, s, rowCount);
+ if (l == Match.OUT || r == Match.OUT) {
+ yield Match.OUT;
+ }
+ yield l == Match.IN && r == Match.IN ? Match.IN : Match.BOUNDARY;
+ }
+ case Predicate.Or(var left, var right) -> {
+ Match l = classifyColumnRaw(left, s, rowCount);
+ Match r = classifyColumnRaw(right, s, rowCount);
+ if (l == Match.IN || r == Match.IN) {
+ yield Match.IN;
+ }
+ yield l == Match.OUT && r == Match.OUT ? Match.OUT : Match.BOUNDARY;
+ }
};
}
@@ -699,14 +877,7 @@ public Enumerator