Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Release History

## Unreleased
- Add default-enabled kernel-backed GEOMETRY / GEOGRAPHY results, selected with `WithEnableGeoSpatialSupport` or the `enableGeoSpatialSupport` DSN parameter. Enabled mode returns raw WKB from `database/sql`; disabled mode returns WKT / EWKT. Logical column metadata is preserved, and Arrow batch access retains the canonical `struct<srid,wkb>` value in enabled mode.

## v1.16.0 (2026-09-24)
- Upgrade the kernel bindings to v1.1.0; the kernel dependency is now stable.
- Expand the kernel backend with client query timeouts, configurable idle HTTP connections, and per-statement query tags.
Expand Down
1 change: 1 addition & 0 deletions CONNECTION_PARAMETERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ ignored with a warning and still stripped from the SEA wire.
|---|---|:---:|:---:|---|---|
| `useArrowNativeDecimal` | `WithArrowNativeDecimal` | ✅ | ⚠️ | `false` | Thrift: return DECIMAL as native Arrow `decimal128` (lossless string when scanned via `database/sql`). The kernel path already renders DECIMAL as the exact string regardless, so the flag is inert there. |
| | `WithKernelDecimalAsFloat(b)` | ❌ | ✅ | `false` | Scan top-level DECIMAL as lossy `float64` instead of the exact string. |
| `enableGeoSpatialSupport` | `WithEnableGeoSpatialSupport(b)` | ❌ | ✅ | `true` | `true`: return top-level GEOMETRY / GEOGRAPHY as raw WKB `[]byte`; `GetArrowBatches` retains the full `{srid,wkb}` struct. `false`: return WKT / EWKT strings. |

With the default client-result settings, results otherwise render **byte-for-byte
identically** on both backends (scalars,
Expand Down
2 changes: 1 addition & 1 deletion KERNEL_REV
Original file line number Diff line number Diff line change
@@ -1 +1 @@
80f2aee7d884994d7b0af9a9ea6078872859a9cd
b7e9310b27be16a6c42490e58c4b5c4a525320bc
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ session parameter on both backends.
|---|---|---|---|---|
| `useArrowNativeDecimal` | `WithArrowNativeDecimal` | Thrift only (inert on kernel) | `false` | Thrift: return DECIMAL as native Arrow `decimal128` (lossless string when scanned via `database/sql`). The kernel path already renders DECIMAL as the exact string regardless. |
| | `WithKernelDecimalAsFloat(b)` | SEA only | `false` | Scan top-level DECIMAL as lossy `float64` instead of the exact string. |
| `enableGeoSpatialSupport` | `WithEnableGeoSpatialSupport(b)` | SEA only | `true` | `true`: return top-level GEOMETRY / GEOGRAPHY as raw WKB `[]byte`; `GetArrowBatches` retains the full `{srid,wkb}` struct. `false`: return WKT / EWKT strings. |

See [Cloud Fetch](#cloud-fetch), [TLS](#tls), and [Proxy](#proxy) for the remaining
groups. Telemetry parameters are covered under [Telemetry](#telemetry).
Expand Down
24 changes: 24 additions & 0 deletions connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,10 @@ func withUserConfig(ucfg config.UserConfig) ConnOption {
if ucfg.TokenCacheEnabledDSN {
kernelExperimental(c).TokenCacheEnabled = true
}
if ucfg.EnableGeoSpatialSupportDSN != nil {
enabled := *ucfg.EnableGeoSpatialSupportDSN
kernelExperimental(c).EnableGeoSpatialSupport = &enabled
}
}
}

Expand Down Expand Up @@ -665,6 +669,26 @@ func WithKernelDecimalAsFloat(asFloat bool) ConnOption {
}
}

// WithEnableGeoSpatialSupport selects how the kernel path exposes top-level
// GEOMETRY and GEOGRAPHY result values. True (the default) returns raw WKB bytes
// ([]byte). False returns WKT / EWKT strings. The logical GEOMETRY / GEOGRAPHY column type remains
// available through database/sql metadata, and the per-row SRID remains present
// in the Arrow struct returned by the driver's GetArrowBatches API.
//
// The conversion is entirely client-side and is never forwarded to SEA.
// Nested geospatial values follow the driver's existing complex-value contract:
// disabled mode renders WKT / EWKT leaves in JSON, while enabled mode preserves the
// {"srid":...,"wkb":...} shape (with JSON base64 for WKB).
//
// EXPERIMENTAL, kernel-only: the default (Thrift) backend rejects this option at
// connect time.
func WithEnableGeoSpatialSupport(enabled bool) ConnOption {
return func(c *config.Config) {
value := enabled
kernelExperimental(c).EnableGeoSpatialSupport = &value
}
}

// WithKernelTrustedCerts adds a PEM CA-certificate bundle to the kernel's TLS
// trust store on top of the system roots — for a corporate re-signing proxy or an
// on-prem CA. Required (rather than relying on SSL_CERT_FILE) because the kernel's
Expand Down
13 changes: 13 additions & 0 deletions connector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,19 @@ func TestNewConnector(t *testing.T) {
assert.Nil(t, coni.cfg.KernelExperimental)
})

