diff --git a/TODO.md b/TODO.md
index 5cf4c394..2068b6e9 100644
--- a/TODO.md
+++ b/TODO.md
@@ -6,6 +6,16 @@
- [ ] Create website
- build something like hardwood.dev but for vortex files
+## Testing
+
+- [ ] **Finish OHLC test-data dedup** — the random-walk generator is single-sourced in
+ `core.testing.OhlcData` (core test-jar). `integration`'s `OhlcGenerator` is now a thin adapter
+ that maps `OhlcData.Batch` to its own `OhlcBatch` (`symbols`/`dates` field names) only to avoid
+ churning the JNI/Arrow callers. Align fully: drop `OhlcGenerator`/`OhlcBatch`, switch the
+ integration callers (`FileSizeComparisonIntegrationTest`, `JavaWritesRustReadsIntegrationTest`)
+ to `OhlcData.Batch` directly so there is one shape, not two. Verify with the JNI integration
+ suite (needs vortex-jni native libs).
+
## Performance
- [ ] **Benchmark publishing** — drop CI workflow, add `bench-publish` script; see [ADR-0006](docs/adr/0006-benchmark-publishing.md).
diff --git a/calcite/pom.xml b/calcite/pom.xml
new file mode 100644
index 00000000..c8073a94
--- /dev/null
+++ b/calcite/pom.xml
@@ -0,0 +1,58 @@
+
+
+ 4.0.0
+
+ io.github.dfa1.vortex
+ vortex-java
+ 0.9.1-SNAPSHOT
+
+
+ vortex-calcite
+
+ vortex-calcite
+ Apache Calcite SQL adapter over the Vortex columnar file format (demo: filter/project/aggregate push-down).
+
+
+ 1.40.0
+
+
+
+
+
+ io.github.dfa1.vortex
+ vortex-reader
+
+
+ org.apache.calcite
+ calcite-core
+ ${calcite.version}
+
+
+
+ io.github.dfa1.vortex
+ vortex-core
+ test-jar
+ test
+
+
+ io.github.dfa1.vortex
+ vortex-writer
+ test
+
+
+ io.airlift
+ aircompressor-v3
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+ org.assertj
+ assertj-core
+ test
+
+
+
diff --git a/calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexAggregatePushDownRule.java b/calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexAggregatePushDownRule.java
new file mode 100644
index 00000000..edabaeb9
--- /dev/null
+++ b/calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexAggregatePushDownRule.java
@@ -0,0 +1,198 @@
+package io.github.dfa1.vortex.calcite;
+
+import io.github.dfa1.vortex.reader.ArrayStats;
+
+import com.google.common.collect.ImmutableList;
+import org.apache.calcite.interpreter.Bindables;
+import org.apache.calcite.plan.RelOptRule;
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelOptRuleOperand;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Aggregate;
+import org.apache.calcite.rel.core.AggregateCall;
+import org.apache.calcite.rel.core.Project;
+import org.apache.calcite.rel.core.TableScan;
+import org.apache.calcite.rel.logical.LogicalValues;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rel.type.RelDataTypeField;
+import org.apache.calcite.rex.RexBuilder;
+import org.apache.calcite.rex.RexInputRef;
+import org.apache.calcite.rex.RexLiteral;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.sql.SqlKind;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+
+/// Rewrites a whole-table `MIN`/`MAX`/`COUNT` aggregate over a [VortexTable] into a single-row
+/// [LogicalValues] computed from the footer zone-map statistics — answering the query without
+/// decoding a single data segment (ADR 0013 §6, ADR 0018 Phase 2).
+///
+/// Fires only when it can answer *every* aggregate from statistics: no `GROUP BY`, and each
+/// call is `COUNT(*)`, `COUNT(col)`, `MIN(col)`, or `MAX(col)` over a numeric column. Anything
+/// else (e.g. `SUM`, a grouped aggregate, `MIN` on a non-numeric column) leaves the plan
+/// untouched for the normal scan path. `SUM`/`AVG` join this tier once the writer emits a
+/// per-zone `SUM` statistic.
+// Calcite 1.40 removed RelRule.Config.EMPTY; the modern RelRule.Config path requires the
+// Immutables annotation processor. The classic operand() constructor is deprecated but fully
+// supported and far lighter for a single adapter rule — suppression is localized and justified.
+@SuppressWarnings("deprecation")
+public final class VortexAggregatePushDownRule extends RelOptRule {
+
+ /// Matches `Aggregate(Project(TableScan))` — the shape Calcite produces when columns are
+ /// selected before aggregation (e.g. `MIN(low)`).
+ public static final VortexAggregatePushDownRule WITH_PROJECT = new VortexAggregatePushDownRule(
+ operand(Aggregate.class, operand(Project.class, operand(TableScan.class, none()))),
+ "VortexAggregatePushDownRule:project");
+
+ /// Matches `Aggregate(TableScan)` — e.g. a bare `COUNT(*)` with no projected columns.
+ public static final VortexAggregatePushDownRule NO_PROJECT = new VortexAggregatePushDownRule(
+ operand(Aggregate.class, operand(TableScan.class, none())),
+ "VortexAggregatePushDownRule:scan");
+
+ /// Every rule variant, for registering with a planner in one call.
+ public static final java.util.List RULES = java.util.List.of(WITH_PROJECT, NO_PROJECT);
+
+ private VortexAggregatePushDownRule(RelOptRuleOperand operand, String description) {
+ super(operand, description);
+ }
+
+ @Override
+ public void onMatch(RelOptRuleCall call) {
+ Aggregate aggregate = call.rel(0);
+ if (aggregate.getGroupCount() != 0) {
+ return;
+ }
+ // Explicit operands give concrete rels under both Hep and Volcano: rel(1) is either the
+ // Project (then rel(2) is the scan) or the scan directly.
+ Project project;
+ TableScan scan;
+ if (call.rel(1) instanceof Project p) {
+ project = p;
+ scan = call.rel(2);
+ } else {
+ project = null;
+ scan = call.rel(1);
+ }
+ VortexTable table = scan.getTable().unwrap(VortexTable.class);
+ if (table == null) {
+ return;
+ }
+ // Whole-table stats are only valid for a whole-table scan. If a WHERE predicate was pushed
+ // into the scan (BindableTableScan.filters), answering from stats would ignore it and return
+ // the wrong MIN/MAX/COUNT — leave the plan to compute it over the filtered rows.
+ if (scan instanceof Bindables.BindableTableScan bindable && !bindable.filters.isEmpty()) {
+ return;
+ }
+ RelDataType scanRowType = scan.getRowType();
+ List scanColumns = scanRowType.getFieldNames();
+
+ RexBuilder rexBuilder = aggregate.getCluster().getRexBuilder();
+ List outTypes = aggregate.getRowType().getFieldList().stream()
+ .map(f -> f.getType()).toList();
+
+ List row = new ArrayList<>();
+ List calls = aggregate.getAggCallList();
+ for (int i = 0; i < calls.size(); i++) {
+ RexLiteral literal = evaluate(calls.get(i), outTypes.get(i), table, scanColumns, scanRowType,
+ project, rexBuilder);
+ if (literal == null) {
+ return; // an aggregate we can't answer from stats — abandon the rewrite
+ }
+ row.add(literal);
+ }
+
+ RelNode values = LogicalValues.create(
+ aggregate.getCluster(), aggregate.getRowType(),
+ ImmutableList.of(ImmutableList.copyOf(row)));
+ call.transformTo(values);
+ }
+
+ /// Evaluates one aggregate call from zone-map stats, returning a literal of `outType`, or
+ /// `null` if it cannot be answered (so the caller abandons the rewrite).
+ private static RexLiteral evaluate(AggregateCall agg, RelDataType outType, VortexTable table,
+ List scanColumns, RelDataType scanRowType,
+ Project project, RexBuilder rexBuilder) {
+ return switch (agg.getAggregation().getKind()) {
+ case COUNT -> {
+ if (agg.getArgList().isEmpty()) {
+ yield exact(rexBuilder, table.totalRows(), outType); // COUNT(*)
+ }
+ String col = resolveColumn(agg.getArgList().getFirst(), scanColumns, project);
+ if (col == null) {
+ yield null;
+ }
+ Long nullCount = table.statsOf(col).nullCount();
+ // COUNT(col) = rows − nulls. Without a NULL_COUNT stat we cannot assume zero nulls
+ // for a nullable column (we would overcount), so abandon; a non-nullable column has
+ // no nulls and is safe.
+ if (nullCount == null && isNullable(scanRowType, col)) {
+ yield null;
+ }
+ long nulls = nullCount == null ? 0L : nullCount;
+ yield exact(rexBuilder, table.totalRows() - nulls, outType);
+ }
+ case MIN, MAX -> {
+ if (agg.getArgList().size() != 1) {
+ yield null;
+ }
+ String col = resolveColumn(agg.getArgList().getFirst(), scanColumns, project);
+ if (col == null) {
+ yield null;
+ }
+ ArrayStats stats = table.statsOf(col);
+ Object value = agg.getAggregation().getKind() == SqlKind.MIN ? stats.min() : stats.max();
+ if (value == null) {
+ // No MIN/MAX stat. A genuine SQL NULL is only correct when the column provably has
+ // no non-null rows (empty table, or every row null); otherwise the stat is merely
+ // absent and we must abandon so the scan computes the real value.
+ long total = table.totalRows();
+ Long nullCount = stats.nullCount();
+ boolean provablyNoValues = total == 0 || (nullCount != null && nullCount == total);
+ yield provablyNoValues ? rexBuilder.makeNullLiteral(outType) : null;
+ }
+ yield numericLiteral(rexBuilder, value, outType);
+ }
+ default -> null;
+ };
+ }
+
+ /// Returns whether column `col` is nullable per the scan's row type.
+ private static boolean isNullable(RelDataType scanRowType, String col) {
+ RelDataTypeField field = scanRowType.getField(col, true, false);
+ return field == null || field.getType().isNullable();
+ }
+
+ /// Maps an aggregate input ordinal to a scan column name, looking through a `Project` of input
+ /// refs when present. Returns `null` if the ordinal is a computed expression, not a column.
+ private static String resolveColumn(int aggInput, List scanColumns, Project project) {
+ if (project == null) {
+ return aggInput < scanColumns.size() ? scanColumns.get(aggInput) : null;
+ }
+ RexNode expr = project.getProjects().get(aggInput);
+ if (expr instanceof RexInputRef ref && ref.getIndex() < scanColumns.size()) {
+ return scanColumns.get(ref.getIndex());
+ }
+ return null;
+ }
+
+ private static RexLiteral exact(RexBuilder rexBuilder, long value, RelDataType type) {
+ return rexBuilder.makeExactLiteral(BigDecimal.valueOf(value), type);
+ }
+
+ /// Builds a literal for a non-null `MIN`/`MAX` value, supporting only numeric output types
+ /// (exact and approximate). A non-numeric value yields `null` so the rule abandons the rewrite.
+ private static RexLiteral numericLiteral(RexBuilder rexBuilder, Object value, RelDataType type) {
+ if (!(value instanceof Number number)) {
+ return null;
+ }
+ return switch (type.getSqlTypeName()) {
+ case TINYINT, SMALLINT, INTEGER, BIGINT ->
+ rexBuilder.makeExactLiteral(BigDecimal.valueOf(number.longValue()), type);
+ case FLOAT, REAL, DOUBLE, DECIMAL ->
+ rexBuilder.makeApproxLiteral(BigDecimal.valueOf(number.doubleValue()), type);
+ default -> null;
+ };
+ }
+}
diff --git a/calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexAggregates.java b/calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexAggregates.java
new file mode 100644
index 00000000..a9f47ae5
--- /dev/null
+++ b/calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexAggregates.java
@@ -0,0 +1,125 @@
+package io.github.dfa1.vortex.calcite;
+
+import io.github.dfa1.vortex.reader.ArrayStats;
+import io.github.dfa1.vortex.reader.Chunk;
+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.array.Array;
+import io.github.dfa1.vortex.reader.array.DoubleArray;
+import io.github.dfa1.vortex.reader.array.FloatArray;
+import io.github.dfa1.vortex.reader.array.IntArray;
+import io.github.dfa1.vortex.reader.array.LongArray;
+
+/// Column aggregates answered with the cheapest available source.
+///
+/// `MIN` / `MAX` / `COUNT` are read from the per-segment zone-map statistics embedded in the
+/// file footer — no data segment is decoded. `SUM` (and therefore `AVG`) has no zone statistic
+/// in the current writer, so it falls back to a streaming scan. This split is the whole point
+/// of the demo: the stats-backed aggregates are effectively free, and `SUM`/`AVG` show what the
+/// next writer increment (emit a per-zone `SUM`) would make free too (ADR 0013 §6).
+public final class VortexAggregates {
+
+ /// Where an aggregate's value came from.
+ public enum Source {
+ /// Read from zone-map statistics in the footer; no data decoded.
+ ZONE_STATS_PUSHDOWN,
+ /// Computed by streaming the column's data segments (no usable statistic).
+ FULL_SCAN
+ }
+
+ /// A column's aggregate summary plus where each part was sourced.
+ ///
+ /// `sum` is a [Long] for integer columns (exact, matching SQL `SUM(BIGINT)` which also wraps
+ /// at 2^63) and a [Double] for floating-point columns — never a `double` accumulation of
+ /// integers, which would lose precision past 2^53.
+ ///
+ /// @param column the column name
+ /// @param min minimum value (boxed `Double`/`Long`), or `null` if unknown
+ /// @param max maximum value, or `null` if unknown
+ /// @param count count of non-null rows
+ /// @param sum sum of values (`Long` for integer columns, `Double` for floats), or `null`
+ /// @param avg mean (`sum / count`), or `null` if not computed
+ /// @param minMaxSource source of `min`/`max`/`count`
+ /// @param sumSource source of `sum`/`avg`
+ public record Summary(String column, Object min, Object max, long count, Number sum, Double avg,
+ Source minMaxSource, Source sumSource) {
+ }
+
+ private VortexAggregates() {
+ }
+
+ /// Computes `MIN`/`MAX`/`COUNT`/`SUM`/`AVG` for a numeric column, pushing down what the zone
+ /// statistics allow and scanning only for `SUM`/`AVG`.
+ ///
+ /// @param reader an open reader over the file
+ /// @param column the numeric column name
+ /// @return the column's aggregate summary
+ public static Summary of(VortexReader reader, String column) {
+ ArrayStats stats = reader.columnStats().getOrDefault(column, ArrayStats.empty());
+ long totalRows = totalRows(reader);
+ long nullCount = stats.nullCount() == null ? 0L : stats.nullCount();
+ long count = totalRows - nullCount;
+
+ // MIN/MAX/COUNT: straight from footer zone-map stats, no data segment touched.
+ Object min = stats.min();
+ Object max = stats.max();
+
+ // SUM/AVG: no SUM zone-stat exists today, so stream the column once. Integer columns sum
+ // into a long (exact); floating columns into a double.
+ Number sum = scanSum(reader, column);
+ Double avg = count == 0 ? null : sum.doubleValue() / count;
+
+ return new Summary(column, min, max, count, sum, avg,
+ Source.ZONE_STATS_PUSHDOWN, Source.FULL_SCAN);
+ }
+
+ private static long totalRows(VortexReader reader) {
+ try (ScanIterator scan = reader.scan(ScanOptions.all())) {
+ long total = 0L;
+ for (long c : scan.chunkRowCounts()) {
+ total += c;
+ }
+ return total;
+ }
+ }
+
+ private static Number scanSum(VortexReader reader, String column) {
+ long longSum = 0L;
+ double doubleSum = 0.0;
+ boolean isFloating = false;
+ try (ScanIterator scan = reader.scan(ScanOptions.columns(column))) {
+ while (scan.hasNext()) {
+ try (Chunk chunk = scan.next()) {
+ long n = chunk.rowCount();
+ switch (chunk.column(column)) {
+ case LongArray a -> {
+ for (long i = 0; i < n; i++) {
+ longSum += a.getLong(i);
+ }
+ }
+ case IntArray a -> {
+ for (long i = 0; i < n; i++) {
+ longSum += a.getInt(i);
+ }
+ }
+ case DoubleArray a -> {
+ isFloating = true;
+ for (long i = 0; i < n; i++) {
+ doubleSum += a.getDouble(i);
+ }
+ }
+ case FloatArray a -> {
+ isFloating = true;
+ for (long i = 0; i < n; i++) {
+ doubleSum += a.getFloat(i);
+ }
+ }
+ default -> throw new IllegalArgumentException("not a numeric column: " + column);
+ }
+ }
+ }
+ }
+ return isFloating ? Double.valueOf(doubleSum) : Long.valueOf(longSum);
+ }
+}
diff --git a/calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexSchema.java b/calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexSchema.java
new file mode 100644
index 00000000..df8be495
--- /dev/null
+++ b/calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexSchema.java
@@ -0,0 +1,48 @@
+package io.github.dfa1.vortex.calcite;
+
+import org.apache.calcite.schema.Table;
+import org.apache.calcite.schema.impl.AbstractSchema;
+
+import java.nio.file.Path;
+import java.util.Map;
+
+/// A Calcite schema exposing one or more Vortex files as SQL tables.
+///
+/// Each entry maps a SQL table name to the `.vortex` file backing it:
+///
+/// ```java
+/// rootSchema.add("vtx", new VortexSchema(Map.of("ohlc", path)));
+/// // SELECT symbol, max(high) FROM vtx.ohlc GROUP BY symbol
+/// ```
+public final class VortexSchema extends AbstractSchema {
+
+ private final Map tables;
+
+ /// Creates a schema from a map of SQL table name to backing Vortex file.
+ ///
+ /// @param files map from table name to the `.vortex` file path
+ public VortexSchema(Map files) {
+ this.tables = files.entrySet().stream()
+ .collect(java.util.stream.Collectors.toUnmodifiableMap(
+ Map.Entry::getKey, e -> new VortexTable(e.getValue())));
+ }
+
+ @Override
+ protected Map getTableMap() {
+ return tables;
+ }
+
+ /// Returns the [VortexTable] registered under `name` — useful for reading push-down
+ /// instrumentation such as [VortexTable#chunksScannedLastQuery()].
+ ///
+ /// @param name the SQL table name
+ /// @return the backing table
+ /// @throws IllegalArgumentException if no table is registered under `name`
+ public VortexTable table(String name) {
+ Table t = tables.get(name);
+ if (!(t instanceof VortexTable vortexTable)) {
+ throw new IllegalArgumentException("no Vortex table named: " + name);
+ }
+ return vortexTable;
+ }
+}
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
new file mode 100644
index 00000000..20f2a402
--- /dev/null
+++ b/calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexTable.java
@@ -0,0 +1,392 @@
+package io.github.dfa1.vortex.calcite;
+
+import io.github.dfa1.vortex.core.model.DType;
+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.array.BoolArray;
+import io.github.dfa1.vortex.reader.array.DoubleArray;
+import io.github.dfa1.vortex.reader.array.FloatArray;
+import io.github.dfa1.vortex.reader.array.IntArray;
+import io.github.dfa1.vortex.reader.array.LongArray;
+import io.github.dfa1.vortex.reader.array.VarBinArray;
+
+import org.apache.calcite.DataContext;
+import org.apache.calcite.jdbc.JavaTypeFactoryImpl;
+import org.apache.calcite.linq4j.AbstractEnumerable;
+import org.apache.calcite.linq4j.Enumerable;
+import org.apache.calcite.linq4j.Enumerator;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rel.type.RelDataTypeFactory;
+import org.apache.calcite.rex.RexBuilder;
+import org.apache.calcite.rex.RexCall;
+import org.apache.calcite.rex.RexInputRef;
+import org.apache.calcite.rex.RexLiteral;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.schema.ProjectableFilterableTable;
+import org.apache.calcite.schema.impl.AbstractTable;
+import org.apache.calcite.sql.type.SqlTypeName;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicLong;
+
+/// A single Vortex file exposed to Calcite as a flat SQL table with column projection and
+/// zone-map filter push-down.
+///
+/// Projection (`projects`) is honoured exactly — only the requested columns are decoded and
+/// returned. Filters (`filters`) that translate to a [RowFilter] are pushed into the scan for
+/// *chunk skipping* via zone-map statistics, but are **left in Calcite's list** rather than
+/// consumed: zone-map pruning is approximate (it drops whole chunks that cannot match, not
+/// individual rows), so Calcite must still apply the predicate row-by-row for exactness. The
+/// win is decoding far fewer chunks when the filter is selective on a clustered column.
+public final class VortexTable extends AbstractTable implements ProjectableFilterableTable {
+
+ /// Used only to expand `SEARCH`/`Sarg` predicates (e.g. `BETWEEN`, `IN`) back into ordinary
+ /// comparison trees the [RowFilter] translation understands.
+ private static final RexBuilder REX_BUILDER = new RexBuilder(new JavaTypeFactoryImpl());
+
+ private final Path file;
+ private final AtomicLong chunksScannedLastQuery = new AtomicLong();
+
+ /// Creates a table backed by the Vortex file at `file`.
+ ///
+ /// @param file path to the `.vortex` file
+ public VortexTable(Path file) {
+ this.file = file;
+ }
+
+ /// Number of chunks actually decoded by the most recent [#scan] — the rest were pruned by
+ /// zone-map statistics. Used by the demo to show push-down skipping work.
+ ///
+ /// @return chunks decoded in the last query
+ public long chunksScannedLastQuery() {
+ return chunksScannedLastQuery.get();
+ }
+
+ /// Per-column zone-map statistics (global min/max and null count), read from the footer
+ /// without decoding data. Used by the aggregate push-down rule to answer `MIN`/`MAX`/`COUNT`.
+ ///
+ /// @param column the column name
+ /// @return the column's aggregated statistics
+ public io.github.dfa1.vortex.reader.ArrayStats statsOf(String column) {
+ try (VortexReader reader = VortexReader.open(file)) {
+ return reader.columnStats().getOrDefault(column, io.github.dfa1.vortex.reader.ArrayStats.empty());
+ } catch (IOException e) {
+ throw new UncheckedIOException("cannot read stats of " + file, e);
+ }
+ }
+
+ /// Total row count across all chunks, read from chunk metadata without decoding data.
+ ///
+ /// @return the number of rows in the file
+ public long totalRows() {
+ try (VortexReader reader = VortexReader.open(file);
+ ScanIterator scan = reader.scan(ScanOptions.all())) {
+ long total = 0;
+ for (long c : scan.chunkRowCounts()) {
+ total += c;
+ }
+ return total;
+ } catch (IOException e) {
+ throw new UncheckedIOException("cannot count rows of " + file, e);
+ }
+ }
+
+ @Override
+ public RelDataType getRowType(RelDataTypeFactory typeFactory) {
+ DType.Struct struct = struct();
+ RelDataTypeFactory.Builder builder = typeFactory.builder();
+ for (int i = 0; i < struct.fieldNames().size(); i++) {
+ builder.add(struct.fieldNames().get(i), toSqlType(typeFactory, struct.fieldTypes().get(i)));
+ }
+ return builder.build();
+ }
+
+ @Override
+ public Enumerable