Skip to content

Commit 9e6a146

Browse files
dfa1claude
andcommitted
feat(core): ColumnName domain primitive carries the name policy
The strict field-name policy created the invariant the type was waiting for: a ColumnName certifies at construction that a name is non-blank and free of control characters — the exact contract both file boundaries enforce. ColumnName.violation is the single source of truth; StructBuilder, VortexWriter, and PostscriptParser all delegate to it instead of triplicating the checks. StructBuilder gains a field(ColumnName, DType) overload for callers that validate early. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c993a35 commit 9e6a146

8 files changed

Lines changed: 190 additions & 45 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- `Compute.filteredSum(filterColumn, predicate, aggColumn)` fuses a filter and a sum into a single scan — a row folds into the total only when the predicate selects it (a null filter row is excluded) and the aggregate value is non-null — with no intermediate selection bitmap. It matches a hand-written fused loop and is ~1.5× faster than the two-pass `filter` + `sum`. ([57d2225b](https://github.com/dfa1/vortex-java/commit/57d2225b))
1313
- `Compute.filteredAggregate(chunk, filter, aggColumn)` fuses a whole multi-column `RowFilter` (an n-ary `AND` of column-bound predicate leaves) and folds the selected rows' `SUM`/`MIN`/`MAX`/non-null count over an aggregate column in a single pass — the multi-column counterpart of `filteredSum`, and the row-level kernel behind the Calcite boundary-chunk aggregate push-down. A `null` aggregate column counts selected rows only (`COUNT(*)`). ([2ba54888](https://github.com/dfa1/vortex-java/commit/2ba54888))
14+
- `core.model.ColumnName` — the validated column-name domain primitive: non-blank, no control characters (the policy the writer, schema builder, and file parser all enforce — `ColumnName.violation` is the single source of truth). Field names are now strict on BOTH sides of the file boundary: the writer refuses blank/control names (`IllegalArgumentException`; NUL additionally crashes the reference toolchain's Arrow FFI), and the reader rejects files carrying them or duplicate field names (`VortexException`) — wire-legal is a floor, not a policy. Printable names of any shape (`$$$$$`, interior spaces, emoji) remain legal and round-trip against the reference implementation. ([c993a355](https://github.com/dfa1/vortex-java/commit/c993a355), [dca815b9](https://github.com/dfa1/vortex-java/commit/dca815b9), [d3b5b251](https://github.com/dfa1/vortex-java/commit/d3b5b251))
1415
- `core.model.LayoutId` — typed layout identity with the same sealed shape as `EncodingId` (`WellKnown` constants plus `Custom`; layouts are runtime-pluggable in the reference implementation). The reader now recognizes `vortex.zoned`, the current canonical zone-map layout id in the Rust reference, alongside the legacy `vortex.stats` alias it keeps writing — files from current Rust writers scan and prune correctly. ([7df3a0db](https://github.com/dfa1/vortex-java/commit/7df3a0db))
1516
- Layout decode is pluggable: `LayoutDecoder` + `LayoutRegistry` (`reader.layout`) mirror the encoding registry — `LayoutRegistry.builder().registerDefaults().register(custom).build()` passed to the new `VortexReader.open(path, readRegistry, layoutRegistry)` / `VortexHttpReader` overloads dispatches every layout decode, container children included, through the registry. Programmatic registration only (no service file); unknown layouts fail loudly. Zone-map pruning and filtered scans recognize the built-in layouts only. ([fc488d04](https://github.com/dfa1/vortex-java/commit/fc488d04), [dd196f17](https://github.com/dfa1/vortex-java/commit/dd196f17))
1617

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package io.github.dfa1.vortex.core.model;
2+
3+
import java.util.Objects;
4+
import java.util.Optional;
5+
6+
/// A column (struct field) name that satisfies vortex-java's name policy: non-blank and free
7+
/// of control characters.
8+
///
9+
/// The wire format itself accepts almost any UTF-8 string — including `""`, whitespace-only
10+
/// names, and control characters — but those are footguns this implementation refuses on both
11+
/// sides of the file boundary (the writer never emits them, the reader rejects files carrying
12+
/// them). A `ColumnName` certifies at construction that a name passed that policy, so code
13+
/// holding one never re-validates. Printable names of any shape remain legal: `"$$$$$"`,
14+
/// names with interior spaces, and emoji all round-trip against the reference implementation.
15+
///
16+
/// The policy checks live in [#violation(String)] — the single source of truth used by the
17+
/// schema builder, the writer, and the file parser (which wraps violations in its own
18+
/// file-context error).
19+
///
20+
/// @param value the validated name; use it wherever a raw column-name string is required
21+
public record ColumnName(String value) implements Comparable<ColumnName> {
22+
23+
/// Validates the name against the policy.
24+
///
25+
/// @param value the column name
26+
/// @throws NullPointerException if `value` is `null`
27+
/// @throws IllegalArgumentException if the name violates the policy ([#violation(String)])
28+
public ColumnName {
29+
Objects.requireNonNull(value, "value");
30+
Optional<String> violation = violation(value);
31+
if (violation.isPresent()) {
32+
throw new IllegalArgumentException(violation.get());
33+
}
34+
}
35+
36+
/// Creates a validated column name.
37+
///
38+
/// @param value the column name
39+
/// @return the validated [ColumnName]
40+
/// @throws NullPointerException if `value` is `null`
41+
/// @throws IllegalArgumentException if the name violates the policy ([#violation(String)])
42+
public static ColumnName of(String value) {
43+
return new ColumnName(value);
44+
}
45+
46+
/// Checks a raw name against the policy without constructing anything — the shared
47+
/// chokepoint for all boundary guards. Callers that need a domain-specific exception
48+
/// (e.g. the file parser's `VortexException` with file context) format the returned
49+
/// reason themselves.
50+
///
51+
/// @param name the raw column name to check (must be non-`null`)
52+
/// @return the policy violation, or empty if `name` is a valid column name
53+
public static Optional<String> violation(String name) {
54+
if (name.isBlank()) {
55+
return Optional.of("blank field name (wire-legal, but a footgun vortex-java refuses to write)");
56+
}
57+
for (int i = 0; i < name.length(); i++) {
58+
if (Character.isISOControl(name.charAt(i))) {
59+
return Optional.of("field name contains control character U+%04X"
60+
.formatted((int) name.charAt(i)));
61+
}
62+
}
63+
return Optional.empty();
64+
}
65+
66+
/// Orders by the name string, so sorted collections of columns read naturally.
67+
///
68+
/// @param other the name to compare against
69+
/// @return negative, zero, or positive per [String#compareTo(String)] on the values
70+
@Override
71+
public int compareTo(ColumnName other) {
72+
return value.compareTo(other.value);
73+
}
74+
75+
@Override
76+
public String toString() {
77+
return value;
78+
}
79+
}

core/src/main/java/io/github/dfa1/vortex/core/model/DType.java

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -164,10 +164,10 @@ private StructBuilder() {
164164

165165
/// Adds a named field to the struct under construction.
166166
///
167-
/// Field names follow the write-side policy, stricter than the wire format on purpose:
168-
/// blank names and control characters are rejected as footguns (a foreign file may
169-
/// carry them and the reader tolerates it, but vortex-java refuses to produce them —
170-
/// same spirit as a JSON library refusing to write a `""` key it would happily parse).
167+
/// Field names follow the [ColumnName] policy, stricter than the wire format on
168+
/// purpose: blank names and control characters are rejected as footguns, on both the
169+
/// write and the read side — same spirit as a JSON library refusing a `""` key it
170+
/// could technically parse.
171171
///
172172
/// @param name the field name; non-`null`, non-blank, no control characters, not
173173
/// previously added
@@ -176,17 +176,17 @@ private StructBuilder() {
176176
/// @throws IllegalArgumentException if `name` is blank, contains a control character,
177177
/// or duplicates a previously added field
178178
public StructBuilder field(String name, DType type) {
179-
if (name.isBlank()) {
180-
throw new IllegalArgumentException(
181-
"blank field name (wire-legal, but a footgun vortex-java refuses to write)");
182-
}
183-
for (int i = 0; i < name.length(); i++) {
184-
if (Character.isISOControl(name.charAt(i))) {
185-
throw new IllegalArgumentException(
186-
"field name contains control character U+%04X".formatted((int) name.charAt(i)));
187-
}
188-
}
189-
if (fields.putIfAbsent(name, type) != null) {
179+
return field(ColumnName.of(name), type);
180+
}
181+
182+
/// Adds a named field using an already-validated [ColumnName].
183+
///
184+
/// @param name the validated field name; must not have been previously added
185+
/// @param type the field type
186+
/// @return this builder
187+
/// @throws IllegalArgumentException if `name` duplicates a previously added field
188+
public StructBuilder field(ColumnName name, DType type) {
189+
if (fields.putIfAbsent(name.value(), type) != null) {
190190
throw new IllegalArgumentException("duplicate field name: " + name);
191191
}
192192
return this;
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package io.github.dfa1.vortex.core.model;
2+
3+
import org.junit.jupiter.api.Test;
4+
import org.junit.jupiter.params.ParameterizedTest;
5+
import org.junit.jupiter.params.provider.ValueSource;
6+
7+
import java.util.Optional;
8+
9+
import static org.assertj.core.api.Assertions.assertThat;
10+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
11+
12+
/// The [ColumnName] policy: names a holder certifies were validated once at construction.
13+
/// Printable weirdness is legal (measured against the reference implementation); blank and
14+
/// control-character names are the footguns the policy exists to refuse.
15+
class ColumnNameTest {
16+
17+
@ParameterizedTest
18+
@ValueSource(strings = {"price", "$$$$$", "with space inside", "😀emoji", "UPPER_lower.dots-1"})
19+
void of_printableName_constructsAndRoundTrips(String raw) {
20+
// Given a printable name of arbitrary shape
21+
// When
22+
ColumnName result = ColumnName.of(raw);
23+
24+
// Then — value, toString, and equality all carry the exact string
25+
assertThat(result.value()).isEqualTo(raw);
26+
assertThat(result).hasToString(raw);
27+
assertThat(result).isEqualTo(new ColumnName(raw));
28+
}
29+
30+
@ParameterizedTest
31+
@ValueSource(strings = {"", " ", " ", "a\nb", "tab\there", "nul\u0000here"})
32+
void of_footgunName_throwsIllegalArgumentException(String raw) {
33+
// Given / When / Then — blank or control-character names violate the policy
34+
assertThatThrownBy(() -> ColumnName.of(raw))
35+
.isInstanceOf(IllegalArgumentException.class);
36+
}
37+
38+
@Test
39+
void of_nullName_throwsNullPointerException() {
40+
// Given / When / Then
41+
assertThatThrownBy(() -> ColumnName.of(null))
42+
.isInstanceOf(NullPointerException.class);
43+
}
44+
45+
@Test
46+
void violation_validName_returnsEmpty() {
47+
// Given a valid name
48+
// When
49+
Optional<String> result = ColumnName.violation("price");
50+
51+
// Then
52+
assertThat(result).isEmpty();
53+
}
54+
55+
@Test
56+
void violation_controlCharacter_namesTheCodePoint() {
57+
// Given a name with an embedded newline
58+
// When
59+
Optional<String> result = ColumnName.violation("a\nb");
60+
61+
// Then — the reason pins the exact code point so boundary errors stay actionable
62+
assertThat(result).hasValueSatisfying(reason ->
63+
assertThat(reason).contains("U+000A"));
64+
}
65+
66+
@Test
67+
void compareTo_ordersByValue() {
68+
// Given two names
69+
// When
70+
int result = ColumnName.of("a").compareTo(ColumnName.of("b"));
71+
72+
// Then
73+
assertThat(result).isNegative();
74+
}
75+
}

integration/src/test/java/io/github/dfa1/vortex/integration/ColumnNameEdgeCasesIntegrationTest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ void jniWritesEmptyColumnName_javaRejectsItByPolicy(@TempDir Path tmp) throws IO
6969
// into name-keyed APIs and SQL identifiers
7070
assertThat(result)
7171
.isInstanceOf(VortexException.class)
72-
.hasMessageContaining("blank field name in file schema");
72+
.hasMessageContaining("invalid field name in file schema")
73+
.hasMessageContaining("blank field name");
7374
}
7475

7576
@Test

reader/src/main/java/io/github/dfa1/vortex/reader/PostscriptParser.java

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -254,22 +254,17 @@ private static DType convertDType(io.github.dfa1.vortex.core.fbs.FbsDType fbs, i
254254
// file, and the name-keyed Chunk API would silently drop a column.
255255
throw new VortexException("duplicate field name in file schema: " + name);
256256
}
257-
// Same strict name policy as the write side: blank and control-character
258-
// names are wire-legal but almost certainly a bug in the producing
259-
// pipeline — reject with a message that says so rather than propagate
260-
// unusable names into name-keyed APIs and SQL identifiers.
261-
if (name.isBlank()) {
262-
throw new VortexException("blank field name in file schema (field index "
263-
+ i + "): wire-legal, but rejected by policy — likely a bug in "
264-
+ "the pipeline that produced this file");
265-
}
266-
for (int c = 0; c < name.length(); c++) {
267-
if (Character.isISOControl(name.charAt(c))) {
268-
throw new VortexException(
269-
"field name in file schema contains control character U+%04X (field index %d)"
270-
.formatted((int) name.charAt(c), i));
271-
}
272-
}
257+
// Same strict name policy as the write side (ColumnName is the single
258+
// source of truth): blank and control-character names are wire-legal but
259+
// almost certainly a bug in the producing pipeline — reject with a message
260+
// that says so rather than propagate unusable names into name-keyed APIs
261+
// and SQL identifiers.
262+
final int fieldIndex = i;
263+
io.github.dfa1.vortex.core.model.ColumnName.violation(name).ifPresent(reason -> {
264+
throw new VortexException("invalid field name in file schema (field index "
265+
+ fieldIndex + "): " + reason
266+
+ " — likely a bug in the pipeline that produced this file");
267+
});
273268
names.add(name);
274269
}
275270
for (int i = 0; i < s.dtypesLength(); i++) {

reader/src/test/java/io/github/dfa1/vortex/reader/PostscriptParserDTypeGuardsTest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ void convertDType_blankFieldName_throwsVortexException(String name) {
5757
// When / Then
5858
assertThatThrownBy(() -> PostscriptParser.parseBlobs(minimalFooter(), flatLayout(), dtype))
5959
.isInstanceOf(VortexException.class)
60-
.hasMessageContaining("blank field name in file schema");
60+
.hasMessageContaining("invalid field name in file schema")
61+
.hasMessageContaining("blank field name");
6162
}
6263

6364
@Test

writer/src/main/java/io/github/dfa1/vortex/writer/VortexWriter.java

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -144,18 +144,11 @@ private VortexWriter(
144144
if (!uniqueNames.add(name)) {
145145
throw new IllegalArgumentException("duplicate field name: " + name);
146146
}
147-
if (name.isBlank()) {
148-
throw new IllegalArgumentException(
149-
"blank field name (wire-legal, but a footgun vortex-java refuses to write)");
150-
}
151-
for (int i = 0; i < name.length(); i++) {
152-
// NUL aborts the reference toolchain's Arrow FFI export (SIGABRT in arrow-rs,
153-
// measured 2026-07-04); other control characters poison SQL/CSV/log renderings.
154-
if (Character.isISOControl(name.charAt(i))) {
155-
throw new IllegalArgumentException(
156-
"field name contains control character U+%04X".formatted((int) name.charAt(i)));
157-
}
158-
}
147+
// Policy chokepoint: ColumnName.violation — NUL additionally aborts the reference
148+
// toolchain's Arrow FFI export (SIGABRT in arrow-rs, measured 2026-07-04).
149+
io.github.dfa1.vortex.core.model.ColumnName.violation(name).ifPresent(reason -> {
150+
throw new IllegalArgumentException(reason);
151+
});
159152
}
160153
this.channel = channel;
161154
this.schema = schema;

0 commit comments

Comments
 (0)