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:

Reply via email to