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


##########
table/arrow_utils.go:
##########
@@ -528,6 +528,10 @@ func ensureSmallArrowTypes(dt arrow.DataType) 
(arrow.DataType, error) {
 }
 
 type convertToArrow struct {
+       // the ctx stores table property metadata,
+       // for instance, projjson definitions that can be
+       // used when writing
+       ctx             context.Context

Review Comment:
   This is the big one, and I think it's worth pulling in @zeroshade before we 
go further — it's a larger design question than the PR itself.
   
   We're using `context.Context` to carry a *required* input (the table 
properties needed to resolve `projjson:`), not a cross-cutting concern like 
cancellation or tracing. The practical cost is that correctness now rides on 
convention instead of the type system: any call site that forgets 
`WithTableProperties` compiles fine and only fails at runtime, and only when a 
projjson CRS actually shows up. That's the exact failure mode #1230 was 
reported for — so as written this relocates the bug class rather than removing 
it, and the user-visible error ("could not be resolved from table properties") 
looks identical to the old "not supported yet".
   
   The alternative I'd lean toward is threading properties explicitly — an 
`iceberg.Properties` argument, or a small `arrowConvertOptions` struct — down 
through `convertToArrow` / the visitor / `arrowProjectionVisitor`, so 
"properties missing" becomes a compile-time question. I know 
`arrowProjectionVisitor.ctx` already sets a ctx-on-the-visitor precedent, so 
staying consistent with that is defensible too — but then I'd want the 
redundant `WithTableProperties` calls (they're re-applied in 
`recordsToDataFiles`/`positionDeleteRecordsToDataFiles` on ctx that often 
already carries them) collapsed to a single owning layer.
   
   @zeroshade — you rejected widening the visitor interface on #1230; does the 
ctx-carries-required-props shape sit right with you, or would you rather see 
properties passed explicitly? This decision drives most of the rest of the PR, 
so I'd like to settle it first.
   
   (Minor while we're here: the field comment says the ctx "stores" properties 
— it carries them via `WithTableProperties`, resolved on demand. Worth 
tightening.)



##########
table/arrow_utils_internal_test.go:
##########
@@ -478,21 +480,134 @@ func TestIcebergCRSToGeoArrowMetadata(t *testing.T) {
                }
                for _, tc := range cases {
                        t.Run(tc.crs, func(t *testing.T) {
-                               meta, err := 
icebergCRSToGeoArrowMetadata(tc.crs)
+                               meta, err := 
icebergCRSToGeoArrowMetadata(context.Background(), tc.crs)
                                require.NoError(t, err)
                                assert.Equal(t, tc.wantCRSType, meta.CRSType)
                        })
                }
        })
 
        t.Run("srid:0 maps to an omitted CRS", func(t *testing.T) {
-               meta, err := icebergCRSToGeoArrowMetadata("srid:0")
+               meta, err := icebergCRSToGeoArrowMetadata(context.Background(), 
"srid:0")
                require.NoError(t, err)
                assert.Empty(t, meta.CRS)
                assert.Empty(t, meta.CRSType)
        })
 }
 
