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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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 `LIKE 'prefix%'` on a string column is about 5 times faster: it is pushed into the scan as a string range that prunes chunks, instead of being matched by Calcite row by row. ([#558](https://github.com/dfa1/vortex-java/pull/558))
- SQL `ORDER BY ... LIMIT n` is up to 9 times faster: the first rows are kept in a bounded heap during the scan instead of sorting the whole table. ([#557](https://github.com/dfa1/vortex-java/pull/557))
- SQL `GROUP BY` on a dictionary-encoded string column is 10 to 30 times faster: rows are counted and summed per dictionary code instead of being built one by one for Calcite. ([#555](https://github.com/dfa1/vortex-java/pull/555))
- A filtered SQL `COUNT`/`MIN`/`MAX` answered from zone maps decodes the zones its filter cuts once per query instead of up to six times. ([#555](https://github.com/dfa1/vortex-java/pull/555))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ private RexFilterTranslator() {

/// 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
/// prefix `LIKE 'abc%'`, 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
Expand Down Expand Up @@ -172,6 +172,7 @@ private static Optional<RowFilter> comparison(RexNode node, List<ColumnName> nam
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 -> nullCheck(call, names);
case LIKE -> like(call, names, types);
default -> Optional.empty();
};
}
Expand Down Expand Up @@ -213,6 +214,52 @@ private static Optional<RowFilter> nullCheck(RexCall call, List<ColumnName> name
: RowFilter.isNotNull(col));
}

/// Translates `col LIKE 'prefix%'` on a string column into the range `col >= prefix AND col <
/// successor(prefix)`, which holds exactly the strings starting with `prefix`: the successor
/// raises the prefix's last code point by one, and code-point order is the UTF-8 byte order
/// [RowFilter] compares strings in. A pattern with any other wildcard (`_`, an inner `%`) or an
/// `ESCAPE` clause is not translated; Calcite evaluates it. `LIKE '%'` keeps every non-null row.
private static Optional<RowFilter> like(RexCall call, List<ColumnName> names, List<DType> types) {
List<RexNode> ops = call.getOperands();
if (ops.size() != 2 || !(ops.get(0) instanceof RexInputRef ref) || !(ops.get(1) instanceof RexLiteral lit)
|| !(types.get(ref.getIndex()) instanceof DType.Utf8)) {
return Optional.empty();
}
String pattern = lit.getValueAs(String.class);
if (pattern == null || !pattern.endsWith("%")) {
return Optional.empty();
}
String prefix = pattern.substring(0, pattern.length() - 1);
if (prefix.indexOf('%') >= 0 || prefix.indexOf('_') >= 0) {
return Optional.empty();
}
ColumnName col = names.get(ref.getIndex());
if (prefix.isEmpty()) {
return Optional.of(RowFilter.isNotNull(col));
}
return Optional.of(successor(prefix)
.map(upper -> RowFilter.and(RowFilter.gte(col, prefix), RowFilter.lt(col, upper)))
.orElseGet(() -> RowFilter.gte(col, prefix)));
}

/// The least string greater than every string starting with `prefix`: its last code point raised
/// by one, skipping the surrogate block; a trailing U+10FFFF has no successor and is dropped
/// first. Empty when every code point is U+10FFFF.
static Optional<String> successor(String prefix) {
int[] codePoints = prefix.codePoints().toArray();
for (int end = codePoints.length - 1; end >= 0; end--) {
int next = codePoints[end] + 1;
if (next == Character.MIN_SURROGATE) {
next = Character.MAX_SURROGATE + 1;
}
if (next <= Character.MAX_CODE_POINT) {
codePoints[end] = next;
return Optional.of(new String(codePoints, 0, end + 1));
}
}
return Optional.empty();
}

private static Optional<RowFilter> binary(RexCall call, List<ColumnName> names, List<DType> types) {
List<RexNode> ops = call.getOperands();
if (ops.size() != 2) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

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 prefix `LIKE 'abc%'` is pushed into the scan as a string range, which prunes zones and spares
/// Calcite compiling the pattern per row. Every query must return the rows of the same query over
/// `s || ''`, an expression the translation leaves to Calcite.
class LikePushDownTest {

@TempDir
static Path tmp;
private static Path file;

/// Two chunks of strings around the edges of a prefix range: the prefix itself, a string just
/// past its successor, non-Latin-1 code points, and a code point outside the BMP.
@BeforeAll
static void writeFile() throws Exception {
file = tmp.resolve("like.vortex");
DType.Struct schema = DType.structBuilder()
.field(ColumnName.of("id"), DType.I64)
.field(ColumnName.of("s"), new DType.Utf8(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, 5, 6},
ColumnName.of("s"), new String[]{"abc", "abcd", "abd", "ab", "Abc", null}));
writer.writeChunk(Map.of(ColumnName.of("id"), new long[]{7, 8, 9, 10, 11, 12},
ColumnName.of("s"), new String[]{"Ωmega", "ÿz", "Ā", "a_c", "a%c", "😀x"}));
}
}

@ParameterizedTest
@ValueSource(strings = {
// the successor of ÿ (U+00FF) is Ā (U+0100), outside Latin-1: it bounds the range but
// never appears in SQL, which only takes Latin-1 literals
"ab%", "abc%", "abd%", "A%", "ÿ%", "zzz%", "%",
// not a prefix pattern: left to Calcite, same answer
"a_c%", "%c", "abc"})
void like_returnsTheRowsCalciteReturns(String pattern) throws Exception {
// Given / When
List<Long> result = ids("select id from vtx.t where s like '" + pattern + "' order by id");

// Then
assertThat(result).isEqualTo(ids("select id from vtx.t where s || '' like '" + pattern + "' order by id"));
}

@Test
void successor_raisesTheLastCodePointSkippingSurrogatesAndMaxima() {
// Given / When / Then
assertThat(RexFilterTranslator.successor("abc")).contains("abd");
assertThat(RexFilterTranslator.successor("a퟿")).contains("a");
assertThat(RexFilterTranslator.successor("a" + Character.toString(Character.MAX_CODE_POINT))).contains("b");
assertThat(RexFilterTranslator.successor(Character.toString(Character.MAX_CODE_POINT))).isEmpty();
}

private static List<Long> ids(String sql) throws Exception {
List<Long> out = new ArrayList<>();
try (Connection conn = VortexCalcite.connect("vtx", Map.of("t", file));
Statement st = conn.createStatement();
ResultSet rs = st.executeQuery(sql)) {
while (rs.next()) {
out.add(rs.getLong(1));
}
}
return out;
}
}
5 changes: 3 additions & 2 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -612,7 +612,8 @@ preserved) and the **Babel** SQL parser, so a column named after a reserved word
keywords that open a typed literal (`date`, `time`, `timestamp`, `interval`), which stay reserved
even under Babel and need back-ticks: `` select `date` from vtx.ohlc ``.

A `WHERE` of those predicates (an `OR` only within one column) prunes chunks from zone maps and is
A `WHERE` of column-vs-literal comparisons, `IN` lists, `IS [NOT] NULL` and prefix `LIKE 'abc%'`
patterns, joined by `AND` (an `OR` only within one column), prunes chunks from zone maps and is
enforced by the scan, so Calcite does not re-check it; anything else is left to Calcite.

Column types map as follows. Unsigned integers widen to the next signed type (`U64` stays `BIGINT`
Expand All @@ -632,7 +633,7 @@ and fails on values past 2^63); `F16` reads as `REAL`.

`count`/`min`/`max`/`sum` (and `avg`, reduced to `sum`/`count`) are answered from zone-map
statistics via `VortexAggregatePushDownRule` where possible, with or without a `WHERE` of
column-vs-literal comparisons, `IN` lists and `IS [NOT] NULL` joined by `AND`: zones the filter selects entirely fold from their statistics, and only
column-vs-literal comparisons, `IN` lists, `IS [NOT] NULL` and prefix `LIKE` joined by `AND`: zones the filter selects entirely fold from their statistics, and only
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,24 @@ void jniWriter_orderByLimit_returnsTheRowsCalciteSortReturns(String tail, String
}
}

@ParameterizedTest(name = "like {0}")
@org.junit.jupiter.params.provider.ValueSource(strings = {"A%", "caf%", "MS%", "NVDB%", "%", "_APL%"})
void jniWriter_likePrefix_returnsTheRowsCalciteReturns(String pattern, @TempDir Path tmp) throws Exception {
// Given — a prefix LIKE is pushed into the scan as a string range; "s || ''" keeps it out,
// so Calcite evaluates the pattern over the same Rust-written rows itself
Path file = tmp.resolve("jni_like.vortex");
writeGroupsWithJni(file, new String[]{"AAPL", "MSFT", "NVDA", "caf\u00E9", "AMZN", null});

try (Connection conn = VortexCalcite.connect("vtx", Map.of("t", file))) {
// When
List<String> result = sqlRows(conn, "select id, s from vtx.t where s like '" + pattern + "' order by id");

// Then
assertThat(result).isEqualTo(
sqlRows(conn, "select id, s from vtx.t where s || '' like '" + pattern + "' order by id"));
}
}

private static List<String> sqlRows(Connection conn, String sql) throws Exception {
List<String> out = new java.util.ArrayList<>();
try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery(sql)) {
Expand Down
Loading