This is an automated email from the ASF dual-hosted git repository.
zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git
The following commit(s) were added to refs/heads/main by this push:
new 7aee8e973 feat(table): build transformed dynamic partition predicates
(#1675)
7aee8e973 is described below
commit 7aee8e9731cdf97e245de9c53e150434033ff004
Author: Minh Vu <[email protected]>
AuthorDate: Wed Sep 30 18:59:07 2026 +0200
feat(table): build transformed dynamic partition predicates (#1675)
* feat(expr): bind predicates over transform terms
Signed-off-by: Minh Vu <[email protected]>
* fix(expr): reject unknown transform predicates
Signed-off-by: Minh Vu <[email protected]>
* feat(table): match transformed dynamic partitions
Signed-off-by: Minh Vu <[email protected]>
* fix(expr): satisfy dynamic transform lint
Signed-off-by: Minh Vu <[email protected]>
* test(table): cover transformed rewrite filters
Signed-off-by: Minh Vu <[email protected]>
* fix(table): preserve dynamic partition source paths
Signed-off-by: Minh Vu <[email protected]>
* fix(table): validate dynamic partition transforms before matching
Signed-off-by: Minh Vu <[email protected]>
* test(table): cover transformed overwrite predicate evaluation
Signed-off-by: Minh Vu <[email protected]>
* test(table): satisfy partition predicate lint
Signed-off-by: Minh Vu <[email protected]>
* fix(table): validate dynamic overwrite partition values
Signed-off-by: Minh Vu <[email protected]>
* fix(table): support source-less void partitions
Signed-off-by: Minh Vu <[email protected]>
* fix(table): trim dynamic partition predicate validation
Signed-off-by: Minh Vu <[email protected]>
* style(table): satisfy nlreturn
Signed-off-by: Minh Vu <[email protected]>
* fix(codec): support ns timestamps in schema fingerprint
Signed-off-by: Minh Vu <[email protected]>
* test(codec): align ns timestamp cache validation
Signed-off-by: Minh Vu <[email protected]>
* ci: refresh integration checks against latest main
Signed-off-by: Minh Vu <[email protected]>
---------
Signed-off-by: Minh Vu <[email protected]>
---
partitions.go | 8 +-
partitions_test.go | 6 +
table/internal/partition_predicate.go | 194 +++++++++--
table/internal/partition_predicate_test.go | 524 ++++++++++++++++++++++++++---
table/transaction_internal_test.go | 15 +
5 files changed, 671 insertions(+), 76 deletions(-)
diff --git a/partitions.go b/partitions.go
index 4c87b7c39..3cf043e18 100644
--- a/partitions.go
+++ b/partitions.go
@@ -777,7 +777,13 @@ func (ps PartitionSpec) IsUnpartitioned() bool {
}
for _, f := range ps.fields {
- if _, ok := f.Transform.(VoidTransform); !ok {
+ switch transform := f.Transform.(type) {
+ case VoidTransform:
+ case *VoidTransform:
+ if transform == nil {
+ return false
+ }
+ default:
return false
}
}
diff --git a/partitions_test.go b/partitions_test.go
index 3ca53f754..a1b5a4b74 100644
--- a/partitions_test.go
+++ b/partitions_test.go
@@ -318,6 +318,12 @@ func TestUnpartitionedWithVoidField(t *testing.T) {
assert.True(t, spec.IsUnpartitioned())
+ pointerSpec := iceberg.NewPartitionSpec(iceberg.PartitionField{
+ SourceIDs: []int{3}, FieldID: 1001, Name: "void", Transform:
&iceberg.VoidTransform{},
+ })
+
+ assert.True(t, pointerSpec.IsUnpartitioned())
+
spec2 := iceberg.NewPartitionSpec(iceberg.PartitionField{
SourceIDs: []int{3}, FieldID: 1001, Name: "void", Transform:
iceberg.VoidTransform{},
}, iceberg.PartitionField{
diff --git a/table/internal/partition_predicate.go
b/table/internal/partition_predicate.go
index 663ed97fb..609d5e627 100644
--- a/table/internal/partition_predicate.go
+++ b/table/internal/partition_predicate.go
@@ -39,46 +39,62 @@ import (
// result is an OR across distinct partitions, each clause an AND across the
// spec's fields:
//
-// source == value when the partition value is present
-// IsNaN(source) when the value is a floating-point NaN (x == NaN is
never true)
-// IsNull(source) when the partition value is absent or nil
+// transform(source) == value when the partition value is present
+// IsNaN(transform(source)) when the value is a floating-point NaN (x ==
NaN is never true)
+// IsNull(transform(source)) when the partition value is explicitly nil
+// Missing partition field IDs are rejected with ErrInvalidArgument.
//
// Duplicate tuples collapse to a single clause, and an empty input yields
// AlwaysFalse (matching nothing). Callers are expected to pass a partitioned
// spec; dynamic partition overwrite rejects unpartitioned tables upstream.
+// Void fields are also accepted when their source column has been dropped (the
+// spec represents those tombstones with source ID 0), because void always
+// produces a null partition value and does not need a source column.
//
-// Because only identity transforms are accepted (see below), the partition
-// value equals the source-column value, so "source == value" selects exactly
-// the rows in that partition. Non-identity transforms (bucket, truncate, the
-// time transforms) cannot be matched by a source-column predicate and need
-// partition-level matching instead; they are rejected here and tracked as a
-// follow-up under issue #1215.
+// The transform is retained in the row predicate so the post-transform
+// partition value is compared against transform(source), not against the raw
+// source column. This is phase 1 of issue #1216. The current overwrite path
+// cannot execute non-identity predicates for partial-file rewrites because
+// source-column metrics are conservative and the Substrait row-filter
converter
+// rejects transformed terms; partition-level strict matching is still needed
+// before this helper can drive those rewrites (tracked in issue #1215).
func BuildPartitionMatchPredicate(spec iceberg.PartitionSpec, schema
*iceberg.Schema, partitions []map[int]any) (iceberg.BooleanExpression, error) {
type fieldRef struct {
- id int
- name string
+ id int
+ name string
+ transform iceberg.Transform
+ resultType iceberg.Type
+ isVoid bool
}
var fields []fieldRef
for _, f := range spec.Fields() {
- if _, ok := f.Transform.(iceberg.IdentityTransform); !ok {
- return nil, fmt.Errorf("%w: dynamic partition overwrite
supports identity-transform partition fields only, got %s on %q (tracked in
https://github.com/apache/iceberg-go/issues/1215)",
- iceberg.ErrNotImplemented, f.Transform, f.Name)
- }
-
- // Identity transforms always have exactly one source column.
+ // Partition transforms currently have exactly one source
column.
if len(f.SourceIDs) != 1 {
- return nil, fmt.Errorf("%w: identity partition field %q
must have exactly one source id, got %d",
+ return nil, fmt.Errorf("%w: partition field %q must
have exactly one source id, got %d",
iceberg.ErrInvalidArgument, f.Name,
len(f.SourceIDs))
}
+ if isVoidTransform(f.Transform) {
+ // A void field can survive source-column removal as a
source-less
+ // tombstone. Its output is always null, so resolving
or binding the
+ // source would add no information and would reject
source ID 0.
+ fields = append(fields, fieldRef{id: f.FieldID, name:
f.Name, transform: f.Transform, isVoid: true})
+
+ continue
+ }
- src, ok := schema.FindFieldByID(f.SourceIDs[0])
+ sourceName, ok := schema.FindColumnName(f.SourceIDs[0])
if !ok {
return nil, fmt.Errorf("%w: partition field %q
references unknown source id %d",
iceberg.ErrInvalidArgument, f.Name,
f.SourceIDs[0])
}
-
- fields = append(fields, fieldRef{id: f.FieldID, name: src.Name})
+ bound, err := iceberg.NewUnboundTransform(f.Transform,
iceberg.Reference(sourceName)).Bind(schema, true)
+ if err != nil {
+ return nil, fmt.Errorf("partition field %q: %w",
f.Name, err)
+ }
+ fields = append(fields, fieldRef{
+ id: f.FieldID, name: sourceName, transform:
f.Transform, resultType: bound.Type(),
+ })
}
// NewAnd/NewOr fold away the AlwaysTrue/AlwaysFalse seeds, so a
single-field,
@@ -96,20 +112,47 @@ func BuildPartitionMatchPredicate(spec
iceberg.PartitionSpec, schema *iceberg.Sc
sigParts := make([]string, 0, len(fields))
for _, fr := range fields {
- ref := iceberg.Reference(fr.name)
-
- // A missing key (!ok) is treated like an explicit nil.
In practice
// DataFile.Partition() stores a null partition value
as a nil-valued
- // entry rather than an absent key; !ok is a defensive
guard.
+ // entry. A missing field ID is malformed and must fail
closed rather
+ // than silently under-deleting.
val, ok := part[fr.id]
- if !ok || val == nil {
- clause = iceberg.NewAnd(clause,
iceberg.IsNull(ref))
+ if !ok {
+ return nil, fmt.Errorf("%w: partition field %q
(field id %d) is missing from partition tuple",
+ iceberg.ErrInvalidArgument, fr.name,
fr.id)
+ }
+ if val == nil {
+ if fr.isVoid {
+ // Void transforms produce null for
every source value, so their
+ // null predicate is AlwaysTrue.
Keeping the clause unchanged
+ // avoids inventing a reference for a
source-less tombstone.
+ sigParts = append(sigParts,
strconv.Itoa(fr.id)+":null")
+
+ continue
+ }
+
+ term := partitionTerm(fr.transform, fr.name)
+ clause = iceberg.NewAnd(clause,
iceberg.IsNull(term))
sigParts = append(sigParts,
strconv.Itoa(fr.id)+":null")
continue
}
- if isNaN(val) {
+ if fr.isVoid {
+ return nil, fmt.Errorf("%w: partition value for
void field %q must be nil",
+ iceberg.ErrInvalidArgument, fr.name)
+ }
+ term := partitionTerm(fr.transform, fr.name)
+
+ lit, err := LiteralForPartitionValue(val)
+ if err != nil {
+ return nil, fmt.Errorf("partition field %q:
%w", fr.name, err)
+ }
+ lit, err = validatePartitionValue(fr.transform,
fr.resultType, lit)
+ if err != nil {
+ return nil, fmt.Errorf("partition field %q:
%w", fr.name, err)
+ }
+
+ if isNaN(lit.Any()) {
// x == NaN is never true (IEEE 754), so a NaN
partition value must
// match via IsNaN. This intentionally diverges
from PyIceberg's
// _build_partition_predicate, which emits
EqualTo for every value:
@@ -119,18 +162,13 @@ func BuildPartitionMatchPredicate(spec
iceberg.PartitionSpec, schema *iceberg.Sc
// NaN has many valid bit patterns, so the
dedup signature uses a
// fixed sentinel (not MarshalBinary) to keep
duplicate NaN
// partitions collapsing to a single clause.
- clause = iceberg.NewAnd(clause,
iceberg.IsNaN(ref))
+ clause = iceberg.NewAnd(clause,
iceberg.IsNaN(term))
sigParts = append(sigParts,
strconv.Itoa(fr.id)+":nan")
continue
}
- lit, err := LiteralForPartitionValue(val)
- if err != nil {
- return nil, fmt.Errorf("partition field %q:
%w", fr.name, err)
- }
-
- clause = iceberg.NewAnd(clause,
iceberg.LiteralPredicate(iceberg.OpEQ, ref, lit))
+ clause = iceberg.NewAnd(clause,
iceberg.LiteralPredicate(iceberg.OpEQ, term, lit))
enc, err := lit.MarshalBinary()
if err != nil {
@@ -151,6 +189,92 @@ func BuildPartitionMatchPredicate(spec
iceberg.PartitionSpec, schema *iceberg.Sc
return result, nil
}
+func partitionTerm(transform iceberg.Transform, name string)
iceberg.UnboundTerm {
+ ref := iceberg.Reference(name)
+ if isIdentityTransform(transform) {
+ return ref
+ }
+
+ return iceberg.NewUnboundTransform(transform, ref)
+}
+
+func isIdentityTransform(transform iceberg.Transform) bool {
+ switch t := transform.(type) {
+ case iceberg.IdentityTransform:
+ return true
+ case *iceberg.IdentityTransform:
+ return t != nil
+ default:
+ return false
+ }
+}
+
+func isVoidTransform(transform iceberg.Transform) bool {
+ switch t := transform.(type) {
+ case iceberg.VoidTransform:
+ return true
+ case *iceberg.VoidTransform:
+ return t != nil
+ default:
+ return false
+ }
+}
+
+func isTruncateTransform(transform iceberg.Transform) bool {
+ switch t := transform.(type) {
+ case iceberg.TruncateTransform:
+ return true
+ case *iceberg.TruncateTransform:
+ return t != nil
+ default:
+ return false
+ }
+}
+
+func validatePartitionValue(transform iceberg.Transform, resultType
iceberg.Type, lit iceberg.Literal) (iceberg.Literal, error) {
+ normalized, err := lit.To(resultType)
+ if err != nil {
+ return nil, fmt.Errorf("%w: partition value type %s cannot be
converted to transform result type %s: %w",
+ iceberg.ErrInvalidArgument, lit.Type(), resultType, err)
+ }
+
+ switch normalized.(type) {
+ case iceberg.AboveMaxLiteral, iceberg.BelowMinLiteral:
+ return nil, fmt.Errorf("%w: partition value %s is outside
transform result type %s",
+ iceberg.ErrInvalidArgument, normalized, resultType)
+ }
+
+ switch t := transform.(type) {
+ case iceberg.BucketTransform:
+ if err := validateBucketPartitionValue(t.NumBuckets,
normalized); err != nil {
+ return nil, err
+ }
+ }
+
+ if (isIdentityTransform(transform) || isTruncateTransform(transform))
&& !isNaN(normalized.Any()) {
+ applied :=
transform.Apply(iceberg.Optional[iceberg.Literal]{Valid: true, Val: normalized})
+ if !applied.Valid || !applied.Val.Equals(normalized) {
+ return nil, fmt.Errorf("%w: partition value %s is not
in the range of transform %s",
+ iceberg.ErrInvalidArgument, normalized,
transform)
+ }
+ }
+
+ return normalized, nil
+}
+
+func validateBucketPartitionValue(numBuckets int, lit iceberg.Literal) error {
+ value, ok := lit.(iceberg.Int32Literal)
+ if !ok {
+ return fmt.Errorf("%w: bucket partition value must be int32,
got %s", iceberg.ErrInvalidArgument, lit.Type())
+ }
+ if value.Value() < 0 || int64(value.Value()) >= int64(numBuckets) {
+ return fmt.Errorf("%w: bucket partition value %d is outside [0,
%d)",
+ iceberg.ErrInvalidArgument, value.Value(), numBuckets)
+ }
+
+ return nil
+}
+
// isNaN reports whether v is a floating-point NaN.
func isNaN(v any) bool {
switch f := v.(type) {
diff --git a/table/internal/partition_predicate_test.go
b/table/internal/partition_predicate_test.go
index ff6f2f8ea..530d4ae7b 100644
--- a/table/internal/partition_predicate_test.go
+++ b/table/internal/partition_predicate_test.go
@@ -20,6 +20,7 @@ package internal
import (
"math"
"testing"
+ "time"
"github.com/apache/arrow-go/v18/arrow/decimal128"
"github.com/apache/iceberg-go"
@@ -43,12 +44,30 @@ func identityField(sourceID, fieldID int, name string)
iceberg.PartitionField {
return iceberg.PartitionField{SourceIDs: []int{sourceID}, FieldID:
fieldID, Name: name, Transform: iceberg.IdentityTransform{}}
}
-func identitySpec(fields ...iceberg.PartitionField) iceberg.PartitionSpec {
+func specWithFields(fields ...iceberg.PartitionField) iceberg.PartitionSpec {
return iceberg.NewPartitionSpec(fields...)
}
+type partitionPredicateRow []any
+
+func (r partitionPredicateRow) Size() int { return len(r) }
+func (r partitionPredicateRow) Get(pos int) any { return r[pos] }
+func (r partitionPredicateRow) Set(pos int, val any) {
+ r[pos] = val
+}
+
+func timestampAtUTC(year, month, day, hour, minute, second, microsecond int)
iceberg.Timestamp {
+ return iceberg.Timestamp(time.Date(year, time.Month(month), day, hour,
minute, second,
+ microsecond*int(time.Microsecond), time.UTC).UnixMicro())
+}
+
+func dateAtUTC(year, month, day int) iceberg.Date {
+ return iceberg.Date(time.Date(year, time.Month(month), day, 0, 0, 0, 0,
time.UTC).Unix() /
+ int64((24*time.Hour)/time.Second))
+}
+
func TestBuildPartitionMatchPredicate_EmptyInput(t *testing.T) {
- spec := identitySpec(identityField(1, 1000, "id"))
+ spec := specWithFields(identityField(1, 1000, "id"))
expr, err := BuildPartitionMatchPredicate(spec,
dynamicOverwriteSchema(), nil)
require.NoError(t, err)
@@ -56,7 +75,7 @@ func TestBuildPartitionMatchPredicate_EmptyInput(t
*testing.T) {
}
func TestBuildPartitionMatchPredicate_SingleField(t *testing.T) {
- spec := identitySpec(identityField(1, 1000, "id"))
+ spec := specWithFields(identityField(1, 1000, "id"))
expr, err := BuildPartitionMatchPredicate(spec,
dynamicOverwriteSchema(), []map[int]any{
{1000: int32(5)},
@@ -68,7 +87,7 @@ func TestBuildPartitionMatchPredicate_SingleField(t
*testing.T) {
}
func TestBuildPartitionMatchPredicate_MultipleFields(t *testing.T) {
- spec := identitySpec(
+ spec := specWithFields(
identityField(1, 1000, "id"),
identityField(2, 1001, "category"),
)
@@ -86,7 +105,7 @@ func TestBuildPartitionMatchPredicate_MultipleFields(t
*testing.T) {
}
func TestBuildPartitionMatchPredicate_MultiplePartitions(t *testing.T) {
- spec := identitySpec(identityField(1, 1000, "id"))
+ spec := specWithFields(identityField(1, 1000, "id"))
expr, err := BuildPartitionMatchPredicate(spec,
dynamicOverwriteSchema(), []map[int]any{
{1000: int32(5)},
@@ -102,7 +121,7 @@ func TestBuildPartitionMatchPredicate_MultiplePartitions(t
*testing.T) {
}
func TestBuildPartitionMatchPredicate_MultipleFieldsAndPartitions(t
*testing.T) {
- spec := identitySpec(
+ spec := specWithFields(
identityField(1, 1000, "id"),
identityField(2, 1001, "category"),
)
@@ -127,30 +146,25 @@ func
TestBuildPartitionMatchPredicate_MultipleFieldsAndPartitions(t *testing.T)
}
func TestBuildPartitionMatchPredicate_NullValue(t *testing.T) {
- spec := identitySpec(identityField(2, 1001, "category"))
+ spec := specWithFields(identityField(2, 1001, "category"))
- // Both an explicit nil and a missing key represent a null partition
value.
- cases := []struct {
- name string
- partitions []map[int]any
- }{
- {"explicit nil", []map[int]any{{1001: nil}}},
- {"missing key", []map[int]any{{}}},
- }
+ expr, err := BuildPartitionMatchPredicate(spec,
dynamicOverwriteSchema(), []map[int]any{{1001: nil}})
+ require.NoError(t, err)
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- expr, err := BuildPartitionMatchPredicate(spec,
dynamicOverwriteSchema(), tc.partitions)
- require.NoError(t, err)
+ want := iceberg.IsNull(iceberg.Reference("category"))
+ assert.True(t, expr.Equals(want), "want %s, got %s", want, expr)
+}
- want := iceberg.IsNull(iceberg.Reference("category"))
- assert.True(t, expr.Equals(want), "want %s, got %s",
want, expr)
- })
- }
+func TestBuildPartitionMatchPredicate_MissingFieldID(t *testing.T) {
+ spec := specWithFields(identityField(2, 1001, "category"))
+
+ _, err := BuildPartitionMatchPredicate(spec, dynamicOverwriteSchema(),
[]map[int]any{{}})
+ require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+ assert.Contains(t, err.Error(), "missing from partition tuple")
}
func TestBuildPartitionMatchPredicate_NullAndNonNullInSameTuple(t *testing.T) {
- spec := identitySpec(
+ spec := specWithFields(
identityField(1, 1000, "id"),
identityField(2, 1001, "category"),
)
@@ -182,7 +196,7 @@ func TestBuildPartitionMatchPredicate_NaNValue(t
*testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
- spec := identitySpec(tc.field)
+ spec := specWithFields(tc.field)
expr, err := BuildPartitionMatchPredicate(spec,
dynamicOverwriteSchema(), []map[int]any{
{tc.field.FieldID: tc.nan},
@@ -196,7 +210,7 @@ func TestBuildPartitionMatchPredicate_NaNValue(t
*testing.T) {
}
func TestBuildPartitionMatchPredicate_NaNDeduplicates(t *testing.T) {
- spec := identitySpec(identityField(4, 1003, "score"))
+ spec := specWithFields(identityField(4, 1003, "score"))
// NaN has many valid bit patterns; distinct payloads must still
collapse to a
// single IsNaN clause (the dedup key uses a sentinel, not the raw
float bits).
@@ -215,7 +229,7 @@ func TestBuildPartitionMatchPredicate_NaNDeduplicates(t
*testing.T) {
}
func TestBuildPartitionMatchPredicate_DeduplicatesPartitions(t *testing.T) {
- spec := identitySpec(identityField(1, 1000, "id"))
+ spec := specWithFields(identityField(1, 1000, "id"))
// Many data files land in the same partition; the predicate must not
repeat it.
expr, err := BuildPartitionMatchPredicate(spec,
dynamicOverwriteSchema(), []map[int]any{
@@ -230,7 +244,7 @@ func
TestBuildPartitionMatchPredicate_DeduplicatesPartitions(t *testing.T) {
}
func TestBuildPartitionMatchPredicate_DeduplicatesMultiFieldPartitions(t
*testing.T) {
- spec := identitySpec(
+ spec := specWithFields(
identityField(1, 1000, "id"),
identityField(2, 1001, "category"),
)
@@ -252,7 +266,7 @@ func
TestBuildPartitionMatchPredicate_DeduplicatesMultiFieldPartitions(t *testin
// not collide into a single clause. A naive "%d=%v"-joined signature would map
// both of these to "1001=5/1002=three/1002=x" and silently drop one partition.
func TestBuildPartitionMatchPredicate_NoSeparatorCollision(t *testing.T) {
- spec := identitySpec(
+ spec := specWithFields(
identityField(2, 1001, "category"),
identityField(3, 1002, "sub"),
)
@@ -276,30 +290,398 @@ func
TestBuildPartitionMatchPredicate_NoSeparatorCollision(t *testing.T) {
assert.True(t, expr.Equals(want), "distinct tuples must not be deduped,
got %s", expr)
}
-func TestBuildPartitionMatchPredicate_NonIdentityTransformRejected(t
*testing.T) {
+func TestBuildPartitionMatchPredicate_UsesNonIdentityTransform(t *testing.T) {
cases := []struct {
name string
- transform iceberg.Transform
+ field iceberg.PartitionField
+ source string
+ value any
+ wantValue iceberg.Literal
}{
- {"bucket", iceberg.BucketTransform{NumBuckets: 4}},
- {"truncate", iceberg.TruncateTransform{Width: 10}},
- {"day", iceberg.DayTransform{}},
+ {
+ name: "bucket",
+ field: iceberg.PartitionField{SourceIDs: []int{1},
FieldID: 1000, Name: "id_part", Transform: iceberg.BucketTransform{NumBuckets:
4}},
+ source: "id",
+ value: int32(1),
+ wantValue: iceberg.Int32Literal(1),
+ },
+ {
+ name: "truncate",
+ field: iceberg.PartitionField{SourceIDs: []int{2},
FieldID: 1000, Name: "category_part", Transform:
iceberg.TruncateTransform{Width: 3}},
+ source: "category",
+ value: "boo",
+ wantValue: iceberg.StringLiteral("boo"),
+ },
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
- spec := identitySpec(iceberg.PartitionField{SourceIDs:
[]int{1}, FieldID: 1000, Name: "id_part", Transform: tc.transform})
+ spec := specWithFields(tc.field)
- _, err := BuildPartitionMatchPredicate(spec,
dynamicOverwriteSchema(), []map[int]any{{1000: int32(1)}})
- require.Error(t, err)
- assert.ErrorIs(t, err, iceberg.ErrNotImplemented)
+ expr, err := BuildPartitionMatchPredicate(spec,
dynamicOverwriteSchema(), []map[int]any{{1000: tc.value}})
+ require.NoError(t, err)
+
+ want := iceberg.LiteralPredicate(iceberg.OpEQ,
+ iceberg.NewUnboundTransform(tc.field.Transform,
iceberg.Reference(tc.source)),
+ tc.wantValue,
+ )
+ assert.True(t, expr.Equals(want), "want %s, got %s",
want, expr)
})
}
}
+func bucketValueForInt32(t *testing.T, transform iceberg.BucketTransform,
value int32) int32 {
+ t.Helper()
+
+ result := transform.Apply(iceberg.Optional[iceberg.Literal]{
+ Valid: true,
+ Val: iceberg.Int32Literal(value),
+ })
+ require.True(t, result.Valid)
+
+ return result.Val.(iceberg.Int32Literal).Value()
+}
+
+func TestBuildPartitionMatchPredicate_EvaluatesTransforms(t *testing.T) {
+ bucketTransform := iceberg.BucketTransform{NumBuckets: 4}
+ bucketPartition := bucketValueForInt32(t, bucketTransform, 1)
+ bucketNonMatch := int32(2)
+ for bucketValueForInt32(t, bucketTransform, bucketNonMatch) ==
bucketPartition {
+ bucketNonMatch++
+ }
+
+ cases := []struct {
+ name string
+ sourceType iceberg.Type
+ transform iceberg.Transform
+ partitionValue any
+ matchingValues []any
+ nonMatchingVals []any
+ }{
+ {
+ name: "bucket",
+ sourceType: iceberg.PrimitiveTypes.Int32,
+ transform: bucketTransform,
+ partitionValue: bucketPartition,
+ matchingValues: []any{int32(1)},
+ nonMatchingVals: []any{bucketNonMatch},
+ },
+ {
+ name: "truncate",
+ sourceType: iceberg.PrimitiveTypes.String,
+ transform: iceberg.TruncateTransform{Width: 3},
+ partitionValue: "boo",
+ matchingValues: []any{"boo", "books", "booster"},
+ nonMatchingVals: []any{"bar", "science"},
+ },
+ {
+ name: "year",
+ sourceType: iceberg.PrimitiveTypes.Timestamp,
+ transform: iceberg.YearTransform{},
+ partitionValue: int32(50), // 2020 - 1970
+ matchingValues: []any{
+ timestampAtUTC(2020, 1, 1, 0, 0, 0, 0),
+ timestampAtUTC(2020, 12, 31, 23, 59, 59,
999999),
+ },
+ nonMatchingVals: []any{
+ timestampAtUTC(2019, 12, 31, 23, 59, 59,
999999),
+ timestampAtUTC(2021, 1, 1, 0, 0, 0, 0),
+ },
+ },
+ {
+ name: "month",
+ sourceType: iceberg.PrimitiveTypes.Timestamp,
+ transform: iceberg.MonthTransform{},
+ partitionValue: int32(601), // 2020-02, relative to
1970-01
+ matchingValues: []any{
+ timestampAtUTC(2020, 2, 1, 0, 0, 0, 0),
+ timestampAtUTC(2020, 2, 29, 23, 59, 59, 999999),
+ },
+ nonMatchingVals: []any{
+ timestampAtUTC(2020, 1, 31, 23, 59, 59, 999999),
+ timestampAtUTC(2020, 3, 1, 0, 0, 0, 0),
+ },
+ },
+ {
+ name: "day",
+ sourceType: iceberg.PrimitiveTypes.Timestamp,
+ transform: iceberg.DayTransform{},
+ partitionValue: dateAtUTC(2020, 2, 29),
+ matchingValues: []any{
+ timestampAtUTC(2020, 2, 29, 0, 0, 0, 0),
+ timestampAtUTC(2020, 2, 29, 23, 59, 59, 999999),
+ },
+ nonMatchingVals: []any{
+ timestampAtUTC(2020, 2, 28, 23, 59, 59, 999999),
+ timestampAtUTC(2020, 3, 1, 0, 0, 0, 0),
+ },
+ },
+ {
+ name: "hour",
+ sourceType: iceberg.PrimitiveTypes.Timestamp,
+ transform: iceberg.HourTransform{},
+ partitionValue: int32(439714), // 2020-02-29 10:00 UTC,
relative to 1970
+ matchingValues: []any{
+ timestampAtUTC(2020, 2, 29, 10, 0, 0, 0),
+ timestampAtUTC(2020, 2, 29, 10, 59, 59, 999999),
+ },
+ nonMatchingVals: []any{
+ timestampAtUTC(2020, 2, 29, 9, 59, 59, 999999),
+ timestampAtUTC(2020, 2, 29, 11, 0, 0, 0),
+ },
+ },
+ {
+ name: "hour before epoch",
+ sourceType: iceberg.PrimitiveTypes.Timestamp,
+ transform: iceberg.HourTransform{},
+ partitionValue: int32(-1), // 1969-12-31 23:00 UTC
+ matchingValues: []any{
+ timestampAtUTC(1969, 12, 31, 23, 0, 0, 0),
+ timestampAtUTC(1969, 12, 31, 23, 59, 59,
999999),
+ },
+ nonMatchingVals: []any{
+ timestampAtUTC(1969, 12, 31, 22, 59, 59,
999999),
+ timestampAtUTC(1970, 1, 1, 0, 0, 0, 0),
+ },
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ schema := iceberg.NewSchema(0, iceberg.NestedField{
+ ID: 1, Name: "value", Type: tc.sourceType,
+ })
+ spec := specWithFields(iceberg.PartitionField{
+ SourceIDs: []int{1}, FieldID: 1000, Name:
"value_part", Transform: tc.transform,
+ })
+
+ expr, err := BuildPartitionMatchPredicate(spec, schema,
[]map[int]any{{1000: tc.partitionValue}})
+ require.NoError(t, err)
+
+ eval, err := iceberg.ExpressionEvaluator(schema, expr,
true)
+ require.NoError(t, err)
+
+ for _, value := range tc.matchingValues {
+ matched, err :=
eval(partitionPredicateRow{value})
+ require.NoError(t, err)
+ assert.True(t, matched, "source value %v should
match partition value %v", value, tc.partitionValue)
+ }
+ for _, value := range tc.nonMatchingVals {
+ matched, err :=
eval(partitionPredicateRow{value})
+ require.NoError(t, err)
+ assert.False(t, matched, "source value %v
should not match partition value %v", value, tc.partitionValue)
+ }
+ })
+ }
+}
+
+func TestBuildPartitionMatchPredicate_EvaluatesBucketCollision(t *testing.T) {
+ transform := iceberg.BucketTransform{NumBuckets: 4}
+ valuesByBucket := make(map[int32][]int32)
+ var collisionBucket int32
+ var collisionValues []int32
+
+ for value := range int32(10000) {
+ bucket := bucketValueForInt32(t, transform, value)
+ valuesByBucket[bucket] = append(valuesByBucket[bucket], value)
+ if len(valuesByBucket[bucket]) == 2 {
+ collisionBucket = bucket
+ collisionValues = valuesByBucket[bucket]
+
+ break
+ }
+ }
+ require.Len(t, collisionValues, 2)
+ assert.NotEqual(t, collisionValues[0], collisionValues[1])
+
+ nonMatchingValue := int32(-1)
+ for value := range int32(10000) {
+ if bucketValueForInt32(t, transform, value) != collisionBucket {
+ nonMatchingValue = value
+
+ break
+ }
+ }
+ require.NotEqual(t, int32(-1), nonMatchingValue)
+
+ schema := iceberg.NewSchema(0, iceberg.NestedField{
+ ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int32,
+ })
+ spec := specWithFields(iceberg.PartitionField{
+ SourceIDs: []int{1}, FieldID: 1000, Name: "id_bucket",
Transform: transform,
+ })
+ expr, err := BuildPartitionMatchPredicate(spec, schema,
[]map[int]any{{1000: collisionBucket}})
+ require.NoError(t, err)
+
+ eval, err := iceberg.ExpressionEvaluator(schema, expr, true)
+ require.NoError(t, err)
+ for _, value := range collisionValues {
+ matched, err := eval(partitionPredicateRow{value})
+ require.NoError(t, err)
+ assert.True(t, matched, "source value %d should match bucket
%d", value, collisionBucket)
+ }
+ matched, err := eval(partitionPredicateRow{nonMatchingValue})
+ require.NoError(t, err)
+ assert.False(t, matched, "source value %d should not match bucket %d",
nonMatchingValue, collisionBucket)
+}
+
+func TestBuildPartitionMatchPredicate_EvaluatesTransformedNull(t *testing.T) {
+ cases := []struct {
+ name string
+ sourceType iceberg.Type
+ transform iceberg.Transform
+ nonNil any
+ nonNilMatches bool
+ }{
+ {name: "bucket", sourceType: iceberg.PrimitiveTypes.Int32,
transform: iceberg.BucketTransform{NumBuckets: 4}, nonNil: int32(1),
nonNilMatches: false},
+ {name: "truncate", sourceType: iceberg.PrimitiveTypes.String,
transform: iceberg.TruncateTransform{Width: 3}, nonNil: "books", nonNilMatches:
false},
+ {name: "year", sourceType: iceberg.PrimitiveTypes.Timestamp,
transform: iceberg.YearTransform{}, nonNil: timestampAtUTC(2020, 1, 1, 0, 0, 0,
0), nonNilMatches: false},
+ {name: "month", sourceType: iceberg.PrimitiveTypes.Timestamp,
transform: iceberg.MonthTransform{}, nonNil: timestampAtUTC(2020, 1, 1, 0, 0,
0, 0), nonNilMatches: false},
+ {name: "day", sourceType: iceberg.PrimitiveTypes.Timestamp,
transform: iceberg.DayTransform{}, nonNil: timestampAtUTC(2020, 1, 1, 0, 0, 0,
0), nonNilMatches: false},
+ {name: "hour", sourceType: iceberg.PrimitiveTypes.Timestamp,
transform: iceberg.HourTransform{}, nonNil: timestampAtUTC(2020, 1, 1, 0, 0, 0,
0), nonNilMatches: false},
+ {name: "void", sourceType: iceberg.PrimitiveTypes.String,
transform: iceberg.VoidTransform{}, nonNil: "books", nonNilMatches: true},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ schema := iceberg.NewSchema(0, iceberg.NestedField{
+ ID: 1, Name: "value", Type: tc.sourceType,
+ })
+ spec := specWithFields(iceberg.PartitionField{
+ SourceIDs: []int{1}, FieldID: 1000, Name:
"value_part", Transform: tc.transform,
+ })
+
+ expr, err := BuildPartitionMatchPredicate(spec, schema,
[]map[int]any{{1000: nil}})
+ require.NoError(t, err)
+
+ eval, err := iceberg.ExpressionEvaluator(schema, expr,
true)
+ require.NoError(t, err)
+
+ matched, err := eval(partitionPredicateRow{nil})
+ require.NoError(t, err)
+ assert.True(t, matched, "null source value should match
null partition value")
+
+ matched, err = eval(partitionPredicateRow{tc.nonNil})
+ require.NoError(t, err)
+ assert.Equal(t, tc.nonNilMatches, matched, "unexpected
match for non-null source value")
+ })
+ }
+}
+
+func TestBuildPartitionMatchPredicate_VoidBindsAlwaysTrue(t *testing.T) {
+ spec := specWithFields(iceberg.PartitionField{
+ SourceIDs: []int{0}, FieldID: 1001, Name: "category_void",
Transform: iceberg.VoidTransform{},
+ })
+
+ expr, err := BuildPartitionMatchPredicate(spec,
dynamicOverwriteSchema(), []map[int]any{{1001: nil}})
+ require.NoError(t, err)
+
+ bound, err := iceberg.BindExpr(dynamicOverwriteSchema(), expr, true)
+ require.NoError(t, err)
+ assert.True(t, bound.Equals(iceberg.AlwaysTrue{}), "void-only predicate
should bind to AlwaysTrue, got %s", bound)
+}
+
+func TestBuildPartitionMatchPredicate_SourceLessVoidDoesNotDropOtherFields(t
*testing.T) {
+ schema := dynamicOverwriteSchema()
+ spec := specWithFields(
+ iceberg.PartitionField{
+ SourceIDs: []int{0}, FieldID: 1000, Name:
"old_category", Transform: iceberg.VoidTransform{},
+ },
+ identityField(1, 1001, "id"),
+ )
+
+ expr, err := BuildPartitionMatchPredicate(spec, schema, []map[int]any{
+ {1000: nil, 1001: int32(5)},
+ })
+ require.NoError(t, err)
+
+ want := iceberg.EqualTo(iceberg.Reference("id"), int32(5))
+ assert.True(t, expr.Equals(want), "want %s, got %s", want, expr)
+
+ bound, err := iceberg.BindExpr(schema, expr, true)
+ require.NoError(t, err)
+
+ eval, err := iceberg.ExpressionEvaluator(schema, expr, true)
+ require.NoError(t, err)
+
+ matched, err := eval(partitionPredicateRow{int32(5), nil, nil, nil,
nil})
+ require.NoError(t, err)
+ assert.True(t, matched)
+
+ wantBound, err := iceberg.BindExpr(schema, want, true)
+ require.NoError(t, err)
+ assert.True(t, bound.Equals(wantBound),
+ "bound predicate should retain non-void field, got %s", bound)
+}
+
+func TestBuildPartitionMatchPredicate_CoercesRawDayValue(t *testing.T) {
+ schema := iceberg.NewSchema(0, iceberg.NestedField{
+ ID: 1, Name: "value", Type: iceberg.PrimitiveTypes.Timestamp,
+ })
+ spec := specWithFields(iceberg.PartitionField{
+ SourceIDs: []int{1}, FieldID: 1000, Name: "value_day",
Transform: iceberg.DayTransform{},
+ })
+ rawDay := int32(dateAtUTC(2020, 2, 29))
+
+ expr, err := BuildPartitionMatchPredicate(spec, schema,
[]map[int]any{{1000: rawDay}})
+ require.NoError(t, err)
+
+ want := iceberg.LiteralPredicate(iceberg.OpEQ,
+ iceberg.NewUnboundTransform(iceberg.DayTransform{},
iceberg.Reference("value")),
+ iceberg.DateLiteral(rawDay),
+ )
+ assert.True(t, expr.Equals(want), "want %s, got %s", want, expr)
+
+ eval, err := iceberg.ExpressionEvaluator(schema, expr, true)
+ require.NoError(t, err)
+
+ matched, err := eval(partitionPredicateRow{timestampAtUTC(2020, 2, 29,
12, 0, 0, 0)})
+ require.NoError(t, err)
+ assert.True(t, matched)
+}
+
+func TestBuildPartitionMatchPredicate_UsesNestedSourcePath(t *testing.T) {
+ schema := iceberg.NewSchema(0,
+ iceberg.NestedField{ID: 10, Name: "location", Type:
&iceberg.StructType{
+ FieldList: []iceberg.NestedField{
+ {ID: 11, Name: "category", Type:
iceberg.PrimitiveTypes.String},
+ },
+ }},
+ iceberg.NestedField{ID: 12, Name: "category", Type:
iceberg.PrimitiveTypes.String},
+ )
+ spec := specWithFields(iceberg.PartitionField{
+ SourceIDs: []int{11}, FieldID: 1000, Name: "category_part",
+ Transform: iceberg.TruncateTransform{Width: 3},
+ })
+
+ expr, err := BuildPartitionMatchPredicate(spec, schema,
[]map[int]any{{1000: "boo"}})
+ require.NoError(t, err)
+ _, err = iceberg.BindExpr(schema, expr, true)
+ require.NoError(t, err)
+
+ want := iceberg.EqualTo(
+ iceberg.NewUnboundTransform(iceberg.TruncateTransform{Width:
3}, iceberg.Reference("location.category")),
+ "boo",
+ )
+ assert.True(t, expr.Equals(want), "want %s, got %s", want, expr)
+}
+
+func TestBuildPartitionMatchPredicate_RecognizesPointerIdentityTransform(t
*testing.T) {
+ spec := specWithFields(iceberg.PartitionField{
+ SourceIDs: []int{1}, FieldID: 1000, Name: "id_part",
+ Transform: &iceberg.IdentityTransform{},
+ })
+
+ expr, err := BuildPartitionMatchPredicate(spec,
dynamicOverwriteSchema(), []map[int]any{{1000: int32(1)}})
+ require.NoError(t, err)
+
+ want := iceberg.EqualTo(iceberg.Reference("id"), int32(1))
+ assert.True(t, expr.Equals(want), "want %s, got %s", want, expr)
+}
+
func TestBuildPartitionMatchPredicate_UnknownSourceID(t *testing.T) {
// Partition field points at a source id that is not present in the
schema.
- spec := identitySpec(identityField(999, 1000, "ghost"))
+ spec := specWithFields(identityField(999, 1000, "ghost"))
_, err := BuildPartitionMatchPredicate(spec, dynamicOverwriteSchema(),
[]map[int]any{{1000: int32(1)}})
require.Error(t, err)
@@ -307,7 +689,7 @@ func TestBuildPartitionMatchPredicate_UnknownSourceID(t
*testing.T) {
}
func TestBuildPartitionMatchPredicate_UnsupportedValueType(t *testing.T) {
- spec := identitySpec(identityField(1, 1000, "id"))
+ spec := specWithFields(identityField(1, 1000, "id"))
_, err := BuildPartitionMatchPredicate(spec, dynamicOverwriteSchema(),
[]map[int]any{
{1000: struct{}{}},
@@ -358,3 +740,65 @@ func TestLiteralForPartitionValue(t *testing.T) {
assert.ErrorIs(t, err, iceberg.ErrInvalidArgument)
})
}
+
+func TestBuildPartitionMatchPredicate_RejectsInvalidTransforms(t *testing.T) {
+ unknown, err := iceberg.ParseTransform("future_transform")
+ require.NoError(t, err)
+ for _, tc := range []struct {
+ name string
+ transform iceberg.Transform
+ want error
+ }{
+ {name: "nil", want: iceberg.ErrInvalidArgument},
+ {name: "typed nil identity", transform:
(*iceberg.IdentityTransform)(nil), want: iceberg.ErrInvalidArgument},
+ {name: "typed nil bucket", transform:
(*iceberg.BucketTransform)(nil), want: iceberg.ErrInvalidArgument},
+ {name: "unknown", transform: unknown, want:
iceberg.ErrNotImplemented},
+ {name: "invalid source type", transform:
iceberg.DayTransform{}, want: iceberg.ErrInvalidArgument},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ spec := specWithFields(iceberg.PartitionField{
+ SourceIDs: []int{1}, FieldID: 1000, Name:
"id_part", Transform: tc.transform,
+ })
+ _, err := BuildPartitionMatchPredicate(spec,
dynamicOverwriteSchema(), []map[int]any{{1000: int32(1)}})
+ require.ErrorIs(t, err, tc.want)
+ })
+ }
+}
+
+func TestBuildPartitionMatchPredicate_RejectsImpossiblePartitionValues(t
*testing.T) {
+ cases := []struct {
+ name string
+ schema *iceberg.Schema
+ transform iceberg.Transform
+ value any
+ }{
+ {
+ name: "bucket value outside range",
+ schema: iceberg.NewSchema(0, iceberg.NestedField{ID:
1, Name: "value", Type: iceberg.PrimitiveTypes.Int32}),
+ transform: iceberg.BucketTransform{NumBuckets: 4},
+ value: int32(4),
+ },
+ {
+ name: "truncate value is not fixed point",
+ schema: iceberg.NewSchema(0, iceberg.NestedField{ID:
1, Name: "value", Type: iceberg.PrimitiveTypes.String}),
+ transform: iceberg.TruncateTransform{Width: 3},
+ value: "books",
+ },
+ {
+ name: "void value is not nil",
+ schema: iceberg.NewSchema(0, iceberg.NestedField{ID:
1, Name: "value", Type: iceberg.PrimitiveTypes.String}),
+ transform: iceberg.VoidTransform{},
+ value: "books",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ spec := specWithFields(iceberg.PartitionField{
+ SourceIDs: []int{1}, FieldID: 1000, Name:
"value_part", Transform: tc.transform,
+ })
+ _, err := BuildPartitionMatchPredicate(spec, tc.schema,
[]map[int]any{{1000: tc.value}})
+ require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+ })
+ }
+}
diff --git a/table/transaction_internal_test.go
b/table/transaction_internal_test.go
index 7754928c3..ce2d4c2b0 100644
--- a/table/transaction_internal_test.go
+++ b/table/transaction_internal_test.go
@@ -31,6 +31,21 @@ import (
"github.com/stretchr/testify/require"
)
+func TestPrepareBatchFilterRejectsTransformedTerms(t *testing.T) {
+ schema := iceberg.NewSchema(1,
+ iceberg.NestedField{ID: 1, Name: "category", Type:
iceberg.PrimitiveTypes.String},
+ )
+ term := iceberg.NewUnboundTransform(
+ iceberg.TruncateTransform{Width: 3},
+ iceberg.Reference("category"),
+ )
+
+ _, err := prepareBatchFilter(iceberg.EqualTo(term, "boo"), schema, true)
+ require.Error(t, err)
+ assert.ErrorIs(t, err, iceberg.ErrNotImplemented)
+ assert.Contains(t, err.Error(), "transformed terms")
+}
+
func TestTransactionApplyKeepsDistinctRequirementsOfSameType(t *testing.T) {
txn := newTransactionWithSnapshotRefs(t)