+func TestTypeToArrowTypeWithContextResolvesProjJSONCRS(t *testing.T) {
+       const projjson = `{"type":"GeographicCRS","name":"WGS 84"}`
+       ctx := internal.WithTableProperties(context.Background(), 
iceberg.Properties{
+               "geo.crs": projjson,
+       })
+
+       t.Run("geometry", func(t *testing.T) {
+               geom, err := iceberg.GeometryTypeOf("projjson:geo.crs")
+               require.NoError(t, err)
+
+               dt, err := typeToArrowTypeWithContext(ctx, geom, true, false)
+               require.NoError(t, err)
+
+               wkb, ok := dt.(*geoarrow.WKBType)
+               require.True(t, ok)
+
+               meta := wkb.Metadata()
+               assert.Equal(t, geoarrow.CRSTypePROJJSON, meta.CRSType)
+               assert.JSONEq(t, projjson, string(meta.CRS))
+               // when edge is omitted, it should default to planar
+               assert.Equal(t, geoarrow.EdgePlanar, meta.Edges)
+       })
+
+       t.Run("geography", func(t *testing.T) {
+               geog, err := iceberg.GeographyTypeOf("projjson:geo.crs", 
string(geoarrow.EdgeSpherical))
+               require.NoError(t, err)
+
+               dt, err := typeToArrowTypeWithContext(ctx, geog, true, false)
+               require.NoError(t, err)
+
+               wkb, ok := dt.(*geoarrow.WKBType)
+               require.True(t, ok)
+
+               meta := wkb.Metadata()
+               assert.Equal(t, geoarrow.CRSTypePROJJSON, meta.CRSType)
+               assert.JSONEq(t, projjson, string(meta.CRS))
+               assert.Equal(t, geoarrow.EdgeSpherical, meta.Edges)
+       })
+}
+
+func TestTypeToArrowTypeWithContextProjJSONCRSErrors(t *testing.T) {
+       geom, err := iceberg.GeometryTypeOf("projjson:geo.crs")
+       require.NoError(t, err)
+
+       t.Run("no properties in context", func(t *testing.T) {
+               _, err := typeToArrowTypeWithContext(context.Background(), 
geom, true, false)
+               require.Error(t, err)
+               require.ErrorIs(t, err, iceberg.ErrInvalidSchema)
+               require.ErrorContains(t, err, "could not be resolved from table 
properties")
+       })
+
+       t.Run("missing table property", func(t *testing.T) {
+               ctx := internal.WithTableProperties(context.Background(), 
iceberg.Properties{})
+               _, err := typeToArrowTypeWithContext(ctx, geom, true, false)
+               require.Error(t, err)
+               require.ErrorIs(t, err, iceberg.ErrInvalidSchema)
+               require.ErrorContains(t, err, "references missing table 
property")
+       })
+
+       t.Run("invalid projjson", func(t *testing.T) {
+               ctx := internal.WithTableProperties(context.Background(), 
iceberg.Properties{
+                       "geo.crs": "{",
+               })
+               _, err := typeToArrowTypeWithContext(ctx, geom, true, false)
+               require.Error(t, err)
+               require.ErrorIs(t, err, iceberg.ErrInvalidSchema)
+               require.ErrorContains(t, err, "is not valid JSON")
+       })
+}
+
+func TestToRequestedSchemaResolvesProjJSONCRSFromContext(t *testing.T) {
+       const projjson = `{"type":"GeographicCRS","name":"WGS 84"}`
+
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer mem.AssertSize(t, 0)
+
+       inputSchema := arrow.NewSchema(nil, nil)
+       bldr := array.NewRecordBuilder(mem, inputSchema)

Review Comment:
   `TestToRequestedSchemaResolvesProjJSONCRSFromContext` runs 
`ToRequestedSchema` against an empty file schema and a zero-column record, so 
it exercises the resolution logic but not a real write. None of the seven call 
sites this PR added `WithTableProperties` to — `recordsToDataFiles`, 
`WriteRecords`, `RollingDataWriter`, `PositionDeltaWriter.Close`, 
`WriteEqualityDeletes`, `ExecuteCompactionGroup` — are touched by a test 
proving the property actually flows from the table/transaction down to arrow 
conversion.
   
   The single most valuable test to add: append a record with a projjson-CRS 
geometry column and confirm the resulting Parquet's Arrow schema carries the 
right GeoArrow metadata. That's what closes the gap between "resolution works 
in isolation" and "the write path wires it up" — and it's exactly the coverage 
that would catch a future call site forgetting `WithTableProperties`. Could we 
add one going through `recordsToDataFiles`?



##########
table/arrow_utils.go:
##########
@@ -2071,8 +2088,39 @@ func geoArrowMetadataToIcebergType(meta 
geoarrow.Metadata) (iceberg.Type, error)
 
 var authorityCodeCRS = 
regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*:[A-Za-z0-9_.-]+$`)
 
-func icebergCRSToGeoArrowMetadata(crs string) (geoarrow.Metadata, error) {
+// icebergCRSToGeoArrowMetadata converts an Iceberg type's CRS to GeoArrow
+// metadata. It uses the ctx variable to pass through the projjson definitions
+// from the table properties.
+func icebergCRSToGeoArrowMetadata(ctx context.Context, crs string) 
(geoarrow.Metadata, error) {
        lowerCRS := strings.ToLower(crs)
+
+       if strings.HasPrefix(lowerCRS, "projjson:") {
+               propKey := strings.TrimSpace(crs[len("projjson:"):])
+               if propKey == "" {
+                       return geoarrow.Metadata{}, fmt.Errorf("%w: projjson 
CRS missing table property key", iceberg.ErrInvalidSchema)
+               }
+
+               props := tblutils.TablePropertiesFromContext(ctx)
+               if props == nil {
+                       return geoarrow.Metadata{}, fmt.Errorf("%w: projjson 
CRS %q could not be resolved from table properties", iceberg.ErrInvalidSchema, 
crs)
+               }
+
+               projjson, ok := props[propKey]
+               if !ok || strings.TrimSpace(projjson) == "" {
+                       return geoarrow.Metadata{}, fmt.Errorf("%w: projjson 
CRS %q references missing table property %q", iceberg.ErrInvalidSchema, crs, 
propKey)
+               }
+
+               raw := json.RawMessage(projjson)
+               if !json.Valid(raw) {
+                       return geoarrow.Metadata{}, fmt.Errorf("%w: projjson 
CRS table property %q is not valid JSON", iceberg.ErrInvalidSchema, propKey)
+               }
+
+               return geoarrow.Metadata{
+                       CRS:     raw,
+                       CRSType: geoarrow.CRSTypePROJJSON,

Review Comment:
   We now write a `geoarrow.Metadata` with `CRSType: PROJJSON` and a 
JSON-object CRS, but `geoArrowCRSToIcebergCRS` on the read side has no case for 
`meta.CRSType == PROJJSON` in its `checkCRSJSON` branch — it only special-cases 
the plain-string PROJJSON form. So a table we write here can't be reconstructed 
back to `projjson:<key>` by our own read path (or by compaction/rewrite, which 
re-derives schema from the written Parquet). The property key isn't stored in 
the GeoArrow metadata at all, so the round-trip is fundamentally lossy.
   
   I'd at minimum add an explicit `PROJJSON` case to the `checkCRSJSON` path 
that returns a clear, distinguishable error rather than falling through to 
authority/code parsing, and cover the write-then-read behavior with a test so 
the limitation is documented rather than surprising. If full round-trip is out 
of scope for this PR that's fine, but I'd want it called out explicitly. wdyt?
   
   (While we're here: the `errPROJJSONStringCRSNotSupported` comment — "which 
this package does not support yet" — reads stale now that we write the object 
form; worth a tweak so a future maintainer doesn't read PROJJSON as fully 
covered.)



##########
table/equality_delete_writer.go:
##########
@@ -27,6 +27,7 @@ import (
        "github.com/apache/iceberg-go"
        "github.com/apache/iceberg-go/config"
        "github.com/apache/iceberg-go/internal"
+       tblutils "github.com/apache/iceberg-go/table/internal"

Review Comment:
   `table/internal` gets imported unaliased as `internal` in 
`transaction.go`/`writer.go` but aliased `tblutils` here (and in 
`rolling_data_writer.go`, `rewrite_data_files.go`, `position_delta_writer.go`, 
`arrow_utils.go`) — so `internal.WithTableProperties` and 
`tblutils.WithTableProperties` are the same function under two names, which is 
harder to grep/refactor. I'd standardize on `tblutils` for `table/internal` and 
reserve the bare `internal` name for the root package.



##########
table/arrow_utils.go:
##########
@@ -2071,8 +2088,39 @@ func geoArrowMetadataToIcebergType(meta 
geoarrow.Metadata) (iceberg.Type, error)
 
 var authorityCodeCRS = 
regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*:[A-Za-z0-9_.-]+$`)
 
-func icebergCRSToGeoArrowMetadata(crs string) (geoarrow.Metadata, error) {
+// icebergCRSToGeoArrowMetadata converts an Iceberg type's CRS to GeoArrow
+// metadata. It uses the ctx variable to pass through the projjson definitions
+// from the table properties.
+func icebergCRSToGeoArrowMetadata(ctx context.Context, crs string) 
(geoarrow.Metadata, error) {
        lowerCRS := strings.ToLower(crs)
+
+       if strings.HasPrefix(lowerCRS, "projjson:") {
+               propKey := strings.TrimSpace(crs[len("projjson:"):])

Review Comment:
   Not a bug — slicing the original-case `crs` after matching on `lowerCRS` is 
safe since `"projjson:"` is a fixed 9-byte ASCII prefix, and preserving the 
key's case is correct. But the case-insensitive-scheme / case-sensitive-key 
asymmetry is easy to misread as a bug on a skim (the `srid:` branch below does 
the same). A one-line comment would save the next reader a double-take.



##########
table/arrow_utils.go:
##########
@@ -714,7 +718,12 @@ var _ iceberg.SchemaVisitorPerPrimitiveType[arrow.Field] = 
convertToArrow{}
 // is true, then each field of the schema will contain a metadata key 
PARQUET:field_id set to
 // the field id from the iceberg schema.
 func SchemaToArrowSchema(sc *iceberg.Schema, metadata map[string]string, 
includeFieldIDs, useLargeTypes bool) (*arrow.Schema, error) {

Review Comment:
   Because the public entry points hardcode `context.Background()`, an external 
caller building a custom writer around `SchemaToArrowSchema`/`TypeToArrowType` 
can never resolve a `projjson:` CRS — they get "could not be resolved from 
table properties" with no signature to pass the props through, and it's the 
same error text as the pre-fix state, so it reads like "add the property and 
it'll work" when there's actually no path to.
   
   #1230 doesn't scope itself to internal writers, so this is a real half-fixed 
state. I'd either add public `SchemaToArrowSchemaWithContext(ctx, 
...)`/`TypeToArrowTypeWithContext(ctx, ...)` mirroring the internal ones, or 
state plainly in the exported doc comments that the public functions can't 
resolve `projjson:` references. This is tied to the ctx-vs-explicit-props 
question above — whichever way that lands should decide the public shape too.



##########
table/rolling_data_writer.go:
##########
@@ -108,6 +108,7 @@ func withFactoryFileSchema(schema *iceberg.Schema) 
writerFactoryOption {
 // configuration derived from the table metadata.
 func newWriterFactory(rootLocation string, args recordWritingArgs, meta 
*MetadataBuilder, taskSchema *iceberg.Schema, targetFileSize int64, opts 
...writerFactoryOption) (*writerFactory, error) {
        nextCount, stopCount := iter.Pull(args.counter)
+       propCtx := tblutils.WithTableProperties(context.Background(), 
meta.props)

Review Comment:
   `newWriterFactory` builds `propCtx` off `context.Background()`, so any 
deadline/cancellation/tracing/allocator on the caller's inbound ctx is dropped 
for schema building and for the option callbacks (`withFactoryFileSchema` gets 
`propCtx`, not the real request ctx). Benign today since only properties are 
threaded, but it's a footgun the moment a callee honors cancellation or calls 
`compute.GetAllocator(ctx)` — which this file family already does elsewhere. 
All three call sites have a real ctx in scope; I'd thread it into 
`newWriterFactory` and derive `propCtx` from it.
   
   Related: making `writerFactoryOption` take a ctx forced `withContentType` 
and `withFactoryEqualityFieldIDs` into `_ context.Context` even though neither 
uses it — only `withFactoryFileSchema` needs it. If the resolved props/ctx 
lived on `writerFactory` once at construction instead, that ripple goes away.



-- 
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