diff --git a/bindings/go/predicate.go b/bindings/go/predicate.go index e8d36de07..d2c03d338 100644 --- a/bindings/go/predicate.go +++ b/bindings/go/predicate.go @@ -254,7 +254,20 @@ var errConsumedPredicate = fmt.Errorf("paimon: predicate already consumed or nil // It holds a Go-level reference to the Table and does not own any C resources, // so there is no Close() method. type PredicateBuilder struct { - table *Table + table *Table + caseSensitive bool +} + +// WithCaseSensitive returns a builder that matches column names by ASCII case +// folding when caseSensitive is false, rejecting a name that folds onto two +// schema columns as ambiguous. The default is true (exact match); the receiver is +// left unchanged. +// +// The choice is fixed when each predicate is built, because that is when the core +// resolves the column. ReadBuilder.WithCaseSensitive is the same switch for +// projection and has no bearing on a predicate built here. +func (pb *PredicateBuilder) WithCaseSensitive(caseSensitive bool) *PredicateBuilder { + return &PredicateBuilder{table: pb.table, caseSensitive: caseSensitive} } // Eq creates an equality predicate: column = value. @@ -333,16 +346,15 @@ func (pb *PredicateBuilder) NotIn(column string, values ...any) (*Predicate, err // buildLeafPredicate is a helper for comparison predicates that take (table, column, datum). func (pb *PredicateBuilder) buildLeafPredicate( - ffiVar *FFI[func(*paimonTable, *byte, paimonDatumC) (*paimonPredicate, error)], + ffiVar *FFI[func(*paimonTable, *byte, paimonDatumC, bool) (*paimonPredicate, error)], column string, datum Datum, ) (*Predicate, error) { t := pb.table if t.inner == nil { return nil, ErrClosed } - createFn := ffiVar.symbol(t.ctx) cCol := append([]byte(column), 0) - inner, err := createFn(t.inner, &cCol[0], datum.inner) + inner, err := ffiVar.symbol(t.ctx)(t.inner, &cCol[0], datum.inner, pb.caseSensitive) runtime.KeepAlive(cCol) runtime.KeepAlive(datum) if err != nil { @@ -354,16 +366,15 @@ func (pb *PredicateBuilder) buildLeafPredicate( // buildNullPredicate is a helper for IS NULL / IS NOT NULL predicates. func (pb *PredicateBuilder) buildNullPredicate( - ffiVar *FFI[func(*paimonTable, *byte) (*paimonPredicate, error)], + ffiVar *FFI[func(*paimonTable, *byte, bool) (*paimonPredicate, error)], column string, ) (*Predicate, error) { t := pb.table if t.inner == nil { return nil, ErrClosed } - createFn := ffiVar.symbol(t.ctx) cCol := append([]byte(column), 0) - inner, err := createFn(t.inner, &cCol[0]) + inner, err := ffiVar.symbol(t.ctx)(t.inner, &cCol[0], pb.caseSensitive) runtime.KeepAlive(cCol) if err != nil { return nil, err @@ -374,7 +385,7 @@ func (pb *PredicateBuilder) buildNullPredicate( // buildInPredicate is a helper for IS IN / IS NOT IN predicates. func (pb *PredicateBuilder) buildInPredicate( - ffiVar *FFI[func(*paimonTable, *byte, unsafe.Pointer, uintptr) (*paimonPredicate, error)], + ffiVar *FFI[func(*paimonTable, *byte, unsafe.Pointer, uintptr, bool) (*paimonPredicate, error)], column string, values []any, ) (*Predicate, error) { t := pb.table @@ -389,13 +400,14 @@ func (pb *PredicateBuilder) buildInPredicate( } datums[i] = d.inner } - createFn := ffiVar.symbol(t.ctx) cCol := append([]byte(column), 0) var datumsPtr unsafe.Pointer if len(datums) > 0 { datumsPtr = unsafe.Pointer(&datums[0]) } - inner, err := createFn(t.inner, &cCol[0], datumsPtr, uintptr(len(datums))) + inner, err := ffiVar.symbol(t.ctx)( + t.inner, &cCol[0], datumsPtr, uintptr(len(datums)), pb.caseSensitive, + ) runtime.KeepAlive(cCol) runtime.KeepAlive(datums) runtime.KeepAlive(values) @@ -462,28 +474,51 @@ var ffiPredicateFree = newFFI(ffiOpts{ } }) -var ffiPredicateEqual = newPredicateLeafFFI("paimon_predicate_equal") -var ffiPredicateNotEqual = newPredicateLeafFFI("paimon_predicate_not_equal") -var ffiPredicateLessThan = newPredicateLeafFFI("paimon_predicate_less_than") -var ffiPredicateLessOrEqual = newPredicateLeafFFI("paimon_predicate_less_or_equal") -var ffiPredicateGreaterThan = newPredicateLeafFFI("paimon_predicate_greater_than") -var ffiPredicateGreaterOrEqual = newPredicateLeafFFI("paimon_predicate_greater_or_equal") +// boolByte renders a Go bool as the single byte a Rust `bool` argument expects. +// +// A Rust `bool` occupies one byte and admits no value but 0 or 1, so every FFI +// wrapper that passes one declares the argument as a 1-byte integer (the ffi +// package documents TypeUint8 as the way to pass a bool) and writes an explicit +// 0/1 through here. This is the binding's first non-pointer scalar narrower than +// four bytes; do not copy a wider type into such an argument, and do not hand the +// callee a Go bool directly — a byte that is neither 0 nor 1 degrades silently to +// case-sensitive rather than failing. +func boolByte(value bool) uint8 { + if value { + return 1 + } + return 0 +} -// newPredicateLeafFFI creates an FFI wrapper for comparison predicate functions -// with signature: (table, column, datum) -> result_predicate. -func newPredicateLeafFFI(sym string) *FFI[func(*paimonTable, *byte, paimonDatumC) (*paimonPredicate, error)] { +var ffiPredicateEqual = newPredicateLeafFFI("paimon_predicate_equal_with_case_sensitive") +var ffiPredicateNotEqual = newPredicateLeafFFI("paimon_predicate_not_equal_with_case_sensitive") +var ffiPredicateLessThan = newPredicateLeafFFI("paimon_predicate_less_than_with_case_sensitive") +var ffiPredicateLessOrEqual = newPredicateLeafFFI("paimon_predicate_less_or_equal_with_case_sensitive") +var ffiPredicateGreaterThan = newPredicateLeafFFI("paimon_predicate_greater_than_with_case_sensitive") +var ffiPredicateGreaterOrEqual = newPredicateLeafFFI("paimon_predicate_greater_or_equal_with_case_sensitive") + +// newPredicateLeafFFI wraps the (table, column, datum, case_sensitive) form. +func newPredicateLeafFFI( + sym string, +) *FFI[func(*paimonTable, *byte, paimonDatumC, bool) (*paimonPredicate, error)] { return newFFI(ffiOpts{ - sym: contextKey(sym), - rType: &typeResultPredicate, - aTypes: []*ffi.Type{&ffi.TypePointer, &ffi.TypePointer, &typePaimonDatum}, - }, func(ctx context.Context, ffiCall ffiCall) func(*paimonTable, *byte, paimonDatumC) (*paimonPredicate, error) { - return func(table *paimonTable, column *byte, datum paimonDatumC) (*paimonPredicate, error) { + sym: contextKey(sym), + rType: &typeResultPredicate, + aTypes: []*ffi.Type{ + &ffi.TypePointer, &ffi.TypePointer, &typePaimonDatum, &ffi.TypeUint8, + }, + }, func(ctx context.Context, ffiCall ffiCall) func(*paimonTable, *byte, paimonDatumC, bool) (*paimonPredicate, error) { + return func( + table *paimonTable, column *byte, datum paimonDatumC, caseSensitive bool, + ) (*paimonPredicate, error) { + flag := boolByte(caseSensitive) var result resultPredicate ffiCall( unsafe.Pointer(&result), unsafe.Pointer(&table), unsafe.Pointer(&column), unsafe.Pointer(&datum), + unsafe.Pointer(&flag), ) if result.error != nil { return nil, parseError(ctx, result.error) @@ -493,23 +528,26 @@ func newPredicateLeafFFI(sym string) *FFI[func(*paimonTable, *byte, paimonDatumC }) } -var ffiPredicateIsNull = newPredicateNullFFI("paimon_predicate_is_null") -var ffiPredicateIsNotNull = newPredicateNullFFI("paimon_predicate_is_not_null") +var ffiPredicateIsNull = newPredicateNullFFI("paimon_predicate_is_null_with_case_sensitive") +var ffiPredicateIsNotNull = newPredicateNullFFI("paimon_predicate_is_not_null_with_case_sensitive") -// newPredicateNullFFI creates an FFI wrapper for null-check predicate functions -// with signature: (table, column) -> result_predicate. -func newPredicateNullFFI(sym string) *FFI[func(*paimonTable, *byte) (*paimonPredicate, error)] { +// newPredicateNullFFI wraps the (table, column, case_sensitive) form. +func newPredicateNullFFI( + sym string, +) *FFI[func(*paimonTable, *byte, bool) (*paimonPredicate, error)] { return newFFI(ffiOpts{ sym: contextKey(sym), rType: &typeResultPredicate, - aTypes: []*ffi.Type{&ffi.TypePointer, &ffi.TypePointer}, - }, func(ctx context.Context, ffiCall ffiCall) func(*paimonTable, *byte) (*paimonPredicate, error) { - return func(table *paimonTable, column *byte) (*paimonPredicate, error) { + aTypes: []*ffi.Type{&ffi.TypePointer, &ffi.TypePointer, &ffi.TypeUint8}, + }, func(ctx context.Context, ffiCall ffiCall) func(*paimonTable, *byte, bool) (*paimonPredicate, error) { + return func(table *paimonTable, column *byte, caseSensitive bool) (*paimonPredicate, error) { + flag := boolByte(caseSensitive) var result resultPredicate ffiCall( unsafe.Pointer(&result), unsafe.Pointer(&table), unsafe.Pointer(&column), + unsafe.Pointer(&flag), ) if result.error != nil { return nil, parseError(ctx, result.error) @@ -519,18 +557,25 @@ func newPredicateNullFFI(sym string) *FFI[func(*paimonTable, *byte) (*paimonPred }) } -var ffiPredicateIsIn = newPredicateInFFI("paimon_predicate_is_in") -var ffiPredicateIsNotIn = newPredicateInFFI("paimon_predicate_is_not_in") +var ffiPredicateIsIn = newPredicateInFFI("paimon_predicate_is_in_with_case_sensitive") +var ffiPredicateIsNotIn = newPredicateInFFI("paimon_predicate_is_not_in_with_case_sensitive") -// newPredicateInFFI creates an FFI wrapper for IN/NOT IN predicate functions -// with signature: (table, column, datums, datums_len) -> result_predicate. -func newPredicateInFFI(sym string) *FFI[func(*paimonTable, *byte, unsafe.Pointer, uintptr) (*paimonPredicate, error)] { +// newPredicateInFFI wraps the (table, column, datums, len, case_sensitive) form. +func newPredicateInFFI( + sym string, +) *FFI[func(*paimonTable, *byte, unsafe.Pointer, uintptr, bool) (*paimonPredicate, error)] { return newFFI(ffiOpts{ - sym: contextKey(sym), - rType: &typeResultPredicate, - aTypes: []*ffi.Type{&ffi.TypePointer, &ffi.TypePointer, &ffi.TypePointer, &ffi.TypePointer}, - }, func(ctx context.Context, ffiCall ffiCall) func(*paimonTable, *byte, unsafe.Pointer, uintptr) (*paimonPredicate, error) { - return func(table *paimonTable, column *byte, datums unsafe.Pointer, datumsLen uintptr) (*paimonPredicate, error) { + sym: contextKey(sym), + rType: &typeResultPredicate, + aTypes: []*ffi.Type{ + &ffi.TypePointer, &ffi.TypePointer, &ffi.TypePointer, &ffi.TypePointer, &ffi.TypeUint8, + }, + }, func(ctx context.Context, ffiCall ffiCall) func(*paimonTable, *byte, unsafe.Pointer, uintptr, bool) (*paimonPredicate, error) { + return func( + table *paimonTable, column *byte, datums unsafe.Pointer, datumsLen uintptr, + caseSensitive bool, + ) (*paimonPredicate, error) { + flag := boolByte(caseSensitive) var result resultPredicate ffiCall( unsafe.Pointer(&result), @@ -538,6 +583,7 @@ func newPredicateInFFI(sym string) *FFI[func(*paimonTable, *byte, unsafe.Pointer unsafe.Pointer(&column), unsafe.Pointer(&datums), unsafe.Pointer(&datumsLen), + unsafe.Pointer(&flag), ) if result.error != nil { return nil, parseError(ctx, result.error) diff --git a/bindings/go/read_builder.go b/bindings/go/read_builder.go index 177c59531..95b2d0c6f 100644 --- a/bindings/go/read_builder.go +++ b/bindings/go/read_builder.go @@ -46,8 +46,9 @@ func (rb *ReadBuilder) Close() { } // WithProjection sets column projection by name. Output order follows the -// caller-specified order. Unknown or duplicate names cause NewRead() to fail; -// an empty list is a valid zero-column projection. +// caller-specified order. A name that matches no schema column under any case +// sensitivity is rejected immediately; case-dependent errors and duplicate names +// cause NewRead() to fail. An empty list is a valid zero-column projection. func (rb *ReadBuilder) WithProjection(columns []string) error { if rb.inner == nil { return ErrClosed @@ -56,6 +57,24 @@ func (rb *ReadBuilder) WithProjection(columns []string) error { return projFn(rb.inner, columns) } +// WithCaseSensitive sets whether the names given to WithProjection must match +// the schema exactly. The default is true. With false, names are matched by +// ASCII case folding, and a name that folds onto two different schema columns is +// rejected as ambiguous. Either way the returned records carry the schema's own +// spelling, not the requested one. +// +// This does not affect predicates. A predicate resolves its column when it is +// built, so its case sensitivity comes from the builder that produced it — see +// PredicateBuilder.WithCaseSensitive — and is unaffected by this setting. Call +// order relative to WithProjection does not matter: projection names are resolved +// in NewRead. +func (rb *ReadBuilder) WithCaseSensitive(caseSensitive bool) error { + if rb.inner == nil { + return ErrClosed + } + return ffiReadBuilderWithCaseSensitive.symbol(rb.ctx)(rb.inner, caseSensitive) +} + // WithFilter sets a filter predicate for scan planning and read-side pruning. // // The predicate is used in two phases: @@ -177,6 +196,28 @@ var ffiReadBuilderWithProjection = newFFI(ffiOpts{ } }) +// The trailing `bool` is passed as a 1-byte integer written through boolByte; see +// that function for why. +var ffiReadBuilderWithCaseSensitive = newFFI(ffiOpts{ + sym: "paimon_read_builder_with_case_sensitive", + rType: &ffi.TypePointer, + aTypes: []*ffi.Type{&ffi.TypePointer, &ffi.TypeUint8}, +}, func(ctx context.Context, ffiCall ffiCall) func(rb *paimonReadBuilder, caseSensitive bool) error { + return func(rb *paimonReadBuilder, caseSensitive bool) error { + flag := boolByte(caseSensitive) + var errPtr *paimonError + ffiCall( + unsafe.Pointer(&errPtr), + unsafe.Pointer(&rb), + unsafe.Pointer(&flag), + ) + if errPtr != nil { + return parseError(ctx, errPtr) + } + return nil + } +}) + var ffiReadBuilderWithFilter = newFFI(ffiOpts{ sym: "paimon_read_builder_with_filter", rType: &ffi.TypePointer, diff --git a/bindings/go/table.go b/bindings/go/table.go index d41a84bdd..48dc49a41 100644 --- a/bindings/go/table.go +++ b/bindings/go/table.go @@ -47,7 +47,7 @@ func (t *Table) Close() { // PredicateBuilder returns a builder for creating filter predicates on this table. func (t *Table) PredicateBuilder() *PredicateBuilder { - return &PredicateBuilder{table: t} + return &PredicateBuilder{table: t, caseSensitive: true} } // NewReadBuilder creates a ReadBuilder for this table. diff --git a/bindings/go/tests/paimon_test.go b/bindings/go/tests/paimon_test.go index 621292a6f..169680940 100644 --- a/bindings/go/tests/paimon_test.go +++ b/bindings/go/tests/paimon_test.go @@ -879,3 +879,204 @@ func TestReadWithProjection(t *testing.T) { } } } + +// TestReadWithCaseInsensitiveProjection covers ReadBuilder.WithCaseSensitive. +// The fixture columns are lowercase, so an uppercase projection is the reverse +// of the Hive/Spark case it exists for, and exercises the same code path. +func TestReadWithCaseInsensitiveProjection(t *testing.T) { + table := openTestTable(t) + + // A name that matches no column under any casing is rejected by + // WithProjection itself, which is what makes the deferral below case-specific + // rather than a blanket "resolution happens later". + rbTypo, err := table.NewReadBuilder() + if err != nil { + t.Fatalf("Failed to create read builder: %v", err) + } + defer rbTypo.Close() + if err := rbTypo.WithProjection([]string{"nosuchcolumn"}); err == nil { + t.Fatal("expected WithProjection to reject a name no casing can match") + } + + // Default: exact matching, so the uppercase names do not resolve. The + // failure surfaces in NewRead, not in WithProjection. + rbDefault, err := table.NewReadBuilder() + if err != nil { + t.Fatalf("Failed to create read builder: %v", err) + } + defer rbDefault.Close() + if err := rbDefault.WithProjection([]string{"ID", "NAME"}); err != nil { + t.Fatalf("WithProjection should defer resolution: %v", err) + } + if _, err := rbDefault.NewRead(); err == nil { + t.Fatal("expected NewRead to reject an uppercase projection by default") + } + + // Explicitly asking for case sensitivity must behave like the default. This + // pins the `true` value of the flag, not just the `false` one. + rbExact, err := table.NewReadBuilder() + if err != nil { + t.Fatalf("Failed to create read builder: %v", err) + } + defer rbExact.Close() + if err := rbExact.WithCaseSensitive(true); err != nil { + t.Fatalf("WithCaseSensitive(true) failed: %v", err) + } + if err := rbExact.WithProjection([]string{"ID", "NAME"}); err != nil { + t.Fatalf("WithProjection failed: %v", err) + } + if _, err := rbExact.NewRead(); err == nil { + t.Fatal("expected WithCaseSensitive(true) to keep rejecting uppercase names") + } + + // Opting out resolves the names, and readRows asserts the records carry the + // schema's own lowercase spelling. Both call orders must agree, because the + // projection is resolved in NewRead rather than when it is set. + for _, order := range []string{"flag-first", "projection-first"} { + rb, err := table.NewReadBuilder() + if err != nil { + t.Fatalf("%s: failed to create read builder: %v", order, err) + } + if order == "flag-first" { + if err := rb.WithCaseSensitive(false); err != nil { + rb.Close() + t.Fatalf("%s: WithCaseSensitive(false) failed: %v", order, err) + } + if err := rb.WithProjection([]string{"ID", "NAME"}); err != nil { + rb.Close() + t.Fatalf("%s: WithProjection failed: %v", order, err) + } + } else { + if err := rb.WithProjection([]string{"ID", "NAME"}); err != nil { + rb.Close() + t.Fatalf("%s: WithProjection failed: %v", order, err) + } + if err := rb.WithCaseSensitive(false); err != nil { + rb.Close() + t.Fatalf("%s: WithCaseSensitive(false) failed: %v", order, err) + } + } + rows := readRows(t, rb) + rb.Close() + if len(rows) != 3 { + t.Fatalf("%s: expected 3 rows, got %d: %v", order, len(rows), rows) + } + } +} + +// TestCaseSensitivitySwitchesAreIndependent pins the asymmetry the two doc +// comments promise: the read builder's flag governs projection only, and a +// predicate keeps the mode of the builder that produced it whatever the read +// builder is set to. Both halves were only ever documented, so without this the +// contract rests on prose. +func TestCaseSensitivitySwitchesAreIndependent(t *testing.T) { + table := openTestTable(t) + + rb, err := table.NewReadBuilder() + if err != nil { + t.Fatalf("Failed to create read builder: %v", err) + } + defer rb.Close() + if err := rb.WithCaseSensitive(false); err != nil { + t.Fatalf("WithCaseSensitive(false) failed: %v", err) + } + // The read builder folds case, but this predicate does not: its column was + // resolved when it was built, before the read builder was ever consulted. + if pred, err := table.PredicateBuilder().Eq("ID", int32(1)); err == nil { + pred.Close() + t.Fatal("expected the read-builder flag not to reach a default-builder predicate") + } + + // The mirror image: a case-folding predicate stays case-folding on a read + // builder left at the default. + rbExact, err := table.NewReadBuilder() + if err != nil { + t.Fatalf("Failed to create read builder: %v", err) + } + defer rbExact.Close() + pred, err := table.PredicateBuilder().WithCaseSensitive(false).Eq("ID", int32(1)) + if err != nil { + t.Fatalf("case-folding predicate failed to build: %v", err) + } + if err := rbExact.WithFilter(pred); err != nil { + t.Fatalf("WithFilter failed: %v", err) + } + rows := readRows(t, rbExact) + if len(rows) != 1 || rows[0].id != 1 { + t.Fatalf("expected exactly the id=1 row, got %v", rows) + } +} + +// TestPredicateBuilderCaseSensitivity covers the predicate half. A predicate +// resolves its column when it is built, so the choice belongs to the builder and +// ReadBuilder.WithCaseSensitive has no bearing on it. +func TestPredicateBuilderCaseSensitivity(t *testing.T) { + table := openTestTable(t) + + for _, mode := range []struct { + name string + pb *paimon.PredicateBuilder + caseSensitive bool + }{ + {"default", table.PredicateBuilder(), true}, + {"case-sensitive", table.PredicateBuilder().WithCaseSensitive(true), true}, + {"case-insensitive", table.PredicateBuilder().WithCaseSensitive(false), false}, + } { + t.Run(mode.name, func(t *testing.T) { + // Exercise every bound symbol, including all three argument shapes. + for _, tc := range []struct { + name string + column string + build func(string) (*paimon.Predicate, error) + want []int32 + }{ + {"Eq", "id", func(c string) (*paimon.Predicate, error) { return mode.pb.Eq(c, int32(2)) }, []int32{2}}, + {"NotEq", "id", func(c string) (*paimon.Predicate, error) { return mode.pb.NotEq(c, int32(2)) }, []int32{1, 3}}, + {"Lt", "id", func(c string) (*paimon.Predicate, error) { return mode.pb.Lt(c, int32(2)) }, []int32{1}}, + {"Le", "id", func(c string) (*paimon.Predicate, error) { return mode.pb.Le(c, int32(2)) }, []int32{1, 2}}, + {"Gt", "id", func(c string) (*paimon.Predicate, error) { return mode.pb.Gt(c, int32(2)) }, []int32{3}}, + {"Ge", "id", func(c string) (*paimon.Predicate, error) { return mode.pb.Ge(c, int32(2)) }, []int32{2, 3}}, + {"IsNull", "name", mode.pb.IsNull, nil}, + {"IsNotNull", "name", mode.pb.IsNotNull, []int32{1, 2, 3}}, + {"In", "id", func(c string) (*paimon.Predicate, error) { return mode.pb.In(c, int32(1), int32(3)) }, []int32{1, 3}}, + {"NotIn", "id", func(c string) (*paimon.Predicate, error) { return mode.pb.NotIn(c, int32(1), int32(3)) }, []int32{2}}, + } { + t.Run(tc.name, func(t *testing.T) { + for _, column := range []string{tc.column, strings.ToUpper(tc.column)} { + t.Run(column, func(t *testing.T) { + pred, err := tc.build(column) + if mode.caseSensitive && column != tc.column { + if err == nil { + pred.Close() + t.Fatal("expected an uppercase column to fail") + } + return + } + if err != nil { + t.Fatalf("Failed to create predicate: %v", err) + } + defer pred.Close() + + rb, err := table.NewReadBuilder() + if err != nil { + t.Fatalf("Failed to create read builder: %v", err) + } + defer rb.Close() + if err := rb.WithFilter(pred); err != nil { + t.Fatalf("WithFilter failed: %v", err) + } + var ids []int32 + for _, r := range readRows(t, rb) { + ids = append(ids, r.id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + if !reflect.DeepEqual(ids, tc.want) { + t.Fatalf("Expected IDs %v, got %v", tc.want, ids) + } + }) + } + }) + } + }) + } +} diff --git a/docs/src/go-binding.md b/docs/src/go-binding.md index 40acda2d2..55ec7b7d0 100644 --- a/docs/src/go-binding.md +++ b/docs/src/go-binding.md @@ -485,6 +485,34 @@ if err := rb.WithProjection([]string{"id", "name"}); err != nil { // Continue with scan-then-read as above... ``` +### Case-Insensitive Column Names + +Projected names must match the schema exactly by default. Pass `false` to +`WithCaseSensitive` to match by ASCII case folding instead — useful for tables +whose columns were declared in upper case by Hive or Spark. A name that folds onto +two schema columns differing only in case is rejected as ambiguous. + +```go +if err := rb.WithCaseSensitive(false); err != nil { + log.Fatal(err) +} +if err := rb.WithProjection([]string{"ID", "NAME"}); err != nil { + log.Fatal(err) +} +``` + +Call order does not matter: names that differ from the schema only in case are +resolved in `NewRead`, which is also where such a name surfaces as an error. A +name that matches no column under any casing is still rejected by +`WithProjection` itself. + +The returned records always carry the schema's own spelling, not the spelling you +asked for, so downstream column lookups stay stable. + +This setting covers projection only. A predicate resolves its column when it is +built, so its case sensitivity comes from which builder created it — see +[Case-Insensitive Predicates](#case-insensitive-predicates). + ## Filter Push-Down Filter push-down prunes data at two levels: @@ -544,6 +572,29 @@ if err := rb.WithFilter(pred); err != nil { // Continue with scan-then-read... ``` +### Case-Insensitive Predicates + +`Table.PredicateBuilder` matches column names exactly. Its +`WithCaseSensitive(false)` returns a builder that folds ASCII case instead; every +predicate that builder produces resolves its column that way. + +```go +pb := table.PredicateBuilder().WithCaseSensitive(false) + +// Resolves to the schema's "id" column. +pred, err := pb.Eq("ID", int32(1)) +``` + +The choice is fixed when the predicate is built, which is why it lives on the +builder that produces predicates rather than on the read builder — the same +`WithCaseSensitive` switch, on the object that owns the resolution. +`ReadBuilder.WithCaseSensitive` does not affect predicates, and a predicate from a +case-folding builder keeps that behaviour whatever the read builder is set to. + +The predicate stores the schema's own spelling of the column, not the one you +passed, so file-level pruning and row-level filtering apply to it exactly as they +would to an exactly-spelled predicate. + ### Compound Predicates Combine predicates with `And`, `Or`, and `Not`. The `predicate` sub-package provides variadic helpers: