Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- `DecimalArray.getUnscaledLong(long)` reads a decimal's unscaled value without building a `BigDecimal`. ([#563](https://github.com/dfa1/vortex-java/pull/563))
- `Compute.groupedAggregate(Chunk, ColumnName, RowFilter, ColumnName)` folds an aggregate per value of a dictionary-encoded string, boolean or integer column. ([#555](https://github.com/dfa1/vortex-java/pull/555))
- `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))
Expand All @@ -17,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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
- SQL `SUM` and `AVG` of a decimal column, grouped or not, are 4 to 6 times faster: they are totalled exactly as unscaled longs in the scan instead of by Calcite with a `BigDecimal` per row. ([#563](https://github.com/dfa1/vortex-java/pull/563))
- SQL `COUNT(DISTINCT col)` is up to 4 times faster: distinct values are counted in the scan, integers by their bits in a primitive map, instead of Calcite hashing a boxed value per row. ([#562](https://github.com/dfa1/vortex-java/pull/562))
- A SQL `WHERE` comparing a `SMALLINT` or `TINYINT` column with a literal is pushed into the scan and its aggregates, instead of being left to Calcite because of the cast Calcite adds. ([#560](https://github.com/dfa1/vortex-java/pull/560))
- SQL `MIN`/`MAX` of a date, time or timestamp column is answered from zone maps, and scans read those columns without building a `java.time` object per row. ([#559](https://github.com/dfa1/vortex-java/pull/559))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ private static void rewriteGrouped(RelOptRuleCall call, Aggregate aggregate, Vor
callColumns.add(null);
continue;
}
boolean supported = kind == SqlKind.COUNT || kind == SqlKind.SUM || kind == SqlKind.MIN
boolean supported = kind == SqlKind.COUNT || kind == SqlKind.SUM || kind == SqlKind.SUM0 || kind == SqlKind.MIN
|| kind == SqlKind.MAX;
ColumnName column = aggCall.getArgList().size() == 1
? resolveColumn(aggCall.getArgList().getFirst(), scanColumns, project) : null;
Expand Down Expand Up @@ -268,8 +268,8 @@ private static RexLiteral groupedLiteral(AggregateCall aggCall, ColumnName colum
long nulls = columnFold.nullCount();
return switch (aggCall.getAggregation().getKind()) {
case COUNT -> exact(rexBuilder, columnFold.rows() - nulls, outType);
case SUM -> sumLiteral(rexBuilder, outType, columnFold.sum(), nulls, columnFold.rows(),
isNullable(scanRowType, column.value()));
case SUM, SUM0 -> sum0(aggCall, rexBuilder, outType, sumLiteral(rexBuilder, outType, columnFold.sum(), nulls, columnFold.rows(),
isNullable(scanRowType, column.value())));
case MIN, MAX -> {
Object value = aggCall.getAggregation().getKind() == SqlKind.MIN ? columnFold.min() : columnFold.max();
yield value == null ? rexBuilder.makeNullLiteral(outType)
Expand Down Expand Up @@ -346,20 +346,23 @@ private static RexLiteral evaluate(AggregateCall agg, RelDataType outType, Vorte
}
yield extremeLiteral(rexBuilder, value, outType, table.columnType(col));
}
case SUM -> {
case SUM, SUM0 -> {
if (agg.getArgList().size() != 1) {
yield null;
}
ColumnName col = resolveColumn(agg.getArgList().getFirst(), scanColumns, project);
if (col == null) {
yield null;
}
ColumnFold fold = columnFold(table, col, filter, planner);
// zone statistics carry no decimal sum, so a decimal column is totalled by its own pass
ColumnFold fold = table.columnType(col) instanceof io.github.dfa1.vortex.core.model.DType.Decimal
? decimalFold(table, col, filter, planner)
: columnFold(table, col, filter, planner);
if (fold == null) {
yield null;
}
yield sumLiteral(rexBuilder, outType, fold.sum(), fold.nullCount(), fold.rows(),
isNullable(scanRowType, col.value()));
yield sum0(agg, rexBuilder, outType, sumLiteral(rexBuilder, outType, fold.sum(), fold.nullCount(),
fold.rows(), isNullable(scanRowType, col.value())));
}
default -> null;
};
Expand All @@ -380,9 +383,23 @@ private static ColumnFold columnFold(VortexTable table, ColumnName column, RowFi
return new ColumnFold(zoneSum.sum(), stats.stats().min(), stats.stats().max(),
stats.stats().nullCount(), stats.totalRows());
}
return filteredFold(planner, table, filter, column)
ColumnFold zones = filteredFold(planner, table, filter, column)
.map(f -> new ColumnFold(f.sum(), f.min(), f.max(), f.nullCount(), f.rows()))
.orElse(null);
// when the zones cannot answer (the filter cuts every zone it keeps), a decimal column still
// has its own exact pass
if (zones == null && table.columnType(column) instanceof io.github.dfa1.vortex.core.model.DType.Decimal) {
return decimalFold(table, column, filter, planner);
}
return zones;
}

/// [VortexTable#decimalFold(ColumnName, RowFilter)], computed once per planner run.
private static ColumnFold decimalFold(VortexTable table, ColumnName column, RowFilter filter,
RelOptPlanner planner) {
List<Object> foldKey = List.of("decimal", table, filter == null ? List.of() : filter, column);
Optional<VortexTable.FilteredFold> fold = memo(planner, foldKey, () -> table.decimalFold(column, filter));
return fold.map(f -> new ColumnFold(f.sum(), f.min(), f.max(), f.nullCount(), f.rows())).orElse(null);
}

/// [VortexTable#filteredFold(RowFilter, ColumnName)], computed once per planner run.
Expand All @@ -392,6 +409,15 @@ private static Optional<VortexTable.FilteredFold> filteredFold(RelOptPlanner pla
return memo(planner, foldKey, () -> table.filteredFold(filter, column));
}

/// `$SUM0`, which Calcite reduces `AVG` to, is `SUM` that returns `0` instead of `NULL` over no
/// non-null rows: turns the `SUM` literal's `NULL` into that zero, and leaves `SUM` alone.
private static RexLiteral sum0(AggregateCall agg, RexBuilder rexBuilder, RelDataType outType, RexLiteral sum) {
if (sum == null || agg.getAggregation().getKind() != SqlKind.SUM0 || !sum.isNull()) {
return sum;
}
return numericLiteral(rexBuilder, 0L, outType);
}

/// Builds the literal for a `SUM`, resolving the all-null-vs-genuine-zero ambiguity from the
/// null count. SQL `SUM` over zero non-null rows is `NULL`, but the writer records an all-null
/// zone's sum as a sum-neutral `0` (validity placeholders are zero), so a `0` fold alone cannot
Expand Down Expand Up @@ -492,7 +518,10 @@ private static RexLiteral numericLiteral(RexBuilder rexBuilder, Object value, Re
return switch (type.getSqlTypeName()) {
case TINYINT, SMALLINT, INTEGER, BIGINT ->
rexBuilder.makeExactLiteral(BigDecimal.valueOf(number.longValue()), type);
case FLOAT, REAL, DOUBLE, DECIMAL ->
// a DECIMAL literal is exact: a decimal sum or extreme must not round through a double
case DECIMAL -> rexBuilder.makeExactLiteral(number instanceof BigDecimal d ? d
: new BigDecimal(number.toString()), type);
case FLOAT, REAL, DOUBLE ->
rexBuilder.makeApproxLiteral(BigDecimal.valueOf(number.doubleValue()), type);
default -> null;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
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.FloatArray;
import io.github.dfa1.vortex.reader.array.IntArray;
Expand Down Expand Up @@ -545,6 +546,67 @@ private static boolean countChunk(Array data, BoolArray validity, java.util.func
return false;
}

/// `SUM`/`MIN`/`MAX`/null count of a decimal column over the rows `filter` selects, totalled as
/// unscaled longs ([DecimalArray#getUnscaledLong(long)]) instead of a `BigDecimal` per row. Zone
/// statistics carry no decimal sum, so without this every decimal `SUM` was left to Calcite.
///
/// @param column the decimal column
/// @param filter the rows to fold, or `null` for every row
/// @return the fold, or empty for a precision past 18 or a sum past a long, which Calcite totals
public Optional<FilteredFold> decimalFold(ColumnName column, RowFilter filter) {
if (!(columnType(column) instanceof DType.Decimal decimal) || decimal.precision() > 18) {
return Optional.empty();
}
java.util.LinkedHashSet<ColumnName> columns = new java.util.LinkedHashSet<>();
columns.add(column);
ScanOptions options;
if (filter == null) {
options = ScanOptions.columns(column);
} else {
RexFilterTranslator.collectColumns(filter, columns);
options = ScanOptions.columns(columns.toArray(ColumnName[]::new)).withFilter(filter);
}
long sum = 0;
long min = Long.MAX_VALUE;
long max = Long.MIN_VALUE;
long rows = 0;
long nonNull = 0;
try (VortexReader reader = VortexReader.open(file);
ScanIterator scan = reader.scan(options)) {
while (scan.hasNext()) {
try (Chunk chunk = scan.next()) {
java.util.function.LongPredicate selected = filter == null ? null : Compute.matcher(chunk, filter);
Array array = chunk.column(column);
BoolArray validity = array instanceof MaskedArray masked ? masked.validity() : null;
DecimalArray data = (DecimalArray) (array instanceof MaskedArray masked ? masked.inner() : array);
long n = chunk.rowCount();
for (long i = 0; i < n; i++) {
if (selected != null && !selected.test(i)) {
continue;
}
rows++;
if (validity != null && !validity.getBoolean(i)) {
continue;
}
long v = data.getUnscaledLong(i);
sum = Math.addExact(sum, v);
min = Math.min(min, v);
max = Math.max(max, v);
nonNull++;
}
}
}
} catch (ArithmeticException overflow) {
return Optional.empty();
} catch (IOException e) {
throw new UncheckedIOException("cannot sum " + column + " in " + file, e);
}
int scale = decimal.scale();
return Optional.of(new FilteredFold(java.math.BigDecimal.valueOf(sum, scale),
nonNull == 0 ? null : java.math.BigDecimal.valueOf(min, scale),
nonNull == 0 ? null : java.math.BigDecimal.valueOf(max, scale), rows - nonNull, rows));
}

/// One `ORDER BY` key of [#topN(int[], RowFilter, List, int)].
///
/// @param field the key's position in the projected row
Expand Down Expand Up @@ -719,6 +781,7 @@ private static final class Fold {
private long longSum;
private double doubleSum;
private boolean floatingSum;
private java.math.BigDecimal decimalSum;
private boolean sumUsable = true;
private Object min;
private Object max;
Expand All @@ -739,6 +802,8 @@ private void addSum(Object contribution) {
} else if (contribution instanceof Double d) {
floatingSum = true;
doubleSum += d;
} else if (contribution instanceof java.math.BigDecimal d) {
decimalSum = decimalSum == null ? d : decimalSum.add(d);
} else {
longSum += ((Number) contribution).longValue();
}
Expand Down Expand Up @@ -777,6 +842,8 @@ private FilteredFold result() {
foldedSum = null; // a contributing zone carried no usable sum
} else if (floatingSum) {
foldedSum = doubleSum;
} else if (decimalSum != null) {
foldedSum = decimalSum;
} else {
foldedSum = longSum;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package io.github.dfa1.vortex.calcite;

import io.github.dfa1.vortex.core.model.ColumnName;
import io.github.dfa1.vortex.core.model.DType;
import io.github.dfa1.vortex.writer.VortexWriter;
import io.github.dfa1.vortex.writer.WriteOptions;

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import java.math.BigDecimal;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;

/// A decimal `SUM` (and the `AVG` reduced to it) is totalled as unscaled longs in the scan instead of
/// by Calcite with a `BigDecimal` per row. Each query must equal the same aggregate over
/// `CASE WHEN id > -1 THEN amount END` (`id` is never null), which the rules leave to Calcite; the test
/// asserts that oracle really scans.
class DecimalSumTest {

@TempDir
static Path tmp;
private static Path file;

/// Two chunks of DECIMAL(10, 2) amounts with nulls, negatives and a group column.
@BeforeAll
static void writeFile() throws Exception {
file = tmp.resolve("decimal.vortex");
DType.Struct schema = DType.structBuilder()
.field(ColumnName.of("id"), DType.I64)
.field(ColumnName.of("s"), DType.UTF8)
.field(ColumnName.of("amount"), new DType.Decimal((byte) 10, (byte) 2, true))
.build();
try (var ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
var writer = VortexWriter.create(ch, schema, WriteOptions.defaults())) {
writer.writeChunk(Map.of(ColumnName.of("id"), new long[]{1, 2, 3, 4},
ColumnName.of("s"), new String[]{"a", "b", "a", "b"},
ColumnName.of("amount"), new BigDecimal[]{new BigDecimal("12.34"), null,
new BigDecimal("-0.05"), new BigDecimal("9999999.99")}));
writer.writeChunk(Map.of(ColumnName.of("id"), new long[]{5, 6, 7},
ColumnName.of("s"), new String[]{"a", "c", "b"},
ColumnName.of("amount"), new BigDecimal[]{new BigDecimal("0.01"), null,
new BigDecimal("-12345.67")}));
}
}

@ParameterizedTest(name = "{0}")
@CsvSource(delimiter = '|', quoteCharacter = '"', value = {
"select sum(amount) from vtx.t "
+ "| select sum(case when id > -1 then amount end) from vtx.t",
"select sum(amount), avg(amount), count(amount) from vtx.t where id < 5 "
+ "| select sum(case when id > -1 then amount end), avg(case when id > -1 then amount end), "
+ "count(case when id > -1 then amount end) from vtx.t where id + 0 < 5",
"select s, sum(amount), min(amount), max(amount) from vtx.t group by s order by s "
+ "| select s, sum(case when id > -1 then amount end), min(case when id > -1 then amount end), "
+ "max(case when id > -1 then amount end) from vtx.t group by s order by s"})
void decimalAggregate_isPushedDownAndEqualsCalcite(String sql, String oracle) throws Exception {
try (Connection conn = VortexCalcite.connect("vtx", Map.of("t", file))) {
// When / Then
assertThat(plan(conn, sql)).contains("Values").doesNotContain("TableScan");
assertThat(plan(conn, oracle)).contains("TableScan");
assertThat(rows(conn, sql)).isEqualTo(rows(conn, oracle)).isNotEmpty();
}
}

private static String plan(Connection conn, String sql) throws Exception {
try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery("explain plan for " + sql)) {
rs.next();
return rs.getString(1);
}
}

/// Rows as strings, decimals compared by value (`1.50` equals `1.5`).
private static List<String> rows(Connection conn, String sql) throws Exception {
List<String> out = new ArrayList<>();
try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery(sql)) {
int columns = rs.getMetaData().getColumnCount();
while (rs.next()) {
StringBuilder row = new StringBuilder();
for (int c = 1; c <= columns; c++) {
Object value = rs.getObject(c);
row.append(value instanceof BigDecimal d ? d.stripTrailingZeros().toPlainString() : value).append('|');
}
out.add(row.toString());
}
}
return out;
}
}
4 changes: 3 additions & 1 deletion docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -637,8 +637,10 @@ column-vs-literal comparisons, `IN` lists, `IS [NOT] NULL` and prefix `LIKE` joi
the rows of zones it cuts are decoded. `sum` needs a per-zone sum, which only the legacy
`vortex.stats` zone map records (writes targeting an edition before `core2026.08.0`); with Rust's
`vortex.zoned` — the default since #447, and every Rust-written file — `sum` is answered by a scan.
A `sum` of a decimal column (precision up to 18) is totalled in that scan as unscaled longs, not by
Calcite with a `BigDecimal` per row, and is exact.
A `GROUP BY` on one column (a dictionary-encoded string, a boolean, or an integer other than
`u64`), with `count`/`sum`/`min`/`max` of plain numeric columns and the same kind of `WHERE`, is
`u64`), with `count`/`sum`/`min`/`max` of plain numeric or decimal columns and the same kind of `WHERE`, is
folded per group into one row per group instead of building a row per record; it falls back to the
scan when a chunk of a string column is not dictionary-encoded, there are more than 10,000 groups,
or a group value is outside Latin-1 (Calcite's `VARCHAR` charset). An ungrouped `count(distinct col)` (with the same kind of `WHERE`) counts distinct values
Expand Down
Loading
Loading