This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 30c4e51a feat(go): support case-insensitive column names on reads
(#796)
30c4e51a is described below
commit 30c4e51a24a541ce17f2dbf773adaa221d13ddb5
Author: jackylee <[email protected]>
AuthorDate: Thu Sep 17 16:06:50 2026 +0800
feat(go): support case-insensitive column names on reads (#796)
---
bindings/go/predicate.go | 130 +++++++++++++++++--------
bindings/go/read_builder.go | 45 ++++++++-
bindings/go/table.go | 2 +-
bindings/go/tests/paimon_test.go | 201 +++++++++++++++++++++++++++++++++++++++
docs/src/go-binding.md | 51 ++++++++++
5 files changed, 384 insertions(+), 45 deletions(-)
diff --git a/bindings/go/predicate.go b/bindings/go/predicate.go
index e8d36de0..d2c03d33 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 177c5953..95b2d0c6 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 d41a84bd..48dc49a4 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 621292a6..16968094 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 025f8e54..a1fd1695 100644
--- a/docs/src/go-binding.md
+++ b/docs/src/go-binding.md
@@ -523,6 +523,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:
@@ -582,6 +610,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: