You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@paimon.apache.org by lz...@apache.org on 2026/09/17 08:06:55 UTC

(paimon-rust) branch main updated: feat(go): support case-insensitive column names on reads (#796)

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 <qc...@gmail.com>
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: