laskoviymishka commented on code in PR #1665:
URL: https://github.com/apache/iceberg-go/pull/1665#discussion_r3740296753


##########
table/sorting.go:
##########
@@ -191,9 +191,11 @@ func (s *SortField) UnmarshalJSON(b []byte) error {
        return nil
 }
 
+// Zero is valid: unbound sort orders carry client ordinal placeholder IDs that
+// start at 0. CheckCompatibility rejects IDs that don't resolve against a 
schema.
 func validateSortSourceID(id int) error {
-       if id <= 0 {
-               return fmt.Errorf("source ID must be positive: %d", id)
+       if id < 0 {

Review Comment:
   Beyond the shared decode issue, relaxing `validateSortSourceID` also changes 
the public `NewSortOrder` constructor, which builds bound orders. It's called 
from there with `validateSourceIDs=true`, so a programmatic caller can now 
construct a `SortOrder` with source-id 0 and get no error at build time, and 
the flipped test (`require.Error` to `require.NoError` in 
`TestNewSortOrderAcceptsZeroSourceID`) turns that into a semver-observable 
removal of a published guarantee.
   
   I'd keep `NewSortOrder` rejecting source-id 0 and let only the 
request-decode path permit it. Happy to be wrong if there's a bound case where 
0 is legitimate, but I can't think of one.



##########
table/metadata_internal_test.go:
##########
@@ -711,6 +711,83 @@ func 
TestRejectStructurallyInvalidHistoricalPartitionSpec(t *testing.T) {
        assert.ErrorContains(t, err, "spec ID must be non-negative")
 }
 
+// A create-table request carries unbound placeholder IDs rather than final
+// field IDs. Spark numbers the root struct's fields by ordinal, so the first
+// column is field-id 0 and partitioning or sorting by it yields source-id 0.
+// NewMetadata must accept that and remap every source ID by name, matching
+// Java's TableMetadata.newTableMetadata.
+func TestNewMetadataFromOrdinalNumberedRequest(t *testing.T) {

Review Comment:
   This covers the accept side nicely, and the round-trip assert is a good 
touch. The reject side zeroshade asked for isn't here yet though, and there's a 
real gap behind it: `checkSortOrders` only runs `CheckCompatibility` on the 
default order and `checkPartitionSpecs` only checks that the default spec id 
appears in the list. So once the parser stops rejecting source-id 0, a 
persisted historical (non-default) spec or order carrying 0 has no validation 
net anywhere.
   
   I'd add persisted v1/v2/v3 rejection tests here covering historical 
specs/orders, not just the default, plus a round-trip check that write paths 
only ever emit positive IDs. If instead the permissive parse path stays, 
`checkSortOrders` and `checkPartitionSpecs` would need to validate every 
spec/order rather than just the default.



##########
partitions.go:
##########
@@ -162,10 +162,13 @@ func (p *PartitionField) UnmarshalJSON(b []byte) error {
        } else {
                p.SourceIDs = []int{aux.SourceID}
        }
+       // Positivity only holds for specs already bound to a schema. Decoding 
also
+       // covers unbound specs from create-table and commit requests, whose 
source
+       // IDs are client ordinal placeholders starting at 0, remapped by name 
at
+       // bind time.
        for _, sourceID := range p.SourceIDs {
-               _, isVoid := p.Transform.(VoidTransform)
-               if sourceID <= 0 && (!isVoid || hasSourceID || hasSourceIDs) {
-                       return fmt.Errorf("%w: partition source ID must be 
positive: %d", ErrInvalidPartitionSpec, sourceID)
+               if sourceID < 0 {

Review Comment:
   I'd keep this check strict and move the ordinal-0 allowance to a 
request-specific path. `PartitionField.UnmarshalJSON` is the same function 
`ParseMetadataBytes` uses to parse persisted v1/v2/v3 metadata, so relaxing `<= 
0` to `< 0` here lets an already-bound persisted spec carry source-id 0 with no 
error, and `sourceIdToFields` ends up keyed on 0. It also quietly drops the old 
`VoidTransform` carve-out, so `{"source-id":0,"transform":"void"}` used to be 
rejected and now isn't.
   
   The create-table path doesn't need the parser relaxed, `reassignIDs` already 
remaps ordinals by name via `previousMapFn(f.SourceID())`, so I'd leave this 
rejecting source-id `<= 0` and add an unbound decode path for requests. Same 
shape on the sort side in `validateSortSourceID`. wdyt?



##########
table/metadata_internal_test.go:
##########
@@ -711,6 +711,83 @@ func 
TestRejectStructurallyInvalidHistoricalPartitionSpec(t *testing.T) {
        assert.ErrorContains(t, err, "spec ID must be non-negative")
 }
 
+// A create-table request carries unbound placeholder IDs rather than final
+// field IDs. Spark numbers the root struct's fields by ordinal, so the first
+// column is field-id 0 and partitioning or sorting by it yields source-id 0.
+// NewMetadata must accept that and remap every source ID by name, matching
+// Java's TableMetadata.newTableMetadata.
+func TestNewMetadataFromOrdinalNumberedRequest(t *testing.T) {
+       const requestSchema = `{
+               "type": "struct", "schema-id": 0,
+               "fields": [
+                       {"id": 0, "name": "my_ints", "required": false, "type": 
"int"},
+                       {"id": 1, "name": "my_floats", "required": false, 
"type": "double"},
+                       {"id": 2, "name": "strings", "required": false, "type": 
"string"}
+               ]
+       }`
+
+       for _, tt := range []struct {
+               name         string
+               spec         string
+               wantSourceID int
+               wantName     string
+       }{
+               {
+                       name:         "identity on first column",
+                       spec:         
`{"spec-id":0,"fields":[{"name":"my_ints","transform":"identity","source-id":0,"field-id":1000}]}`,
+                       wantSourceID: 1,
+                       wantName:     "my_ints",
+               },
+               {
+                       name:         "bucket on first column",
+                       spec:         
`{"spec-id":0,"fields":[{"name":"my_ints_bucket","transform":"bucket[16]","source-id":0,"field-id":1000}]}`,
+                       wantSourceID: 1,
+                       wantName:     "my_ints_bucket",
+               },
+               {
+                       name:         "identity on last column",
+                       spec:         
`{"spec-id":0,"fields":[{"name":"strings","transform":"identity","source-id":2,"field-id":1000}]}`,
+                       wantSourceID: 3,
+                       wantName:     "strings",
+               },
+       } {
+               t.Run(tt.name, func(t *testing.T) {
+                       var sc iceberg.Schema
+                       require.NoError(t, 
json.Unmarshal([]byte(requestSchema), &sc))
+
+                       var spec iceberg.PartitionSpec
+                       require.NoError(t, json.Unmarshal([]byte(tt.spec), 
&spec))
+
+                       var order SortOrder
+                       require.NoError(t, json.Unmarshal([]byte(
+                               
`{"order-id":1,"fields":[{"transform":"identity","source-id":0,"direction":"asc","null-order":"nulls-first"}]}`,
+                       ), &order))
+
+                       meta, err := NewMetadata(&sc, &spec, order, 
"s3://bucket/tbl", nil)
+                       require.NoError(t, err)
+
+                       gotSpec := meta.PartitionSpec()
+                       require.Equal(t, 1, gotSpec.NumFields())
+
+                       assert.Equal(t, tt.wantSourceID, 
gotSpec.Field(0).SourceID())
+                       assert.Equal(t, tt.wantName, gotSpec.Field(0).Name)
+
+                       // The sort order references the first column, so it 
remaps to 1.
+                       for _, field := range meta.SortOrder().Fields() {

Review Comment:
   This loop can pass without asserting anything. If `meta.SortOrder()` comes 
back as `UnsortedSortOrder` (0 fields) because the sort remap silently failed, 
the range body never runs and the assertion is vacuous, so the exact regression 
this test is meant to catch would slip through green.
   
   I'd add `assert.Equal(t, 1, meta.SortOrder().Len())` right before the loop, 
mirroring the `require.Equal(t, 1, gotSpec.NumFields())` guard you already have 
on the partition side.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to