Skip to content

Commit 22d3660

Browse files
dfa1claude
andcommitted
feat(calcite): vortex-calcite — SQL adapter with zone-map push-down
New `vortex-calcite` module (Apache Calcite 1.40) exposing a Vortex file as a SQL table, with filter/projection/aggregate push-down into the reader's existing zone-map primitives. ADR 0018 records the decision: be a push-down source, not a query engine. - VortexTable (ProjectableFilterableTable): DType.Struct -> SQL row type; projection prunes columns; Calcite predicates (=,<>,<,<=,>,>=,AND,BETWEEN,IN via RexUtil.expandSearch) translate to a reader RowFilter for zone-map chunk skipping (pushed, not consumed — pruning is approximate, Calcite re-checks rows). - VortexAggregatePushDownRule: rewrites a whole-table MIN/MAX/COUNT over a VortexTable into a single-row Values computed from footer stats — no scan, no decode. Registered end-to-end on the JDBC planner via Hook.PLANNER. - VortexAggregates / VortexSchema: stats-backed helpers; sum is exact Long for integer columns (no double precision loss). - Demos (OhlcSqlDemoTest, AggregatePushDownTest): 1M-row OHLC, MIN/MAX/COUNT ~44x vs full scan; date-range filter prunes 99% of chunks; EXPLAIN shows the rewrite. - CalciteSmokeTest gates Calcite/Janino runtime codegen on JDK 25. Heavy Calcite deps quarantined in this module; core/reader/writer stay clean. OHLC test data is single-sourced in core.testing.OhlcData (core test-jar), reused by calcite and (via a thin adapter) integration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 58c2e37 commit 22d3660

16 files changed

Lines changed: 1644 additions & 45 deletions

File tree

TODO.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@
66
- [ ] Create website
77
- build something like hardwood.dev but for vortex files
88

9+
## Testing
10+
11+
- [ ] **Finish OHLC test-data dedup** — the random-walk generator is single-sourced in
12+
`core.testing.OhlcData` (core test-jar). `integration`'s `OhlcGenerator` is now a thin adapter
13+
that maps `OhlcData.Batch` to its own `OhlcBatch` (`symbols`/`dates` field names) only to avoid
14+
churning the JNI/Arrow callers. Align fully: drop `OhlcGenerator`/`OhlcBatch`, switch the
15+
integration callers (`FileSizeComparisonIntegrationTest`, `JavaWritesRustReadsIntegrationTest`)
16+
to `OhlcData.Batch` directly so there is one shape, not two. Verify with the JNI integration
17+
suite (needs vortex-jni native libs).
18+
919
## Performance
1020

1121
- [ ] **Benchmark publishing** — drop CI workflow, add `bench-publish` script; see [ADR-0006](docs/adr/0006-benchmark-publishing.md).

