laskoviymishka commented on code in PR #1463:
URL: https://github.com/apache/iceberg-go/pull/1463#discussion_r3590554889
##########
transforms.go:
##########
@@ -230,6 +240,49 @@ func (VoidTransform) Project(string, BoundPredicate)
(UnboundPredicate, error) {
return nil, nil
}
+// UnknownTransform is a placeholder for a partition or sort transform that
+// this implementation doesn't recognize. The v3 spec requires readers to load
+// tables that use unknown transforms and to ignore those fields when
+// filtering; writers must not commit a partition spec that uses one.
+type UnknownTransform struct {
+ name string
+}
+
+func (t UnknownTransform) MarshalText() ([]byte, error) {
+ return []byte(t.name), nil
+}
+
+func (t UnknownTransform) String() string { return t.name }
+
+// CanTransform assumes an unknown transform could apply to any type -- the
+// real applicability isn't known.
+func (UnknownTransform) CanTransform(Type) bool { return true }
+
+// ResultType is unknown, so report string, matching the Java reference.
+func (UnknownTransform) ResultType(Type) Type { return StringType{} }
+
+func (UnknownTransform) PreservesOrder() bool { return false }
+
+func (t UnknownTransform) Equals(other Transform) bool {
+ o, ok := other.(UnknownTransform)
+
+ return ok && t.name == o.name
+}
+
+// Apply can't be evaluated for an unknown transform.
+func (UnknownTransform) Apply(Optional[Literal]) Optional[Literal] {
Review Comment:
this is the round-1 write-path concern, still open — flagging it here
because `Apply` returning an empty Optional (and `ToHumanStrType` just below
returning the transform name) is exactly what lets it corrupt silently.
A table loaded with an unknown partition transform isn't unpartitioned
(`IsUnpartitioned` only counts `VoidTransform`), so `WriteRecords` takes the
partitioned path, `getRecordPartitions` calls this `Apply`, gets `Valid:false`
for every row, and we write null partition values under a
`field=<transform-name>` dir — unreadable by Java/PyIceberg. On the `AddFiles`
path `fileToDataFile` panics instead.
I'd guard at `recordsToDataFiles`/`WriteRecords`: range the current spec and
reject if any field is an `UnknownTransform` before writing anything. That's
the spec rule too — writers must not commit against an unknown-transform spec.
Once that's in, this is good to land.
##########
table/update_spec.go:
##########
@@ -82,6 +82,12 @@ func NewUpdateSpec(t *Transaction, caseSensitive bool)
*UpdateSpec {
nameToField := make(map[string]iceberg.PartitionField)
partitionSpec := t.tbl.Metadata().PartitionSpec()
for _, partitionField := range partitionSpec.Fields() {
+ if _, ok :=
partitionField.Transform.(iceberg.UnknownTransform); ok {
Review Comment:
this went the opposite way from my round-1 ask, and I want to make sure
that's intentional rather than a slip.
Round 1 I asked to validate only newly-added fields, so a table with an
unknown transform on one column could still rename or drop an unrelated one.
This rejects any evolution up front — the `UpdateSpec` is dead before a single
op is queued.
If it's deliberate parity with Java's `BaseUpdatePartitionSpec`, I'm happy
with it — simpler, clearer signal, and I'll take Java's precedent over my
earlier ask. Just want to confirm it's the intended final direction. wdyt?
##########
transforms_test.go:
##########
@@ -103,6 +93,45 @@ func TestParseTransform(t *testing.T) {
assert.ErrorContains(t, err, tt.toparse)
})
}
+
+ // Unrecognized transform strings parse to an UnknownTransform (v3
requires
+ // readers to load unknown transforms) and round-trip verbatim,
preserving
+ // the original casing.
+ unknownTests := []struct {
+ name string
+ toparse string
+ }{
+ {"foobar", "foobar"},
+ {"bucket no brackets", "bucket"},
+ {"truncate no brackets", "truncate"},
+ {"bucket no val", "bucket[]"},
+ {"truncate no val", "truncate[]"},
+ {"bucket neg", "bucket[-1]"},
+ {"truncate neg", "truncate[-1]"},
+ {"bucket extra suffix", "bucketx[5]"},
+ {"bucket extra token", "bucket_extra[5]"},
+ {"truncate extra suffix", "truncatefoo[10]"},
+ {"truncate extra token", "truncate_garbage[4]"},
+ {"preserves original case", "Custom_V2[3]"},
+ }
+
+ for _, tt := range unknownTests {
+ t.Run("unknown/"+tt.name, func(t *testing.T) {
+ tr, err := iceberg.ParseTransform(tt.toparse)
+ require.NoError(t, err)
+ _, ok := tr.(iceberg.UnknownTransform)
+ assert.True(t, ok)
Review Comment:
tiny one: `assert.True(t, ok)` is non-fatal, so if `ParseTransform` ever
regresses and returns a non-`UnknownTransform`, execution falls through to the
bare `u := tr.(iceberg.UnknownTransform)` on line 130 and panics the goroutine
instead of failing cleanly. `require.True` here fixes it.
##########
table/metadata_internal_test.go:
##########
@@ -309,6 +309,31 @@ func TestMetadataV3Parsing(t *testing.T) {
assert.Equal(t, int64(2000), *secondSnapshot.FirstRowID)
}
+// A full v3 metadata document must load when its partition spec and sort order
+// use unknown transforms — nothing on the metadata path may trip a guard.
+func TestMetadataV3ParsesUnknownTransforms(t *testing.T) {
+ j := strings.NewReplacer(
+ `{"name": "x", "transform": "identity", "source-id": 1,
"field-id": 1000}`,
+ `{"name": "x", "transform": "custom_transform[42]",
"source-id": 1, "field-id": 1000}`,
+ `{"transform": "bucket[4]", "source-id": 3, "direction":
"desc", "null-order": "nulls-last"}`,
+ `{"transform": "custom_sort[7]", "source-id": 3, "direction":
"desc", "null-order": "nulls-last"}`,
+ ).Replace(ExampleTableMetadataV3)
+
+ meta, err := ParseMetadataBytes([]byte(j))
+ require.NoError(t, err)
+
+ spec := meta.PartitionSpec()
+ pf := spec.Field(0)
+ _, ok := pf.Transform.(iceberg.UnknownTransform)
+ assert.True(t, ok, "unknown partition transform should load")
+ assert.Equal(t, "custom_transform[42]", pf.Transform.String())
+
+ sf := meta.SortOrder().Field(1)
+ _, ok = sf.Transform.(iceberg.UnknownTransform)
+ assert.True(t, ok, "unknown sort transform should load")
+ assert.Equal(t, "custom_sort[7]", sf.Transform.String())
Review Comment:
this is the full-metadata round-trip I asked for last round, thanks — it
proves the load path doesn't trip a guard.
It only goes one direction though: `ParseMetadataBytes` then assert. The
field-level `TestPartitionFieldUnknownTransformRoundTrip` covers marshal-back,
so serialization itself is tested — but a marshal-back assert here (that the
output still contains `custom_transform[42]`/`custom_sort[7]`) is the exact
guardrail against `validateTransform` sneaking onto the write path that I was
after. Cheap to add while we're here.
--
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]