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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- A CSV import schema can declare any integer type from `I8` to `U64`, range-checked per value, and `F32`; an unsupported type fails in `withSchema` instead of at the first chunk. ([#593](https://github.com/dfa1/vortex-java/pull/593))

### Changed
- **Breaking:** CSV import stores an empty cell in a number or boolean column as null instead of `0` or `false`, so inferred number and boolean columns are now nullable (read through `MaskedArray`), and an empty cell or a missing column fails the import where the schema declares the column non-nullable. ([#592](https://github.com/dfa1/vortex-java/issues/592))

### Fixed
- `filter` and `select --where` on the command line work on a nullable column, where they failed; a null row matches no comparison, as in SQL. ([#593](https://github.com/dfa1/vortex-java/pull/593))

## [0.18.1] — 2026-10-22

### Added
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,10 @@ would otherwise guess. Working *on* this repository instead? Read [AGENTS.md](AG
bounded memory; `ParquetImporter` and `JdbcImporter` do the same for Parquet and JDBC sources.
- **Column types are inferred from the first 65 536 rows** (long, then double, then boolean, else
text). A later value that does not fit fails the import, so for a column that mixes types pass
an explicit schema: `ImportOptions.defaults().withSchema(DType.structBuilder()...build())`.
Empty cells become `0`, `false` or `""`; the first row is a header unless
an explicit schema: `ImportOptions.defaults().withSchema(DType.structBuilder()...build())`. A
schema can also pick a narrower type: any integer from `I8` to `U64` (range-checked) or `F32`.
Inferred number and boolean columns are nullable: an empty cell is null there, and `""` in a text
column. The first row is a header unless
`withHeader(false)`; the delimiter must be an ASCII character.
- **Compact files from code**: start from the import's own write options, which keep the global
dictionary off for streaming (`options.withWriteOptions(options.writeOptions().withCompact(true))`),
Expand Down
29 changes: 21 additions & 8 deletions cli/src/main/java/io/github/dfa1/vortex/cli/FilterCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.function.IntPredicate;

@SuppressWarnings("java:S106") // CLI command: stdout is the intended output channel
final class FilterCommand {
Expand Down Expand Up @@ -188,14 +189,12 @@ static RowPredicate toRowPredicate(RowFilter filter) {
/// the CLI grammar never reaches — can be exercised directly.
static RowPredicate columnPredicate(ColumnName col, Predicate predicate) {
return switch (predicate) {
case Predicate.Gt(var val) -> (chunk, rowIdx) -> compareValue(chunk.column(col), rowIdx, val) > 0;
case Predicate.Gte(var val) -> (chunk, rowIdx) -> compareValue(chunk.column(col), rowIdx, val) >= 0;
case Predicate.Lt(var val) -> (chunk, rowIdx) -> compareValue(chunk.column(col), rowIdx, val) < 0;
case Predicate.Lte(var val) -> (chunk, rowIdx) -> compareValue(chunk.column(col), rowIdx, val) <= 0;
case Predicate.Eq(var val) ->
(chunk, rowIdx) -> compareValue(chunk.column(col), rowIdx, (Comparable<?>) val) == 0;
case Predicate.Neq(var val) ->
(chunk, rowIdx) -> compareValue(chunk.column(col), rowIdx, (Comparable<?>) val) != 0;
case Predicate.Gt(var val) -> comparison(col, val, order -> order > 0);
case Predicate.Gte(var val) -> comparison(col, val, order -> order >= 0);
case Predicate.Lt(var val) -> comparison(col, val, order -> order < 0);
case Predicate.Lte(var val) -> comparison(col, val, order -> order <= 0);
case Predicate.Eq(var val) -> comparison(col, (Comparable<?>) val, order -> order == 0);
case Predicate.Neq(var val) -> comparison(col, (Comparable<?>) val, order -> order != 0);
case Predicate.IsNull _ -> (chunk, rowIdx) -> isRowNull(chunk.column(col), rowIdx);
case Predicate.IsNotNull _ -> (chunk, rowIdx) -> !isRowNull(chunk.column(col), rowIdx);
case Predicate.Between _ ->
Expand All @@ -207,6 +206,20 @@ static RowPredicate columnPredicate(ColumnName col, Predicate predicate) {
};
}

/// A comparison against `value`, true when `test` accepts the row's order against it. A null row
/// never matches, as in SQL, where comparing with null is not true; a nullable column's other
/// rows compare through its unmasked values (a nullable column reached here as a `MaskedArray`
/// and failed with "filter not supported for column type").
private static RowPredicate comparison(ColumnName col, Comparable<?> value, IntPredicate test) {
return (chunk, rowIdx) -> {
Array arr = chunk.column(col);
if (isRowNull(arr, rowIdx)) {
return false;
}
return test.test(compareValue(MaskedArray.innerOrSelf(arr), rowIdx, value));
};
}

// Only a masked column carries nulls; an unmasked array is null-free, so every row is non-null.
private static boolean isRowNull(Array arr, long rowIdx) {
return arr instanceof MaskedArray masked && !masked.isValid(rowIdx);
Expand Down
24 changes: 24 additions & 0 deletions cli/src/test/java/io/github/dfa1/vortex/cli/FilterCommandTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import java.nio.channels.FileChannel;
import java.nio.file.StandardOpenOption;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
Expand Down Expand Up @@ -214,6 +215,29 @@ void filtersF16Column_keepsOnlyMatchingRows() throws IOException {
assertThat(result.stdout().lines().toList()).containsExactly("h", "2.5", "3.5");
}

@ParameterizedTest
@CsvSource({"'>', 0, '1,3'", "'!=', 1, '3'", "'=', 3, '3'"})
void filtersNullableColumn_skipsNullRows(String op, String value, String expected) throws IOException {
// Given a nullable I64 column [1, null, 3], as a CSV import with an empty cell writes it:
// filtering one threw "filter not supported for column type: MaskedArray"
Path nullable = tmp.resolve("nullable.vortex");
DType.Struct schema = new DType.Struct(List.of(ColumnName.of("v")), List.of(DType.I64.asNullable()), false);
try (FileChannel ch = FileChannel.open(nullable, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
VortexWriter writer = VortexWriter.create(ch, schema, WriteOptions.defaults())) {
writer.writeChunk(Map.of(ColumnName.of("v"), new Long[]{1L, null, 3L}));
}

// When
CliTestSupport.Captured result = capture(() ->
FilterCommand.run(new String[]{"filter", nullable.toString(), "v", op, value}));

// Then: as in SQL, a comparison with null is never true, so the null row never matches
assertThat(result.status()).isEqualTo(ExitStatus.OK);
List<String> rows = new ArrayList<>(List.of("v"));
rows.addAll(List.of(expected.split(",")));
assertThat(result.stdout().lines().toList()).isEqualTo(rows);
}

@Test
void operatorPrefixAttack_doesNotCauseBacktracking() {
// Given — input designed to trigger polynomial backtracking under the previous regex
Expand Down
Loading
Loading