From 242f3e5a154f82c518998825e786a9f1a7ed0559 Mon Sep 17 00:00:00 2001 From: Javier de la Torre Date: Sun, 12 Apr 2026 07:05:16 +0200 Subject: [PATCH] feat: add GeoArrow support for bulk ingestion (GEOGRAPHY/GEOMETRY) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add transparent geometry import via geoarrow.wkb/wkt extension types. The driver detects geoarrow columns in Arrow metadata and converts them to Snowflake GEOGRAPHY or GEOMETRY using a COPY transform with inline TO_GEOGRAPHY/TO_GEOMETRY conversion. How it works: 1. Detect geoarrow.wkb/wkb_view/wkt/wkt_view from Arrow extension types or ARROW:extension:name field metadata (C Data Interface) 2. Create table with native GEOGRAPHY/GEOMETRY columns 3. COPY INTO with transform: TO_GEOGRAPHY($1:"geom"::BINARY, true) converts WKB to GEOGRAPHY inline during COPY — no post-processing Statement option: adbc.snowflake.statement.ingest_geo_type = "geography" (default) | "geometry" GEOGRAPHY is always WGS84 (SRID 4326). GEOMETRY supports any SRID, extracted from geoarrow CRS metadata (PROJJSON or "EPSG:NNNN"). The COPY transform approach is ~1.33x faster than the alternative rename+CTAS+drop pattern because it eliminates 3 SQL round-trips and a full table rewrite: 50K points (median, 10 runs): 8.13s → 6.11s (6,150 → 8,183 rows/sec) 500K points: 16.95s → 12.71s (29,499 → 39,339 rows/sec) 500K polygons: 19.85s → 17.81s (25,189 → 28,074 rows/sec) --- go/bulk_ingestion.go | 26 ++-- go/statement.go | 358 +++++++++++++++++++++++++++++++++++++++++-- go/statement_test.go | 111 ++++++++++++++ 3 files changed, 477 insertions(+), 18 deletions(-) create mode 100644 go/statement_test.go diff --git a/go/bulk_ingestion.go b/go/bulk_ingestion.go index a339530..144851c 100644 --- a/go/bulk_ingestion.go +++ b/go/bulk_ingestion.go @@ -122,6 +122,13 @@ type ingestOptions struct { // // Default is true. vectorizedScanner bool + // Snowflake type to use for geoarrow columns (geoarrow.wkb, geoarrow.wkt). + // + // Valid values are "geography" (default) and "geometry". + // GEOGRAPHY is always WGS84 (SRID 4326). GEOMETRY supports any SRID; + // the SRID is extracted from geoarrow extension metadata and applied + // via ST_SETSRID after COPY INTO. + geoType string } func DefaultIngestOptions() ingestOptions { @@ -133,6 +140,7 @@ func DefaultIngestOptions() ingestOptions { compressionCodec: defaultCompressionCodec, compressionLevel: defaultCompressionLevel, vectorizedScanner: defaultVectorizedScanner, + geoType: "geography", } } @@ -141,7 +149,7 @@ func DefaultIngestOptions() ingestOptions { // // The Record must already be bound by calling stmt.Bind(), and will be released // and reset upon completion. -func (st *statement) ingestRecord(ctx context.Context) (nrows int64, err error) { +func (st *statement) ingestRecord(ctx context.Context, copyQ string) (nrows int64, err error) { defer func() { // Record already released by writeParquet() st.bound = nil @@ -209,7 +217,7 @@ func (st *statement) ingestRecord(ctx context.Context) (nrows int64, err error) } // Load the uploaded file into the target table - _, err = st.cnxn.cn.ExecContext(ctx, copyQuery, []driver.NamedValue{{Value: target}}) + _, err = st.cnxn.cn.ExecContext(ctx, copyQ, []driver.NamedValue{{Value: target}}) if err != nil { return } @@ -225,7 +233,7 @@ func (st *statement) ingestRecord(ctx context.Context) (nrows int64, err error) // // The RecordReader must already be bound by calling stmt.BindStream(), and will // be released and reset upon completion. -func (st *statement) ingestStream(ctx context.Context) (nrows int64, err error) { +func (st *statement) ingestStream(ctx context.Context, copyQ string) (nrows int64, err error) { defer func() { st.streamBind.Release() st.streamBind = nil @@ -298,7 +306,7 @@ func (st *statement) ingestStream(ctx context.Context) (nrows int64, err error) } // Kickoff background tasks to COPY Parquet files into Snowflake table as they are uploaded - fileReady, finishCopy, cancelCopy := runCopyTasks(ctx, st.cnxn.cn, target, int(st.ingestOptions.copyConcurrency)) + fileReady, finishCopy, cancelCopy := runCopyTasks(ctx, st.cnxn.cn, copyQ, target, int(st.ingestOptions.copyConcurrency)) // Read Parquet files from buffer pool and upload to Snowflake stage in parallel g.Go(func() error { @@ -532,8 +540,8 @@ func uploadAllStreams( return g.Wait() } -func executeCopyQuery(ctx context.Context, cn snowflakeConn, tableName string, filesToCopy *fileSet) (err error) { - rows, err := cn.QueryContext(ctx, copyQuery, []driver.NamedValue{{Value: tableName}}) +func executeCopyQuery(ctx context.Context, cn snowflakeConn, copyQ string, tableName string, filesToCopy *fileSet) (err error) { + rows, err := cn.QueryContext(ctx, copyQ, []driver.NamedValue{{Value: tableName}}) if err != nil { return err } @@ -566,7 +574,7 @@ func executeCopyQuery(ctx context.Context, cn snowflakeConn, tableName string, f return nil } -func runCopyTasks(ctx context.Context, cn snowflakeConn, tableName string, concurrency int) (func(string), func() error, func()) { +func runCopyTasks(ctx context.Context, cn snowflakeConn, copyQ string, tableName string, concurrency int) (func(string), func() error, func()) { var filesToCopy fileSet ctx, cancel := context.WithCancel(ctx) @@ -631,7 +639,7 @@ func runCopyTasks(ctx context.Context, cn snowflakeConn, tableName string, concu time.Sleep(backoff) } - if err := executeCopyQuery(ctx, cn, tableName, &filesToCopy); err != nil { + if err := executeCopyQuery(ctx, cn, copyQ, tableName, &filesToCopy); err != nil { return err } @@ -670,7 +678,7 @@ func runCopyTasks(ctx context.Context, cn snowflakeConn, tableName string, concu } g.Go(func() error { - return executeCopyQuery(ctx, cn, tableName, &filesToCopy) + return executeCopyQuery(ctx, cn, copyQ, tableName, &filesToCopy) }) } }() diff --git a/go/statement.go b/go/statement.go index df15bc1..538114d 100644 --- a/go/statement.go +++ b/go/statement.go @@ -25,6 +25,7 @@ package snowflake import ( "context" "database/sql/driver" + "encoding/json" "fmt" "io" "strconv" @@ -51,6 +52,11 @@ const ( OptionStatementIngestCompressionCodec = "adbc.snowflake.statement.ingest_compression_codec" // TODO(GH-1473): Implement option OptionStatementIngestCompressionLevel = "adbc.snowflake.statement.ingest_compression_level" // TODO(GH-1473): Implement option OptionStatementVectorizedScanner = "adbc.snowflake.statement.ingest_use_vectorized_scanner" + // OptionStatementIngestGeoType controls the Snowflake type created for + // columns with geoarrow extension types (geoarrow.wkb, geoarrow.wkt). + // Valid values are "geography" (default) and "geometry". + // GEOGRAPHY is always WGS84 (SRID 4326). GEOMETRY supports any SRID. + OptionStatementIngestGeoType = "adbc.snowflake.statement.ingest_geo_type" ) type statement struct { @@ -263,6 +269,17 @@ func (st *statement) SetOption(key string, val string) error { } st.ingestOptions.vectorizedScanner = vectorized return nil + case OptionStatementIngestGeoType: + switch strings.ToLower(val) { + case "geography", "geometry": + st.ingestOptions.geoType = strings.ToLower(val) + default: + return adbc.Error{ + Msg: fmt.Sprintf("[Snowflake] invalid geo type '%s': must be 'geography' or 'geometry'", val), + Code: adbc.StatusInvalidArgument, + } + } + return nil default: return st.Base().SetOption(key, val) } @@ -367,14 +384,20 @@ func (st *statement) SetSqlQuery(query string) error { return nil } -func toSnowflakeType(dt arrow.DataType) string { +func toSnowflakeType(dt arrow.DataType, geoType string) string { switch dt.ID() { case arrow.EXTENSION: - return toSnowflakeType(dt.(arrow.ExtensionType).StorageType()) + ext := dt.(arrow.ExtensionType) + switch ext.ExtensionName() { + case "geoarrow.wkb", "geoarrow.wkb_view", "geoarrow.wkt", "geoarrow.wkt_view": + return geoType + default: + return toSnowflakeType(ext.StorageType(), geoType) + } case arrow.DICTIONARY: - return toSnowflakeType(dt.(*arrow.DictionaryType).ValueType) + return toSnowflakeType(dt.(*arrow.DictionaryType).ValueType, geoType) case arrow.RUN_END_ENCODED: - return toSnowflakeType(dt.(*arrow.RunEndEncodedType).Encoded()) + return toSnowflakeType(dt.(*arrow.RunEndEncodedType).Encoded(), geoType) case arrow.INT8, arrow.INT16, arrow.INT32, arrow.INT64, arrow.UINT8, arrow.UINT16, arrow.UINT32, arrow.UINT64: return "integer" @@ -416,7 +439,13 @@ func toSnowflakeType(dt arrow.DataType) string { return "" } -func (st *statement) initIngest(ctx context.Context) error { +// initIngest creates the target table for ingestion. +// +// geoTypeOverrides maps field names to Snowflake types ("geography" or "geometry") +// for geo columns that should be created with native types instead of their Arrow +// storage type (BINARY/TEXT). This is used when COPY transform handles inline +// conversion, so the table must have native geo columns from the start. +func (st *statement) initIngest(ctx context.Context, geoTypeOverrides map[string]string) error { var ( createBldr strings.Builder ) @@ -442,7 +471,14 @@ func (st *statement) initIngest(ctx context.Context) error { createBldr.WriteString(quoteIdentifier(f.Name)) createBldr.WriteString(" ") - ty := toSnowflakeType(f.Type) + + // Use geo type override if provided (for COPY transform path). + var ty string + if override, ok := geoTypeOverrides[f.Name]; ok { + ty = override + } else { + ty = toSnowflakeType(f.Type, st.ingestOptions.geoType) + } if ty == "" { return adbc.Error{ Msg: fmt.Sprintf("unimplemented type conversion for field %s, arrow type: %s", f.Name, f.Type), @@ -493,16 +529,320 @@ func (st *statement) executeIngest(ctx context.Context) (int64, error) { } } - err := st.initIngest(ctx) + // Capture schema before ingest (ingestRecord nils st.bound after completion) + var schema *arrow.Schema + if st.bound != nil { + schema = st.bound.Schema() + } else { + schema = st.streamBind.Schema() + } + + // Build the COPY query. If the schema has geo columns, build a COPY transform + // that converts WKB/WKT → GEOGRAPHY/GEOMETRY inline during COPY INTO. + // This avoids the expensive post-COPY rename+CTAS+drop pattern. + copyQ, usedGeoTransform, geoOverrides := st.buildCopyQuery(schema) + + err := st.initIngest(ctx, geoOverrides) if err != nil { return -1, err } + var nrows int64 if st.bound != nil { - return st.ingestRecord(ctx) + nrows, err = st.ingestRecord(ctx, copyQ) + } else { + nrows, err = st.ingestStream(ctx, copyQ) + } + if err != nil { + return nrows, err + } + + // Only run post-COPY geo conversion if the COPY transform wasn't used. + // The COPY transform handles conversion inline, so no CTAS is needed. + if !usedGeoTransform { + if err := st.convertGeoColumns(ctx, schema); err != nil { + return nrows, err + } + } + + return nrows, nil +} + +// buildCopyQuery returns the COPY query to use for ingestion and whether a geo +// transform was applied. When the schema contains geoarrow columns, a COPY +// transform is returned that converts WKB/WKT to GEOGRAPHY/GEOMETRY inline +// during COPY INTO — eliminating the need for a post-COPY CTAS. +// +// Snowflake's COPY INTO from Parquet normally cannot load WKB directly into +// GEOGRAPHY/GEOMETRY columns. A COPY transform works around this by applying +// TO_GEOGRAPHY/TO_GEOMETRY in the SELECT clause of the COPY subquery. +// buildCopyQuery returns the COPY query, whether a geo transform was used, and +// a map of geo column name → Snowflake type for table creation overrides. +func (st *statement) buildCopyQuery(schema *arrow.Schema) (string, bool, map[string]string) { + if schema == nil { + return copyQuery, false, nil + } + + // Detect geo columns from schema (same logic as convertGeoColumns). + type geoCol struct { + name string + extName string + extMeta string + } + var geoCols []geoCol + + for _, f := range schema.Fields() { + var extName, extMeta string + if f.Type.ID() == arrow.EXTENSION { + ext := f.Type.(arrow.ExtensionType) + extName = ext.ExtensionName() + extMeta = ext.Serialize() + } else if name, ok := f.Metadata.GetValue("ARROW:extension:name"); ok { + extName = name + extMeta, _ = f.Metadata.GetValue("ARROW:extension:metadata") + } + + switch extName { + case "geoarrow.wkb", "geoarrow.wkb_view", "geoarrow.wkt", "geoarrow.wkt_view": + geoCols = append(geoCols, geoCol{name: f.Name, extName: extName, extMeta: extMeta}) + } + } + + if len(geoCols) == 0 { + return copyQuery, false, nil + } + + // Build a COPY transform with inline geo conversion. + geoType := st.ingestOptions.geoType + var selectCols []string + for _, f := range schema.Fields() { + quoted := fmt.Sprintf("%q", f.Name) + parqRef := fmt.Sprintf("$1:%s", quoted) + + // Check if this field is a geo column. + var gc *geoCol + for i := range geoCols { + if geoCols[i].name == f.Name { + gc = &geoCols[i] + break + } + } + + if gc == nil { + // Non-geo column: reference directly from Parquet, Snowflake auto-casts to target type. + selectCols = append(selectCols, fmt.Sprintf("%s AS %s", parqRef, quoted)) + continue + } + + // Geo column: apply conversion function. + isWKB := strings.Contains(gc.extName, "wkb") + var expr string + if geoType == "geography" { + if isWKB { + expr = fmt.Sprintf("TO_GEOGRAPHY(%s::BINARY, true) AS %s", parqRef, quoted) + } else { + expr = fmt.Sprintf("TRY_TO_GEOGRAPHY(%s::VARCHAR) AS %s", parqRef, quoted) + } + } else { + srid := extractSRIDFromMeta(gc.extMeta) + if srid != 0 { + if isWKB { + expr = fmt.Sprintf("ST_SETSRID(TO_GEOMETRY(%s::BINARY), %d) AS %s", parqRef, srid, quoted) + } else { + expr = fmt.Sprintf("ST_SETSRID(TO_GEOMETRY(%s::VARCHAR), %d) AS %s", parqRef, srid, quoted) + } + } else { + if isWKB { + expr = fmt.Sprintf("TO_GEOMETRY(%s::BINARY) AS %s", parqRef, quoted) + } else { + expr = fmt.Sprintf("TO_GEOMETRY(%s::VARCHAR) AS %s", parqRef, quoted) + } + } + } + selectCols = append(selectCols, expr) + } + + // Build the geo type overrides for initIngest — the table must have native + // GEOGRAPHY/GEOMETRY columns for the COPY transform to write into. + geoOverrides := make(map[string]string, len(geoCols)) + for _, gc := range geoCols { + geoOverrides[gc.name] = geoType + } + + transformQ := fmt.Sprintf( + "COPY INTO IDENTIFIER(?) FROM (SELECT %s FROM @%s)", + strings.Join(selectCols, ", "), + bindStageName, + ) + return transformQ, true, geoOverrides +} + +// convertGeoColumns converts BINARY/TEXT geo columns to GEOGRAPHY/GEOMETRY after COPY INTO. +// +// Snowflake's COPY INTO from Parquet cannot load WKB directly into GEOGRAPHY/GEOMETRY +// columns — only CSV and JSON/AVRO support direct geospatial loading from stages. +// See: https://docs.snowflake.com/en/sql-reference/data-types-geospatial#loading-geospatial-data-from-stages +// +// We work around this with a CTAS pattern: rename the staging table, create the +// final table with TO_GEOGRAPHY/TO_GEOMETRY conversion, then drop staging. +// +// TODO: Investigate using a COPY transform (SELECT ... FROM @stage) to convert +// inline during COPY INTO, which would avoid the rename+CTAS overhead. +func (st *statement) convertGeoColumns(ctx context.Context, schema *arrow.Schema) error { + if schema == nil { + return nil + } + + // Find geo columns from Arrow metadata. + // DuckDB sends geoarrow.wkb as plain BINARY with ARROW:extension:name in field metadata + // (extension types don't survive the C Data Interface round-trip). + type geoCol struct { + name string + extName string + extMeta string + } + var geoCols []geoCol + + for _, f := range schema.Fields() { + var extName, extMeta string + if f.Type.ID() == arrow.EXTENSION { + ext := f.Type.(arrow.ExtensionType) + extName = ext.ExtensionName() + extMeta = ext.Serialize() + } else if name, ok := f.Metadata.GetValue("ARROW:extension:name"); ok { + extName = name + extMeta, _ = f.Metadata.GetValue("ARROW:extension:metadata") + } + + switch extName { + case "geoarrow.wkb", "geoarrow.wkb_view", "geoarrow.wkt", "geoarrow.wkt_view": + geoCols = append(geoCols, geoCol{name: f.Name, extName: extName, extMeta: extMeta}) + } + } + + if len(geoCols) == 0 { + return nil + } + + geoType := st.ingestOptions.geoType + target := quoteIdentifier(st.targetTable) + staging := quoteIdentifier(st.targetTable + "_ADBC_STAGING") + + // Rename current table to staging + renameQuery := fmt.Sprintf("ALTER TABLE %s RENAME TO %s", target, staging) + if _, err := st.cnxn.cn.ExecContext(ctx, renameQuery, nil); err != nil { + return errToAdbcErr(adbc.StatusInternal, err) + } + + // Build SELECT with geo conversion + var selectCols []string + for _, f := range schema.Fields() { + isGeo := false + var gc geoCol + for _, g := range geoCols { + if g.name == f.Name { + isGeo = true + gc = g + break + } + } + + quoted := quoteIdentifier(f.Name) + if !isGeo { + selectCols = append(selectCols, quoted) + continue + } + + // Build conversion expression. + // For WKB: TO_GEOGRAPHY(binary, allow_invalid=true) or TO_GEOMETRY(binary). + // For WKT: TRY_TO_GEOGRAPHY(text) or TO_GEOMETRY(text). + var expr string + isWKB := strings.Contains(gc.extName, "wkb") + if geoType == "geography" { + if isWKB { + expr = fmt.Sprintf("TO_GEOGRAPHY(%s, true) AS %s", quoted, quoted) + } else { + expr = fmt.Sprintf("TRY_TO_GEOGRAPHY(%s) AS %s", quoted, quoted) + } + } else { + srid := extractSRIDFromMeta(gc.extMeta) + if srid != 0 { + expr = fmt.Sprintf("ST_SETSRID(TO_GEOMETRY(%s), %d) AS %s", quoted, srid, quoted) + } else { + expr = fmt.Sprintf("TO_GEOMETRY(%s) AS %s", quoted, quoted) + } + } + selectCols = append(selectCols, expr) + } + + // CTAS with geo conversion + ctasQuery := fmt.Sprintf("CREATE TABLE %s AS SELECT %s FROM %s", + target, strings.Join(selectCols, ", "), staging) + if _, err := st.cnxn.cn.ExecContext(ctx, ctasQuery, nil); err != nil { + // Try to restore the original table name on failure + restoreQuery := fmt.Sprintf("ALTER TABLE %s RENAME TO %s", staging, target) + st.cnxn.cn.ExecContext(ctx, restoreQuery, nil) + return errToAdbcErr(adbc.StatusInternal, err) + } + + // Drop staging table + dropQuery := fmt.Sprintf("DROP TABLE %s", staging) + st.cnxn.cn.ExecContext(ctx, dropQuery, nil) + + return nil +} + +// extractSRIDFromMeta extracts the SRID from geoarrow extension metadata string. +// The metadata is a JSON string that may contain a "crs" field. +// Supported formats: +// - PROJJSON: {"crs": {"id": {"authority": "EPSG", "code": 4326}}} +// - Simple string: "EPSG:4326" (as CRS value) +// +// Returns 0 if no SRID can be determined. +func extractSRIDFromMeta(metadata string) int { + if metadata == "" { + return 0 + } + + type projID struct { + Authority string `json:"authority"` + Code int `json:"code"` + } + type projCRS struct { + ID projID `json:"id"` + } + type geoarrowMeta struct { + CRS json.RawMessage `json:"crs"` + } + + var meta geoarrowMeta + if err := json.Unmarshal([]byte(metadata), &meta); err != nil { + return 0 + } + + if len(meta.CRS) == 0 { + return 0 + } + + // CRS can be a string like "EPSG:4326" or a PROJJSON object + var crsStr string + if err := json.Unmarshal(meta.CRS, &crsStr); err == nil { + if strings.HasPrefix(crsStr, "EPSG:") { + if code, err := strconv.Atoi(crsStr[5:]); err == nil { + return code + } + } + return 0 + } + + var crs projCRS + if err := json.Unmarshal(meta.CRS, &crs); err == nil { + if strings.EqualFold(crs.ID.Authority, "EPSG") && crs.ID.Code != 0 { + return crs.ID.Code + } } - return st.ingestStream(ctx) + return 0 } // ExecuteQuery executes the current query or prepared statement diff --git a/go/statement_test.go b/go/statement_test.go new file mode 100644 index 0000000..18fcce4 --- /dev/null +++ b/go/statement_test.go @@ -0,0 +1,111 @@ +// Copyright (c) 2025 ADBC Drivers Contributors +// +// Licensed 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 snowflake + +import ( + "reflect" + "testing" + + "github.com/apache/arrow-go/v18/arrow" + "github.com/apache/arrow-go/v18/arrow/array" + "github.com/stretchr/testify/assert" +) + +// geoArrowType implements arrow.ExtensionType for testing geoarrow types. +type geoArrowType struct { + arrow.ExtensionBase + name string +} + +func newGeoArrowType(name string, storage arrow.DataType) *geoArrowType { + return &geoArrowType{ + ExtensionBase: arrow.ExtensionBase{Storage: storage}, + name: name, + } +} + +func (g *geoArrowType) ExtensionName() string { return g.name } +func (g *geoArrowType) Serialize() string { return "" } +func (g *geoArrowType) Deserialize(storage arrow.DataType, data string) (arrow.ExtensionType, error) { + return newGeoArrowType(g.name, storage), nil +} +func (g *geoArrowType) ExtensionEquals(other arrow.ExtensionType) bool { + return g.ExtensionName() == other.ExtensionName() +} +func (g *geoArrowType) ArrayType() reflect.Type { + return reflect.TypeOf(array.ExtensionArrayBase{}) +} + +func TestToSnowflakeTypeGeoArrow(t *testing.T) { + tests := []struct { + name string + dt arrow.DataType + geoType string + expected string + }{ + { + name: "geoarrow.wkb defaults to geography", + dt: newGeoArrowType("geoarrow.wkb", arrow.BinaryTypes.Binary), + geoType: "geography", + expected: "geography", + }, + { + name: "geoarrow.wkb with geometry option", + dt: newGeoArrowType("geoarrow.wkb", arrow.BinaryTypes.Binary), + geoType: "geometry", + expected: "geometry", + }, + { + name: "plain binary stays binary", + dt: arrow.BinaryTypes.Binary, + geoType: "geography", + expected: "binary", + }, + { + name: "unknown extension falls through", + dt: newGeoArrowType("some.other.ext", arrow.BinaryTypes.Binary), + geoType: "geography", + expected: "binary", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := toSnowflakeType(tt.dt, tt.geoType) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExtractSRIDFromMeta(t *testing.T) { + tests := []struct { + name string + metadata string + expected int + }{ + {"empty", "", 0}, + {"PROJJSON 4326", `{"crs":{"id":{"authority":"EPSG","code":4326}}}`, 4326}, + {"EPSG string", `{"crs":"EPSG:3857"}`, 3857}, + {"no CRS", `{"edges":"planar"}`, 0}, + {"null CRS", `{"crs":null}`, 0}, + {"invalid JSON", `not json`, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, extractSRIDFromMeta(tt.metadata)) + }) + } +}