calcite/pom.xml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
3+
<modelVersion>4.0.0</modelVersion>
4+
<parent>
5+
<groupId>io.github.dfa1.vortex</groupId>
6+
<artifactId>vortex-java</artifactId>
7+
<version>0.9.1-SNAPSHOT</version>
8+
</parent>
9+
10+
<artifactId>vortex-calcite</artifactId>
11+
12+
<name>vortex-calcite</name>
13+
<description>Apache Calcite SQL adapter over the Vortex columnar file format (demo: filter/project/aggregate push-down).</description>
14+
15+
<properties>
16+
<calcite.version>1.40.0</calcite.version>
17+
</properties>
18+
19+
<dependencies>
20+
<!-- production -->
21+
<dependency>
22+
<groupId>io.github.dfa1.vortex</groupId>
23+
<artifactId>vortex-reader</artifactId>
24+
</dependency>
25+
<dependency>
26+
<groupId>org.apache.calcite</groupId>
27+
<artifactId>calcite-core</artifactId>
28+
<version>${calcite.version}</version>
29+
</dependency>
30+
<!-- testing -->
31+
<dependency>
32+
<groupId>io.github.dfa1.vortex</groupId>
33+
<artifactId>vortex-core</artifactId>
34+
<type>test-jar</type>
35+
<scope>test</scope>
36+
</dependency>
37+
<dependency>
38+
<groupId>io.github.dfa1.vortex</groupId>
39+
<artifactId>vortex-writer</artifactId>
40+
<scope>test</scope>
41+
</dependency>
42+
<dependency>
43+
<groupId>io.airlift</groupId>
44+
<artifactId>aircompressor-v3</artifactId>
45+
<scope>test</scope>
46+
</dependency>
47+
<dependency>
48+
<groupId>org.junit.jupiter</groupId>
49+
<artifactId>junit-jupiter</artifactId>
50+
<scope>test</scope>
51+
</dependency>
52+
<dependency>
53+
<groupId>org.assertj</groupId>
54+
<artifactId>assertj-core</artifactId>
55+
<scope>test</scope>
56+
</dependency>
57+
</dependencies>
58+
</project>
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package io.github.dfa1.vortex.calcite;
2+
3+
import io.github.dfa1.vortex.reader.ArrayStats;
4+
5+
import com.google.common.collect.ImmutableList;
6+
import org.apache.calcite.interpreter.Bindables;
7+
import org.apache.calcite.plan.RelOptRule;
8+
import org.apache.calcite.plan.RelOptRuleCall;
9+
import org.apache.calcite.plan.RelOptRuleOperand;
10+
import org.apache.calcite.rel.RelNode;
11+
import org.apache.calcite.rel.core.Aggregate;
12+
import org.apache.calcite.rel.core.AggregateCall;
13+
import org.apache.calcite.rel.core.Project;
14+
import org.apache.calcite.rel.core.TableScan;
15+
import org.apache.calcite.rel.logical.LogicalValues;
16+
import org.apache.calcite.rel.type.RelDataType;
17+
import org.apache.calcite.rel.type.RelDataTypeField;
18+
import org.apache.calcite.rex.RexBuilder;
19+
import org.apache.calcite.rex.RexInputRef;
20+
import org.apache.calcite.rex.RexLiteral;
21+
import org.apache.calcite.rex.RexNode;
22+
import org.apache.calcite.sql.SqlKind;
23+
24+
import java.math.BigDecimal;
25+
import java.util.ArrayList;
26+
import java.util.List;
27+
28+
/// Rewrites a whole-table `MIN`/`MAX`/`COUNT` aggregate over a [VortexTable] into a single-row
29+
/// [LogicalValues] computed from the footer zone-map statistics — answering the query without
30+
/// decoding a single data segment (ADR 0013 §6, ADR 0018 Phase 2).
31+
///
32+
/// Fires only when it can answer *every* aggregate from statistics: no `GROUP BY`, and each
33+
/// call is `COUNT(*)`, `COUNT(col)`, `MIN(col)`, or `MAX(col)` over a numeric column. Anything
34+
/// else (e.g. `SUM`, a grouped aggregate, `MIN` on a non-numeric column) leaves the plan
35+
/// untouched for the normal scan path. `SUM`/`AVG` join this tier once the writer emits a
36+
/// per-zone `SUM` statistic.
37+
// Calcite 1.40 removed RelRule.Config.EMPTY; the modern RelRule.Config path requires the
38+
// Immutables annotation processor. The classic operand() constructor is deprecated but fully
39+
// supported and far lighter for a single adapter rule — suppression is localized and justified.
40+
@SuppressWarnings("deprecation")
41+
public final class VortexAggregatePushDownRule extends RelOptRule {
42+
43+
/// Matches `Aggregate(Project(TableScan))` — the shape Calcite produces when columns are
44+
/// selected before aggregation (e.g. `MIN(low)`).
45+
public static final VortexAggregatePushDownRule WITH_PROJECT = new VortexAggregatePushDownRule(
46+
operand(Aggregate.class, operand(Project.class, operand(TableScan.class, none()))),
47+
"VortexAggregatePushDownRule:project");
48+
49+
/// Matches `Aggregate(TableScan)` — e.g. a bare `COUNT(*)` with no projected columns.
50+
public static final VortexAggregatePushDownRule NO_PROJECT = new VortexAggregatePushDownRule(
51+
operand(Aggregate.class, operand(TableScan.class, none())),
52+
"VortexAggregatePushDownRule:scan");
53+
54+
/// Every rule variant, for registering with a planner in one call.
55+
public static final java.util.List<RelOptRule> RULES = java.util.List.of(WITH_PROJECT, NO_PROJECT);
56+
57+
private VortexAggregatePushDownRule(RelOptRuleOperand operand, String description) {
58+
super(operand, description);
59+
}
60+
61+
@Override
62+
public void onMatch(RelOptRuleCall call) {
63+
Aggregate aggregate = call.rel(0);
64+
if (aggregate.getGroupCount() != 0) {
65+
return;
66+
}
67+
// Explicit operands give concrete rels under both Hep and Volcano: rel(1) is either the
68+
// Project (then rel(2) is the scan) or the scan directly.
69+
Project project;
70+
TableScan scan;
71+
if (call.rel(1) instanceof Project p) {
72+
project = p;
73+
scan = call.rel(2);
74+
} else {
75+
project = null;
76+
scan = call.rel(1);
77+
}
78+
VortexTable table = scan.getTable().unwrap(VortexTable.class);
79+
if (table == null) {
80+
return;
81+
}
82+
// Whole-table stats are only valid for a whole-table scan. If a WHERE predicate was pushed
83+
// into the scan (BindableTableScan.filters), answering from stats would ignore it and return
84+
// the wrong MIN/MAX/COUNT — leave the plan to compute it over the filtered rows.
85+
if (scan instanceof Bindables.BindableTableScan bindable && !bindable.filters.isEmpty()) {
86+
return;
87+
}
88+
RelDataType scanRowType = scan.getRowType();
89+
List<String> scanColumns = scanRowType.getFieldNames();
90+
91+
RexBuilder rexBuilder = aggregate.getCluster().getRexBuilder();
92+
List<RelDataType> outTypes = aggregate.getRowType().getFieldList().stream()
93+
.map(f -> f.getType()).toList();
94+
95+
List<RexLiteral> row = new ArrayList<>();
96+
List<AggregateCall> calls = aggregate.getAggCallList();
97+
for (int i = 0; i < calls.size(); i++) {
98+
RexLiteral literal = evaluate(calls.get(i), outTypes.get(i), table, scanColumns, scanRowType,
99+
project, rexBuilder);
100+
if (literal == null) {
101+
return; // an aggregate we can't answer from stats — abandon the rewrite
102+
}
103+
row.add(literal);
104+
}
105+
106+
RelNode values = LogicalValues.create(
107+
aggregate.getCluster(), aggregate.getRowType(),
108+
ImmutableList.of(ImmutableList.copyOf(row)));
109+
call.transformTo(values);
110+
}
111+
112+
/// Evaluates one aggregate call from zone-map stats, returning a literal of `outType`, or
113+
/// `null` if it cannot be answered (so the caller abandons the rewrite).
114+
private static RexLiteral evaluate(AggregateCall agg, RelDataType outType, VortexTable table,
115+
List<String> scanColumns, RelDataType scanRowType,
116+
Project project, RexBuilder rexBuilder) {
117+
return switch (agg.getAggregation().getKind()) {
118+
case COUNT -> {
119+
if (agg.getArgList().isEmpty()) {
120+
yield exact(rexBuilder, table.totalRows(), outType); // COUNT(*)
121+
}
122+
String col = resolveColumn(agg.getArgList().getFirst(), scanColumns, project);
123+
if (col == null) {
124+
yield null;
125+
}
126+
Long nullCount = table.statsOf(col).nullCount();
127+
// COUNT(col) = rows − nulls. Without a NULL_COUNT stat we cannot assume zero nulls
128+
// for a nullable column (we would overcount), so abandon; a non-nullable column has
129+
// no nulls and is safe.
130+
if (nullCount == null && isNullable(scanRowType, col)) {
131+
yield null;
132+
}
133+
long nulls = nullCount == null ? 0L : nullCount;
134+
yield exact(rexBuilder, table.totalRows() - nulls, outType);
135+
}
136+
case MIN, MAX -> {
137+
if (agg.getArgList().size() != 1) {
138+
yield null;
139+
}
140+
String col = resolveColumn(agg.getArgList().getFirst(), scanColumns, project);
141+
if (col == null) {
142+
yield null;
143+
}
144+
ArrayStats stats = table.statsOf(col);
145+
Object value = agg.getAggregation().getKind() == SqlKind.MIN ? stats.min() : stats.max();
146+
if (value == null) {
147+
// No MIN/MAX stat. A genuine SQL NULL is only correct when the column provably has
148+
// no non-null rows (empty table, or every row null); otherwise the stat is merely
149+
// absent and we must abandon so the scan computes the real value.
150+
long total = table.totalRows();
151+
Long nullCount = stats.nullCount();
152+
boolean provablyNoValues = total == 0 || (nullCount != null && nullCount == total);
153+
yield provablyNoValues ? rexBuilder.makeNullLiteral(outType) : null;
154+
}
155+
yield numericLiteral(rexBuilder, value, outType);
156+
}
157+
default -> null;
158+
};
159+
}
160+
161+
/// Returns whether column `col` is nullable per the scan's row type.
162+
private static boolean isNullable(RelDataType scanRowType, String col) {
163+
RelDataTypeField field = scanRowType.getField(col, true, false);
164+
return field == null || field.getType().isNullable();
165+
}
166+
167+
/// Maps an aggregate input ordinal to a scan column name, looking through a `Project` of input
168+
/// refs when present. Returns `null` if the ordinal is a computed expression, not a column.
169+
private static String resolveColumn(int aggInput, List<String> scanColumns, Project project) {
170+
if (project == null) {
171+
return aggInput < scanColumns.size() ? scanColumns.get(aggInput) : null;
172+
}
173+
RexNode expr = project.getProjects().get(aggInput);
174+
if (expr instanceof RexInputRef ref && ref.getIndex() < scanColumns.size()) {
175+
return scanColumns.get(ref.getIndex());
176+
}
177+
return null;
178+
}
179+
180+
private static RexLiteral exact(RexBuilder rexBuilder, long value, RelDataType type) {
181+
return rexBuilder.makeExactLiteral(BigDecimal.valueOf(value), type);
182+
}
183+
184+
/// Builds a literal for a non-null `MIN`/`MAX` value, supporting only numeric output types
185+
/// (exact and approximate). A non-numeric value yields `null` so the rule abandons the rewrite.
186+
private static RexLiteral numericLiteral(RexBuilder rexBuilder, Object value, RelDataType type) {
187+
if (!(value instanceof Number number)) {
188+
return null;
189+
}
190+
return switch (type.getSqlTypeName()) {
191+
case TINYINT, SMALLINT, INTEGER, BIGINT ->
192+
rexBuilder.makeExactLiteral(BigDecimal.valueOf(number.longValue()), type);
193+
case FLOAT, REAL, DOUBLE, DECIMAL ->
194+
rexBuilder.makeApproxLiteral(BigDecimal.valueOf(number.doubleValue()), type);
195+
default -> null;
196+
};
197+
}
198+
}

0 commit comments

Comments
 (0)