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 6db54d61a feat(table): support sort order satisfaction checks (#2439)
6db54d61a is described below

commit 6db54d61a20ad383a4112373716527abd9977138
Author: Minh Vu <[email protected]>
AuthorDate: Thu Nov 5 21:08:23 2026 +0100

    feat(table): support sort order satisfaction checks (#2439)
    
    * feat(table): add Java-compatible sort-order satisfaction checks
    
    * test(table): cover Java sort-order satisfaction rules
    
    * fix(table): avoid conflicting sort transform helper
    
    * fix(table): reject sort satisfaction without source IDs
    
    * test(table): cover source IDs and Java transform edge cases
    
    * style(table): format multi-source sort order test
    
    * test(table): cover full Java time sort satisfaction matrix
    
    * docs(table): clarify limits of Java sort satisfaction
---
 table/sort_order_satisfies.go      | 112 +++++++++++++++
 table/sort_order_satisfies_test.go | 286 +++++++++++++++++++++++++++++++++++++
 2 files changed, 398 insertions(+)

diff --git a/table/sort_order_satisfies.go b/table/sort_order_satisfies.go
new file mode 100644
index 000000000..47197e441
--- /dev/null
+++ b/table/sort_order_satisfies.go
@@ -0,0 +1,112 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table
+
+import (
+       "reflect"
+       "slices"
+
+       "github.com/apache/iceberg-go"
+)
+
+// Satisfies checks whether this sort field satisfies another using Iceberg
+// Java's transform ordering rules. Source IDs, direction, and null ordering
+// must match. This is a metadata compatibility check, not a type-aware proof
+// of physical row ordering (notably for numeric truncation).
+func (s SortField) Satisfies(required SortField) bool {
+       if len(s.SourceIDs) == 0 || !slices.Equal(s.SourceIDs, 
required.SourceIDs) ||
+               s.Direction != required.Direction || s.NullOrder != 
required.NullOrder ||
+               isNilSatisfactionTransform(s.Transform) || 
isNilSatisfactionTransform(required.Transform) {
+               return false
+       }
+
+       if s.Transform.Equals(required.Transform) {
+               return true
+       }
+
+       switch transform := s.Transform.(type) {
+       case iceberg.IdentityTransform, *iceberg.IdentityTransform:
+               // Identity preserves the order of every monotonic transform.
+               return required.Transform.PreservesOrder()
+       case iceberg.TruncateTransform:
+               return truncateSatisfies(transform.Width, required.Transform)
+       case *iceberg.TruncateTransform:
+               return truncateSatisfies(transform.Width, required.Transform)
+       }
+
+       actualGranularity := timeSortGranularity(s.Transform)
+       requiredGranularity := timeSortGranularity(required.Transform)
+
+       // A finer time order also satisfies coarser orders: hour > day > month 
> year.
+       return actualGranularity != 0 && requiredGranularity != 0 && 
actualGranularity >= requiredGranularity
+}
+
+// Satisfies checks whether this sort order satisfies another using Iceberg
+// Java's field-by-field prefix rules. Order IDs are ignored, and every order
+// satisfies the unsorted order. This is not a proof of physical row ordering:
+// for example, (day(ts), id) satisfies (month(ts), id) by these rules even
+// though IDs need not be globally ordered within each month.
+func (s SortOrder) Satisfies(required SortOrder) bool {
+       if len(s.fields) < len(required.fields) {
+               return false
+       }
+
+       for i, field := range required.fields {
+               if !s.fields[i].Satisfies(field) {
+                       return false
+               }
+       }
+
+       return true
+}
+
+func isNilSatisfactionTransform(transform iceberg.Transform) bool {
+       if transform == nil {
+               return true
+       }
+
+       value := reflect.ValueOf(transform)
+
+       return value.Kind() == reflect.Pointer && value.IsNil()
+}
+
+func truncateSatisfies(width int, required iceberg.Transform) bool {
+       switch target := required.(type) {
+       case iceberg.TruncateTransform:
+               return width >= target.Width
+       case *iceberg.TruncateTransform:
+               return target != nil && width >= target.Width
+       default:
+               return false
+       }
+}
+
+func timeSortGranularity(transform iceberg.Transform) int {
+       switch transform.(type) {
+       case iceberg.YearTransform, *iceberg.YearTransform:
+               return 1
+       case iceberg.MonthTransform, *iceberg.MonthTransform:
+               return 2
+       case iceberg.DayTransform, *iceberg.DayTransform:
+               return 3
+       case iceberg.HourTransform, *iceberg.HourTransform:
+               return 4
+       default:
+               return 0
+       }
+}
diff --git a/table/sort_order_satisfies_test.go 
b/table/sort_order_satisfies_test.go
new file mode 100644
index 000000000..5cd16d13d
--- /dev/null
+++ b/table/sort_order_satisfies_test.go
@@ -0,0 +1,286 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table_test
+
+import (
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       "github.com/apache/iceberg-go/table"
+       "github.com/stretchr/testify/require"
+)
+
+func satisfyingSortField(ids []int, transform iceberg.Transform, direction 
table.SortDirection, nullOrder table.NullOrder) table.SortField {
+       return table.SortField{
+               SourceIDs: ids,
+               Transform: transform,
+               Direction: direction,
+               NullOrder: nullOrder,
+       }
+}
+
+func satisfyingSortOrder(t *testing.T, id int, fields ...table.SortField) 
table.SortOrder {
+       t.Helper()
+
+       order, err := table.NewSortOrder(id, fields)
+       require.NoError(t, err)
+
+       return order
+}
+
+func TestSortOrderSatisfies(t *testing.T) {
+       byID := satisfyingSortField([]int{1}, iceberg.IdentityTransform{}, 
table.SortASC, table.NullsFirst)
+       byName := satisfyingSortField([]int{2}, iceberg.IdentityTransform{}, 
table.SortDESC, table.NullsLast)
+       id := satisfyingSortOrder(t, 1, byID)
+       idWithDifferentOrderID := satisfyingSortOrder(t, 99, byID)
+       idAndName := satisfyingSortOrder(t, 2, byID, byName)
+       name := satisfyingSortOrder(t, 3, byName)
+       unsorted := table.UnsortedSortOrder
+
+       cases := []struct {
+               name     string
+               actual   table.SortOrder
+               required table.SortOrder
+               want     bool
+       }{
+               {"unsorted satisfies unsorted", unsorted, unsorted, true},
+               {"any sorted order satisfies unsorted", idAndName, unsorted, 
true},
+               {"unsorted does not satisfy sorted", unsorted, id, false},
+               {"an order satisfies itself", idAndName, idAndName, true},
+               {"order IDs do not matter", id, idWithDifferentOrderID, true},
+               {"longer sort satisfies a prefix", idAndName, id, true},
+               {"shorter sort does not satisfy longer", id, idAndName, false},
+               {"non-prefix sort does not satisfy", idAndName, name, false},
+       }
+
+       for _, tc := range cases {
+               t.Run(tc.name, func(t *testing.T) {
+                       require.Equal(t, tc.want, 
tc.actual.Satisfies(tc.required))
+               })
+       }
+}
+
+func TestSortFieldSatisfiesMatchesSourceDirectionAndNulls(t *testing.T) {
+       base := satisfyingSortField([]int{1, 2}, iceberg.IdentityTransform{}, 
table.SortDESC, table.NullsLast)
+
+       tests := []struct {
+               name     string
+               required table.SortField
+               want     bool
+       }{
+               {"same field", base, true},
+               {"different first source", satisfyingSortField([]int{9, 2}, 
iceberg.IdentityTransform{}, table.SortDESC, table.NullsLast), false},
+               {"different second source", satisfyingSortField([]int{1, 3}, 
iceberg.IdentityTransform{}, table.SortDESC, table.NullsLast), false},
+               {"missing source", satisfyingSortField([]int{1}, 
iceberg.IdentityTransform{}, table.SortDESC, table.NullsLast), false},
+               {"reordered sources", satisfyingSortField([]int{2, 1}, 
iceberg.IdentityTransform{}, table.SortDESC, table.NullsLast), false},
+               {"different direction", satisfyingSortField([]int{1, 2}, 
iceberg.IdentityTransform{}, table.SortASC, table.NullsLast), false},
+               {"different null order", satisfyingSortField([]int{1, 2}, 
iceberg.IdentityTransform{}, table.SortDESC, table.NullsFirst), false},
+       }
+
+       for _, tc := range tests {
+               t.Run(tc.name, func(t *testing.T) {
+                       require.Equal(t, tc.want, base.Satisfies(tc.required))
+               })
+       }
+}
+
+func TestSortFieldSatisfiesTransformImplications(t *testing.T) {
+       identity := iceberg.IdentityTransform{}
+       truncate2 := iceberg.TruncateTransform{Width: 2}
+       truncate4 := iceberg.TruncateTransform{Width: 4}
+       year := iceberg.YearTransform{}
+       month := iceberg.MonthTransform{}
+       day := iceberg.DayTransform{}
+       hour := iceberg.HourTransform{}
+       bucket4 := iceberg.BucketTransform{NumBuckets: 4}
+       bucket8 := iceberg.BucketTransform{NumBuckets: 8}
+
+       tests := []struct {
+               name     string
+               actual   iceberg.Transform
+               required iceberg.Transform
+               want     bool
+       }{
+               {"identity implies identity", identity, identity, true},
+               {"identity implies truncation", identity, truncate2, true},
+               {"identity implies time transform", identity, hour, true},
+               {"truncation does not imply identity", truncate2, identity, 
false},
+               {"wider truncation implies narrower", truncate4, truncate2, 
true},
+               {"narrower truncation does not imply wider", truncate2, 
truncate4, false},
+               {"truncation implies itself", truncate4, truncate4, true},
+               {"time transform does not imply truncation", day, truncate2, 
false},
+               {"truncation does not imply time transform", truncate4, year, 
false},
+               {"hour implies day", hour, day, true},
+               {"hour implies month", hour, month, true},
+               {"hour implies year", hour, year, true},
+               {"day implies month", day, month, true},
+               {"day implies year", day, year, true},
+               {"month implies year", month, year, true},
+               {"month does not imply day", month, day, false},
+               {"year does not imply month", year, month, false},
+               {"day does not imply hour", day, hour, false},
+               {"same bucket transform", bucket4, bucket4, true},
+               {"different bucket transforms", bucket4, bucket8, false},
+               {"identity does not imply bucket", identity, bucket4, false},
+               {"pointer truncate transform", &truncate4, &truncate2, true},
+               {"pointer time transform", &hour, &year, true},
+       }
+
+       for _, tc := range tests {
+               t.Run(tc.name, func(t *testing.T) {
+                       actual := satisfyingSortField([]int{7}, tc.actual, 
table.SortASC, table.NullsLast)
+                       required := satisfyingSortField([]int{7}, tc.required, 
table.SortASC, table.NullsLast)
+                       require.Equal(t, tc.want, actual.Satisfies(required))
+               })
+       }
+}
+
+func TestSortOrderSatisfiesTransformOnPrefixOnly(t *testing.T) {
+       year := satisfyingSortField([]int{1}, iceberg.YearTransform{}, 
table.SortASC, table.NullsLast)
+       day := satisfyingSortField([]int{1}, iceberg.DayTransform{}, 
table.SortASC, table.NullsLast)
+       byName := satisfyingSortField([]int{2}, iceberg.IdentityTransform{}, 
table.SortDESC, table.NullsLast)
+
+       actual := satisfyingSortOrder(t, 1, day, byName)
+       required := satisfyingSortOrder(t, 2, year)
+       require.True(t, actual.Satisfies(required))
+       require.False(t, required.Satisfies(actual))
+
+       wrongSuffix := satisfyingSortOrder(t, 3, year, 
satisfyingSortField([]int{2}, iceberg.IdentityTransform{}, table.SortASC, 
table.NullsLast))
+       require.False(t, actual.Satisfies(wrongSuffix))
+}
+
+func TestSortFieldSatisfiesRejectsNilTransforms(t *testing.T) {
+       valid := satisfyingSortField([]int{1}, iceberg.IdentityTransform{}, 
table.SortASC, table.NullsFirst)
+       missing := satisfyingSortField([]int{1}, nil, table.SortASC, 
table.NullsFirst)
+       require.False(t, valid.Satisfies(missing))
+       require.False(t, missing.Satisfies(valid))
+
+       var nilTruncate *iceberg.TruncateTransform
+       typedNil := satisfyingSortField([]int{1}, nilTruncate, table.SortASC, 
table.NullsFirst)
+       require.False(t, typedNil.Satisfies(valid))
+       require.False(t, valid.Satisfies(typedNil))
+}
+
+func TestSortFieldSatisfiesRequiresSourceIDs(t *testing.T) {
+       valid := satisfyingSortField([]int{1}, iceberg.IdentityTransform{}, 
table.SortASC, table.NullsLast)
+       noSources := satisfyingSortField(nil, iceberg.IdentityTransform{}, 
table.SortASC, table.NullsLast)
+       emptySources := satisfyingSortField([]int{}, 
iceberg.IdentityTransform{}, table.SortASC, table.NullsLast)
+
+       require.False(t, noSources.Satisfies(noSources))
+       require.False(t, noSources.Satisfies(emptySources))
+       require.False(t, valid.Satisfies(noSources))
+       require.False(t, noSources.Satisfies(valid))
+}
+
+func TestSortFieldSatisfiesOpaqueTransforms(t *testing.T) {
+       unknown, err := iceberg.ParseTransform("custom-sort[4]")
+       require.NoError(t, err)
+       sameUnknown, err := iceberg.ParseTransform("custom-sort[4]")
+       require.NoError(t, err)
+       otherUnknown, err := iceberg.ParseTransform("custom-sort[8]")
+       require.NoError(t, err)
+
+       field := func(transform iceberg.Transform) table.SortField {
+               return satisfyingSortField([]int{1}, transform, table.SortASC, 
table.NullsLast)
+       }
+
+       require.True(t, field(unknown).Satisfies(field(sameUnknown)))
+       require.False(t, field(unknown).Satisfies(field(otherUnknown)))
+       require.False(t, 
field(iceberg.IdentityTransform{}).Satisfies(field(unknown)))
+       require.False(t, 
field(unknown).Satisfies(field(iceberg.IdentityTransform{})))
+       require.True(t, 
field(iceberg.VoidTransform{}).Satisfies(field(iceberg.VoidTransform{})))
+       require.False(t, 
field(iceberg.VoidTransform{}).Satisfies(field(iceberg.IdentityTransform{})))
+}
+
+func TestSortFieldSatisfiesMixedPointerAndValueTransforms(t *testing.T) {
+       identity := iceberg.IdentityTransform{}
+       year := iceberg.YearTransform{}
+       day := iceberg.DayTransform{}
+       truncate2 := iceberg.TruncateTransform{Width: 2}
+       truncate4 := iceberg.TruncateTransform{Width: 4}
+       bucket := iceberg.BucketTransform{NumBuckets: 4}
+
+       field := func(transform iceberg.Transform) table.SortField {
+               return satisfyingSortField([]int{1}, transform, table.SortDESC, 
table.NullsFirst)
+       }
+
+       require.True(t, field(&identity).Satisfies(field(identity)))
+       require.True(t, field(identity).Satisfies(field(&identity)))
+       require.True(t, field(&identity).Satisfies(field(&year)))
+       require.True(t, field(&day).Satisfies(field(year)))
+       require.True(t, field(day).Satisfies(field(&year)))
+       require.False(t, field(&year).Satisfies(field(day)))
+       require.True(t, field(&truncate4).Satisfies(field(truncate2)))
+       require.True(t, field(truncate4).Satisfies(field(&truncate2)))
+       require.False(t, field(&truncate2).Satisfies(field(&truncate4)))
+       require.True(t, field(&bucket).Satisfies(field(bucket)))
+       require.True(t, field(bucket).Satisfies(field(&bucket)))
+}
+
+func TestSortOrderSatisfiesMultiSourceField(t *testing.T) {
+       transform, err := iceberg.ParseTransform("multi-source[2]")
+       require.NoError(t, err)
+
+       field := func(ids []int) table.SortField {
+               return satisfyingSortField(ids, transform, table.SortASC, 
table.NullsLast)
+       }
+
+       actual := satisfyingSortOrder(t, 1,
+               field([]int{1, 2}),
+               satisfyingSortField([]int{3}, iceberg.IdentityTransform{}, 
table.SortASC, table.NullsLast),
+       )
+       compatible := satisfyingSortOrder(t, 2, field([]int{1, 2}))
+       reversed := satisfyingSortOrder(t, 3, field([]int{2, 1}))
+       shorterSourceList := satisfyingSortOrder(t, 4, field([]int{1}))
+
+       require.True(t, actual.Satisfies(compatible))
+       require.False(t, compatible.Satisfies(actual))
+       require.False(t, actual.Satisfies(reversed))
+       require.False(t, actual.Satisfies(shorterSourceList))
+}
+
+func TestSortFieldSatisfiesRejectsTwoTypedNilTransforms(t *testing.T) {
+       var nilTruncate *iceberg.TruncateTransform
+       field := satisfyingSortField([]int{1}, nilTruncate, table.SortASC, 
table.NullsFirst)
+
+       require.False(t, field.Satisfies(field))
+}
+
+func TestSortFieldSatisfiesCompleteTimeTransformMatrix(t *testing.T) {
+       // Mirror Java TestTimeTransforms for all 16 relationships.
+       // The ordering implications apply equally to descending sorts.
+       transforms := []struct {
+               name      string
+               transform iceberg.Transform
+       }{
+               {"year", iceberg.YearTransform{}},
+               {"month", iceberg.MonthTransform{}},
+               {"day", iceberg.DayTransform{}},
+               {"hour", iceberg.HourTransform{}},
+       }
+
+       for actualIndex, actual := range transforms {
+               for requiredIndex, required := range transforms {
+                       t.Run(actual.name+"-to-"+required.name, func(t 
*testing.T) {
+                               actualField := satisfyingSortField([]int{1}, 
actual.transform, table.SortDESC, table.NullsLast)
+                               requiredField := satisfyingSortField([]int{1}, 
required.transform, table.SortDESC, table.NullsLast)
+                               require.Equal(t, actualIndex >= requiredIndex, 
actualField.Satisfies(requiredField))
+                       })
+               }
+       }
+}

Reply via email to