t.Run("Connector test enableGeoSpatialSupport=false DSN param preserves explicit false", func(t *testing.T) {
ucfg, err := config.ParseDSN("token:supersecret@databricks-host:443/sql/1.0/endpoints/abc?enableGeoSpatialSupport=false")
require.NoError(t, err)
con, err := NewConnector(withUserConfig(ucfg))
require.NoError(t, err)

coni, ok := con.(*connector)
require.True(t, ok)
require.NotNil(t, coni.cfg.KernelExperimental)
require.NotNil(t, coni.cfg.KernelExperimental.EnableGeoSpatialSupport)
assert.False(t, *coni.cfg.KernelExperimental.EnableGeoSpatialSupport)
})

t.Run("Connector test WithTransport sets HTTPClient in CloudFetchConfig", func(t *testing.T) {
host := "databricks-host"
accessToken := "token"
Expand Down
65 changes: 59 additions & 6 deletions internal/arrowscan/arrowscan.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ import (
// date, timestamp, and top-level decimal (as an exact fixed-point string,
// matching the Thrift path — a float64 would lose precision beyond ~17 digits).
// Nested types (List/Map/Struct, and VARIANT which
// arrives nested) render to a JSON string byte-identical to the Thrift path;
// GEOMETRY arrives as a WKB/WKT string and is handled by the string arm. INTERVAL
// arrives nested) render to a JSON string byte-identical to the Thrift path.
// Top-level binary GEOMETRY / GEOGRAPHY is selected explicitly through
// ScanOptions and exposes its WKB child as []byte; string mode is handled by the
// regular string arm. INTERVAL
// day-time/year-month arrive as native arrow duration/month-interval and format to
// the same string the Thrift path receives pre-formatted from the server. NULLs
// map to nil. A genuinely unhandled type returns an error rather than a silently
Expand Down Expand Up @@ -106,20 +108,39 @@ func (c *StructKeyCache) keyPrefixes(st *arrow.StructType) []string {
// StructKeyCache) so struct field-name keys are escaped once per result set
// rather than once per row. Pass nil for the un-memoized one-shot behavior.
func ScanCellCached(col arrow.Array, row int, loc *time.Location, keys *StructKeyCache) (driver.Value, error) {
return scanCell(col, row, loc, keys, false)
return scanCell(col, row, loc, keys, ScanOptions{})
}

// ScanCellCachedDecimalFloat is ScanCellCached that, when decimalAsFloat is true,
// scans a TOP-LEVEL Decimal128 to a lossy float64 instead of the exact string
// (nested decimals still render exactly). Opt in via WithKernelDecimalAsFloat.
func ScanCellCachedDecimalFloat(col arrow.Array, row int, loc *time.Location, keys *StructKeyCache, decimalAsFloat bool) (driver.Value, error) {
return scanCell(col, row, loc, keys, decimalAsFloat)
return scanCell(col, row, loc, keys, ScanOptions{DecimalAsFloat: decimalAsFloat})
}

func scanCell(col arrow.Array, row int, loc *time.Location, keys *StructKeyCache, decimalAsFloat bool) (driver.Value, error) {
// ScanOptions controls top-level result adaptations that cannot be inferred from
// an Arrow array alone. Logical GEOMETRY / GEOGRAPHY identity lives on the
// enclosing schema Field, so kernelRows sets GeospatialBinary only for a field
// carrying that metadata and a physical struct value.
type ScanOptions struct {
DecimalAsFloat bool
GeospatialBinary bool
}

// ScanCellCachedWithOptions is the full scanner entry point used by the kernel
// rows implementation. Callers that do not need result-specific adaptations
// should continue to use ScanCellCached.
func ScanCellCachedWithOptions(col arrow.Array, row int, loc *time.Location, keys *StructKeyCache, options ScanOptions) (driver.Value, error) {
return scanCell(col, row, loc, keys, options)
}

func scanCell(col arrow.Array, row int, loc *time.Location, keys *StructKeyCache, options ScanOptions) (driver.Value, error) {
if col.IsNull(row) {
return nil, nil
}
if options.GeospatialBinary {
return scanGeospatialWKB(col, row)
}
switch c := col.(type) {
case *array.Null:
return nil, nil
Expand Down Expand Up @@ -195,7 +216,7 @@ func scanCell(col arrow.Array, row int, loc *time.Location, keys *StructKeyCache
return inLocation(timestampToTime(int64(c.Value(row)), dt.Unit), loc), nil
case *array.Decimal128:
dt := col.DataType().(*arrow.Decimal128Type)
if decimalAsFloat {
if options.DecimalAsFloat {
// Lossy fast path: float64, no per-cell string. Opt-in only.
return c.Value(row).ToFloat64(dt.Scale), nil
}
Expand Down Expand Up @@ -228,6 +249,38 @@ func scanCell(col arrow.Array, row int, loc *time.Location, keys *StructKeyCache
}
}

// scanGeospatialWKB validates and unwraps Reyden's canonical top-level
// struct<srid:int32,wkb:binary> value. The outer struct null was handled by
// scanCell before this function is called. Child nulls are malformed because
// both canonical children are non-nullable; reject them rather than returning a
// byte slice whose SRID/value pairing is incomplete.
func scanGeospatialWKB(col arrow.Array, row int) (driver.Value, error) {
values, ok := col.(*array.Struct)
if !ok {
return nil, fmt.Errorf("binary geospatial column has arrow type %s, want struct<srid:int32,wkb:binary>", col.DataType())
}
fields := values.DataType().(*arrow.StructType).Fields()
if len(fields) != 2 || fields[0].Name != "srid" || fields[1].Name != "wkb" ||
fields[0].Type.ID() != arrow.INT32 || fields[1].Type.ID() != arrow.BINARY {
return nil, fmt.Errorf("binary geospatial column has arrow type %s, want struct<srid:int32,wkb:binary>", col.DataType())
}
srids, ok := values.Field(0).(*array.Int32)
if !ok {
return nil, fmt.Errorf("binary geospatial srid child has arrow type %s, want int32", values.Field(0).DataType())
}
wkbs, ok := values.Field(1).(*array.Binary)
if !ok {
return nil, fmt.Errorf("binary geospatial wkb child has arrow type %s, want binary", values.Field(1).DataType())
}
if srids.IsNull(row) {
return nil, fmt.Errorf("binary geospatial value at row %d has a null srid child", row)
}
if wkbs.IsNull(row) {
return nil, fmt.Errorf("binary geospatial value at row %d has a null wkb child", row)
}
return wkbs.Value(row), nil
}

// formatDayTimeInterval renders an arrow duration (in the given time unit) as the
// Thrift path's "D HH:MM:SS.nnnnnnnnn" — days, then zero-padded hours:minutes:seconds
// with 9 fractional digits, negated with a leading '-'.
Expand Down
84 changes: 84 additions & 0 deletions internal/arrowscan/arrowscan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package arrowscan

import (
"fmt"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -684,3 +685,86 @@ func TestScanCellDecimalAsFloat(t *testing.T) {
t.Errorf("decimalAsFloat arm = %v, want ~99.98", fv)
}
}

func TestScanCellBinaryGeospatial(t *testing.T) {
pool := memory.NewGoAllocator()
wkb := []byte{0x01, 0x01, 0x00, 0x00, 0x00}
geoType := arrow.StructOf(
arrow.Field{Name: "srid", Type: arrow.PrimitiveTypes.Int32, Nullable: false},
arrow.Field{Name: "wkb", Type: arrow.BinaryTypes.Binary, Nullable: false},
)

build := func(outerValid, sridValid, wkbValid bool) *array.Struct {
b := array.NewStructBuilder(pool, geoType)
defer b.Release()
b.Append(outerValid)
if sridValid {
b.FieldBuilder(0).(*array.Int32Builder).Append(4326)
} else {
b.FieldBuilder(0).(*array.Int32Builder).AppendNull()
}
if wkbValid {
b.FieldBuilder(1).(*array.BinaryBuilder).Append(wkb)
} else {
b.FieldBuilder(1).(*array.BinaryBuilder).AppendNull()
}
return b.NewStructArray()
}

t.Run("unwraps WKB", func(t *testing.T) {
arr := build(true, true, true)
defer arr.Release()
got, err := ScanCellCachedWithOptions(arr, 0, nil, nil, ScanOptions{GeospatialBinary: true})
if err != nil {
t.Fatal(err)
}
if string(got.([]byte)) != string(wkb) {
t.Errorf("WKB = %x, want %x", got, wkb)
}
})

t.Run("outer null wins", func(t *testing.T) {
arr := build(false, false, false)
defer arr.Release()
got, err := ScanCellCachedWithOptions(arr, 0, nil, nil, ScanOptions{GeospatialBinary: true})
if err != nil || got != nil {
t.Errorf("outer-null geo = (%v, %v), want (nil, nil)", got, err)
}
})

t.Run("rejects null srid child", func(t *testing.T) {
arr := build(true, false, true)
defer arr.Release()
_, err := ScanCellCachedWithOptions(arr, 0, nil, nil, ScanOptions{GeospatialBinary: true})
if err == nil || !strings.Contains(err.Error(), "null srid") {
t.Errorf("error = %v, want null-srid validation error", err)
}
})

t.Run("rejects null wkb child", func(t *testing.T) {
arr := build(true, true, false)
defer arr.Release()
_, err := ScanCellCachedWithOptions(arr, 0, nil, nil, ScanOptions{GeospatialBinary: true})
if err == nil || !strings.Contains(err.Error(), "null wkb") {
t.Errorf("error = %v, want null-wkb validation error", err)
}
})

t.Run("rejects a non-canonical struct", func(t *testing.T) {
badType := arrow.StructOf(
arrow.Field{Name: "srid", Type: arrow.PrimitiveTypes.Int64},
arrow.Field{Name: "bytes", Type: arrow.BinaryTypes.Binary},
)
b := array.NewStructBuilder(pool, badType)
defer b.Release()
b.Append(true)
b.FieldBuilder(0).(*array.Int64Builder).Append(4326)
b.FieldBuilder(1).(*array.BinaryBuilder).Append(wkb)
arr := b.NewStructArray()
defer arr.Release()
_, err := ScanCellCachedWithOptions(arr, 0, nil, nil, ScanOptions{GeospatialBinary: true})
if err == nil || !strings.Contains(err.Error(), "want struct<srid:int32,wkb:binary>") {
t.Errorf("error = %v, want canonical-shape validation error", err)
}
})
}
49 changes: 49 additions & 0 deletions internal/arrowscan/coltype.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ import (
"database/sql"
"math"
"reflect"
"strings"
"time"

"github.com/apache/arrow/go/v12/arrow"
)

const databricksTypeNameMetadataKey = "databricks.type_name"

// ColumnTypeInfo is the per-column metadata database/sql surfaces through
// sql.ColumnType. The kernel derives it from the result's Arrow schema via
// ColumnTypeInfoFor — the mapping the value scanner and type reporter share, kept
Expand Down Expand Up @@ -110,6 +113,52 @@ func ColumnTypeInfoFor(dt arrow.DataType) ColumnTypeInfo {
}
}

// LogicalGeospatialType returns the top-level Databricks geospatial family
// recorded by the kernel on an Arrow field. The physical value is UTF-8 in
// string mode and a struct in binary mode, so DataType alone cannot preserve
// this database type name.
func LogicalGeospatialType(field arrow.Field) (string, bool) {
name, ok := field.Metadata.GetValue(databricksTypeNameMetadataKey)
if !ok {
return "", false
}
switch strings.ToUpper(strings.TrimSpace(name)) {
case "GEOMETRY":
return "GEOMETRY", true
case "GEOGRAPHY":
return "GEOGRAPHY", true
default:
return "", false
}
}

// IsBinaryGeospatialField reports whether field is a logical top-level
// GEOMETRY / GEOGRAPHY using the kernel's struct representation. The scanner
// performs the detailed child-shape validation when it reads a non-null value.
func IsBinaryGeospatialField(field arrow.Field) bool {
_, geospatial := LogicalGeospatialType(field)
return geospatial && field.Type.ID() == arrow.STRUCT
}

// ColumnTypeInfoForField preserves logical GEOMETRY / GEOGRAPHY metadata while
// selecting a scan type that matches the requested physical representation.
// Other fields retain the Arrow-only mapping.
func ColumnTypeInfoForField(field arrow.Field) ColumnTypeInfo {
name, geospatial := LogicalGeospatialType(field)
if !geospatial {
return ColumnTypeInfoFor(field.Type)
}
if field.Type.ID() == arrow.STRUCT {
return varLen(name, scanTypeRawBytes)
}
if field.Type.ID() == arrow.STRING || field.Type.ID() == arrow.LARGE_STRING {
return varLen(name, scanTypeString)
}
info := ColumnTypeInfoFor(field.Type)
info.DatabaseTypeName = name
return info
}

// varLen builds a ColumnTypeInfo for a variable-length type, reporting the
// unbounded length (math.MaxInt64) the Thrift path uses for such columns.
func varLen(name string, scan reflect.Type) ColumnTypeInfo {
Expand Down
37 changes: 37 additions & 0 deletions internal/arrowscan/coltype_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,40 @@ func TestColumnTypeInfoScanTypeCoversScanner(t *testing.T) {
}
}
}

func TestColumnTypeInfoForFieldPreservesGeospatialType(t *testing.T) {
raw := reflect.TypeOf(sql.RawBytes{})
str := reflect.TypeOf("")
metadata := arrow.MetadataFrom(map[string]string{
"databricks.type_name": "geometry",
"databricks.type_text": "GEOMETRY(ANY)",
})
geoStruct := arrow.StructOf(
arrow.Field{Name: "srid", Type: arrow.PrimitiveTypes.Int32, Nullable: false},
arrow.Field{Name: "wkb", Type: arrow.BinaryTypes.Binary, Nullable: false},
)

stringField := arrow.Field{Name: "g", Type: arrow.BinaryTypes.String, Nullable: true, Metadata: metadata}
stringInfo := ColumnTypeInfoForField(stringField)
if stringInfo.DatabaseTypeName != "GEOMETRY" || stringInfo.ScanType != str {
t.Errorf("string geo metadata = (%q, %v), want (GEOMETRY, string)", stringInfo.DatabaseTypeName, stringInfo.ScanType)
}
if IsBinaryGeospatialField(stringField) {
t.Error("UTF-8 geospatial field classified as binary")
}

binaryField := arrow.Field{Name: "g", Type: geoStruct, Nullable: true, Metadata: metadata}
binaryInfo := ColumnTypeInfoForField(binaryField)
if binaryInfo.DatabaseTypeName != "GEOMETRY" || binaryInfo.ScanType != raw {
t.Errorf("binary geo metadata = (%q, %v), want (GEOMETRY, RawBytes)", binaryInfo.DatabaseTypeName, binaryInfo.ScanType)
}
if !IsBinaryGeospatialField(binaryField) {
t.Error("logical geospatial struct was not classified as binary")
}

ordinaryStruct := arrow.Field{Name: "s", Type: geoStruct, Nullable: true}
ordinaryInfo := ColumnTypeInfoForField(ordinaryStruct)
if ordinaryInfo.DatabaseTypeName != "STRUCT" || IsBinaryGeospatialField(ordinaryStruct) {
t.Errorf("ordinary struct misclassified: %+v", ordinaryInfo)
}
}
Loading
Loading