Skip to content

Commit 2b56603

Browse files
committed
feat: support geospatial results on the kernel path
Signed-off-by: Cathleen Yan <58714163+cathleeny@users.noreply.github.com>
1 parent 01564c7 commit 2b56603

9 files changed

Lines changed: 177 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Release History
22

3+
# Unreleased
4+
- Add the kernel-only `geospatial_as_string` connection option. GEOMETRY / GEOGRAPHY results are exposed as EWKT strings when true or `{"srid": int, "wkb": bytes}` values when false.
5+
36
# 4.6.0 (2026-09-24)
47
- Upgrade Databricks SQL Kernel to 1.1.0; the kernel dependency is now stable and no longer experimental.
58
- Transparently auto-recover Thrift connections to Reyden / Real-Time warehouses: when a warehouse rejects the default Thrift protocol (SQLSTATE `KP001`), the session is re-opened on the kernel backend and the warehouse is remembered so later connections skip Thrift. Applies only when no backend was chosen explicitly.

‎CONNECTION_PARAMETERS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ to change without notice.
153153
| `max_download_threads` | `int` | ✅ | ❌ | `10` | Worker threads for cloud-fetch downloads. Not forwarded to the kernel. |
154154
| `enable_query_result_lz4_compression` | `bool` | ✅ | ❌ | `True` | LZ4-compress result payloads. Not forwarded; the kernel handles compression internally. |
155155
| `_disable_pandas` | `bool` | ✅ | ✅ | `False` | Skip the pandas-based Arrow→row deserialization and materialize rows directly with PyArrow. This is a **Python-side** result-conversion toggle, not a wire option: the kernel returns results as Arrow (`RecordBatch`es) and the connector runs the *same* `_convert_arrow_table` for both backends, so the flag is honored on the kernel path too. Affects only row fetches (`fetchone`/`fetchmany`/`fetchall`); the `fetch*_arrow` methods return the Arrow table unchanged regardless of this flag. |
156+
| `geospatial_as_string` | `bool \| None` | ❌ | ✅ | `None` (kernel default: `True`) | Return GEOMETRY / GEOGRAPHY as EWKT strings when `True`, or as `{"srid": int, "wkb": bytes}` values when `False`. `None` leaves the kernel default in force. This is a local result conversion and is never forwarded to SEA. |
156157
| `_use_arrow_native_complex_types` | `bool` | ✅ | ✅ | `True` | Return `ARRAY`/`MAP`/`STRUCT` as native Arrow types instead of JSON strings. Forwarded to the kernel. |
157158
| `_use_arrow_native_decimals` | `bool` | ✅ | ❌ | `True` | Thrift wire encoding for `DECIMAL`: `True` → native Arrow `decimal128`, `False` → Arrow string. **No value-level effect**, though: the connector unconditionally re-casts the column back to `decimal128` (`convert_decimals_in_arrow_table`, `thrift_backend.py`), so both `fetchall()` and `fetchall_arrow()` yield `Decimal` / `decimal128(p,s)` either way (verified live). Not forwarded to the kernel, which always returns native Arrow decimals. |
158159
| `_use_arrow_native_timestamps` | `bool` | ✅ | ❌ | `True` | Thrift wire encoding for `TIMESTAMP`: `True` → native Arrow timestamp (→ Python `datetime`), `False` → Arrow string (→ Python **`str`**). **Unlike decimals there is no re-cast**, so `False` genuinely surfaces strings — and `cursor.description` still reports the type code as `'timestamp'`, a mismatch to watch for (verified live). Note the connector always also sends the `spark.thriftserver.arrowBasedRowSet.timestampAsString=false` conf, but the `timestampAsArrow=False` flag wins. Not forwarded to the kernel, which always returns native Arrow timestamps. |

‎KERNEL_REV‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
80f2aee7d884994d7b0af9a9ea6078872859a9cd
1+
ad3bc6993bca95b810839feade77ccd0ab98ece5

‎src/databricks/sql/backend/kernel/client.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,29 @@ def _kernel_session_accepts_kwarg(name: str) -> bool:
169169
return name in params
170170

171171

172+
def _kernel_geospatial_kwargs(value: Optional[bool]) -> Dict[str, bool]:
173+
"""Build the optional geospatial result-representation kwarg.
174+
175+
``None`` deliberately omits the option so the installed kernel owns its
176+
default. An explicit value must never be silently ignored: older kernel
177+
wheels do not declare ``geospatial_as_string`` and would otherwise return a
178+
different public value shape than the caller requested.
179+
"""
180+
if value is None:
181+
return {}
182+
if not isinstance(value, bool):
183+
raise ValueError(
184+
"geospatial_as_string must be a bool or None; "
185+
f"got {type(value).__name__}"
186+
)
187+
if not _kernel_session_accepts_kwarg("geospatial_as_string"):
188+
raise NotSupportedError(
189+
"geospatial_as_string requires a newer databricks-sql-kernel "
190+
"wheel that exposes geospatial result representation support."
191+
)
192+
return {"geospatial_as_string": value}
193+
194+
172195
def _kernel_telemetry_kwargs(options: Dict[str, Any]) -> Dict[str, Any]:
173196
"""Build phase-7 telemetry/system kwargs for ``databricks_sql_kernel.Session``.
174197
@@ -262,6 +285,12 @@ def __init__(
262285
# The kernel binding owns type and range validation.
263286
self._request_timeout_secs = kwargs.get("request_timeout_secs")
264287
self._max_connections = kwargs.get("max_connections")
288+
# Client-side result representation for GEOMETRY / GEOGRAPHY. None
289+
# leaves the kernel default in force (EWKT strings); False requests the
290+
# canonical Arrow struct and surfaces as ``{"srid": int, "wkb":
291+
# bytes}`` through pyarrow. This is intentionally separate from
292+
# ``session_configuration``: it is never forwarded to SEA.
293+
self._geospatial_as_string = kwargs.get("geospatial_as_string")
265294
# Kernel telemetry phase 7 adds binding/runtime identity and
266295
# telemetry config kwargs directly to ``databricks_sql_kernel.Session``.
267296
self._telemetry_options = kwargs.get("telemetry_options") or {}
@@ -379,6 +408,7 @@ def open_session(
379408
# kernel's ``retry_*`` kwargs. Empty when at defaults.
380409
retry_kwargs = _kernel_retry_kwargs(self._retry_options)
381410
telemetry_kwargs = _kernel_telemetry_kwargs(self._telemetry_options)
411+
geospatial_kwargs = _kernel_geospatial_kwargs(self._geospatial_as_string)
382412
max_connections_kwargs: Dict[str, Any] = {}
383413
if _kernel_session_accepts_kwarg("max_connections"):
384414
max_connections_kwargs["max_connections"] = self._max_connections
@@ -426,6 +456,7 @@ def open_session(
426456
**tls_kwargs,
427457
**retry_kwargs,
428458
**telemetry_kwargs,
459+
**geospatial_kwargs,
429460
**max_connections_kwargs,
430461
**http_headers_kwargs,
431462
)

‎src/databricks/sql/client.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,14 @@ def __init__(
194194
decision. This is an intentional divergence from the
195195
Thrift/SEA paths, where an explicit ``True`` can still
196196
be suppressed by the feature flag.
197+
:param geospatial_as_string: `bool | None`, optional (default is None)
198+
Kernel backend only. Controls the public representation of
199+
``GEOMETRY`` and ``GEOGRAPHY`` result values. ``True`` returns
200+
EWKT strings (for example ``"SRID=4326;POINT(1 2)"``);
201+
``False`` returns ``{"srid": int, "wkb": bytes}``; and
202+
``None`` uses the kernel default (currently EWKT strings).
203+
The conversion is local to the kernel/driver and this option is
204+
never sent to the SQL Execution API.
197205
:param use_hybrid_disposition: `bool`, optional (default is False)
198206
Use the hybrid disposition instead of the inline disposition.
199207
:param server_hostname: Databricks instance host name.

‎src/databricks/sql/session.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@ def _create_backend(
314314
retry_options=kernel_retry_options,
315315
request_timeout_secs=kwargs.get("_socket_timeout"),
316316
max_connections=kwargs.get("_pool_maxsize") or None,
317+
geospatial_as_string=kwargs.get("geospatial_as_string"),
317318
telemetry_options=kernel_telemetry_options,
318319
)
319320

‎tests/unit/test_kernel_client.py‎

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,94 @@ def fake_session(**kw):
392392
assert captured["max_connections"] == max_connections
393393

394394

395+
@pytest.mark.parametrize("as_string", [True, False])
396+
def test_open_session_passes_geospatial_representation_to_kernel(
397+
monkeypatch, as_string
398+
):
399+
captured = {}
400+
401+
def fake_session(*, geospatial_as_string=None, **kw):
402+
captured["geospatial_as_string"] = geospatial_as_string
403+
sess = MagicMock()
404+
sess.session_id = "sess-id"
405+
return sess
406+
407+
monkeypatch.setattr(kernel_client._kernel, "Session", fake_session)
408+
c = kernel_client.KernelDatabricksClient(
409+
server_hostname="example.cloud.databricks.com",
410+
http_path="/sql/1.0/warehouses/abc",
411+
auth_provider=AccessTokenAuthProvider("dapi-test"),
412+
ssl_options=None,
413+
geospatial_as_string=as_string,
414+
)
415+
416+
c.open_session(session_configuration=None, catalog=None, schema=None)
417+
418+
assert captured["geospatial_as_string"] is as_string
419+
420+
421+
def test_open_session_omits_unset_geospatial_representation(monkeypatch):
422+
captured = {}
423+
424+
def fake_session(**kw):
425+
captured.update(kw)
426+
sess = MagicMock()
427+
sess.session_id = "sess-id"
428+
return sess
429+
430+
monkeypatch.setattr(kernel_client._kernel, "Session", fake_session)
431+
c = kernel_client.KernelDatabricksClient(
432+
server_hostname="example.cloud.databricks.com",
433+
http_path="/sql/1.0/warehouses/abc",
434+
auth_provider=AccessTokenAuthProvider("dapi-test"),
435+
ssl_options=None,
436+
)
437+
438+
c.open_session(session_configuration=None, catalog=None, schema=None)
439+
440+
assert "geospatial_as_string" not in captured
441+
442+
443+
def test_open_session_rejects_explicit_geospatial_option_with_old_kernel(
444+
monkeypatch,
445+
):
446+
def fake_session_without_geospatial(
447+
host,
448+
http_path,
449+
*,
450+
catalog=None,
451+
schema=None,
452+
session_conf=None,
453+
complex_types_as_json=False,
454+
intervals_as_string=False,
455+
request_timeout_secs=None,
456+
auth_type=None,
457+
access_token=None,
458+
):
459+
sess = MagicMock()
460+
sess.session_id = "sess-id"
461+
return sess
462+
463+
monkeypatch.setattr(
464+
kernel_client._kernel, "Session", fake_session_without_geospatial
465+
)
466+
c = kernel_client.KernelDatabricksClient(
467+
server_hostname="example.cloud.databricks.com",
468+
http_path="/sql/1.0/warehouses/abc",
469+
auth_provider=AccessTokenAuthProvider("dapi-test"),
470+
ssl_options=None,
471+
geospatial_as_string=False,
472+
)
473+
474+
with pytest.raises(NotSupportedError, match="newer databricks-sql-kernel"):
475+
c.open_session(session_configuration=None, catalog=None, schema=None)
476+
477+
478+
def test_geospatial_option_rejects_non_bool():
479+
with pytest.raises(ValueError, match="must be a bool or None"):
480+
kernel_client._kernel_geospatial_kwargs("false")
481+
482+
395483
def test_open_session_passes_phase_7_telemetry_kwargs_to_kernel(monkeypatch):
396484
"""Kernel telemetry phase 7 added binding/runtime identity and
397485
telemetry config kwargs to ``databricks_sql_kernel.Session``."""
@@ -589,7 +677,9 @@ def raise_value_error(_obj):
589677
kwargs = kernel_client._kernel_telemetry_kwargs(
590678
{"enable_telemetry": True, "telemetry_batch_size": 17}
591679
)
592-
assert kwargs == {}, f"expected no phase-7 kwargs when signature unreadable, got {kwargs}"
680+
assert (
681+
kwargs == {}
682+
), f"expected no phase-7 kwargs when signature unreadable, got {kwargs}"
593683

594684

595685
def test_execute_command_forwards_parameters_to_bind_param():

‎tests/unit/test_kernel_result_set.py‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,12 @@ def close(self):
4343
self.closed = True
4444

4545

46-
def _make_rs(handle) -> KernelResultSet:
46+
def _make_rs(handle, *, disable_pandas=False) -> KernelResultSet:
4747
# The base ResultSet __init__ takes a `connection` ref it never
4848
# actually dereferences during these buffer tests, so a Mock is
4949
# fine.
5050
connection = MagicMock()
51+
connection.disable_pandas = disable_pandas
5152
backend = MagicMock()
5253
return KernelResultSet(
5354
connection=connection,
@@ -94,6 +95,43 @@ def test_fetchall_arrow_drains_all_batches(int_schema):
9495
assert rs.has_more_rows is False
9596

9697

98+
def test_geospatial_string_and_binary_values_keep_logical_type():
99+
wkb = bytes.fromhex("0101000000000000000000F03F0000000000000040")
100+
geo_metadata = {
101+
b"databricks.type_name": b"GEOMETRY",
102+
b"databricks.type_text": b"GEOMETRY(ANY)",
103+
}
104+
105+
string_schema = pa.schema([pa.field("g", pa.string(), metadata=geo_metadata)])
106+
string_batch = pa.RecordBatch.from_arrays(
107+
[pa.array(["SRID=4326;POINT(1 2)", None], type=pa.string())],
108+
schema=string_schema,
109+
)
110+
string_rows = _make_rs(
111+
_FakeKernelHandle(string_schema, [string_batch]), disable_pandas=True
112+
).fetchall()
113+
assert [row[0] for row in string_rows] == ["SRID=4326;POINT(1 2)", None]
114+
115+
binary_type = pa.struct(
116+
[
117+
pa.field("srid", pa.int32(), nullable=False),
118+
pa.field("wkb", pa.binary(), nullable=False),
119+
]
120+
)
121+
binary_schema = pa.schema([pa.field("g", binary_type, metadata=geo_metadata)])
122+
binary_batch = pa.RecordBatch.from_arrays(
123+
[pa.array([{"srid": 4326, "wkb": wkb}, None], type=binary_type)],
124+
schema=binary_schema,
125+
)
126+
binary_rs = _make_rs(
127+
_FakeKernelHandle(binary_schema, [binary_batch]), disable_pandas=True
128+
)
129+
assert binary_rs.description[0][1] == "geometry"
130+
binary_rows = binary_rs.fetchall()
131+
assert binary_rows[0][0] == {"srid": 4326, "wkb": wkb}
132+
assert binary_rows[1][0] is None
133+
134+
97135
def test_fetchmany_arrow_slices_within_batch(int_schema):
98136
handle = _FakeKernelHandle(int_schema, [_batch(int_schema, [10, 20, 30, 40])])
99137
rs = _make_rs(handle)

‎tests/unit/test_session.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,7 @@ def test_retry_and_socket_timeout_threaded_into_kernel_client(self):
476476
_retry_stop_after_attempts_duration=600.0,
477477
_socket_timeout=12.5,
478478
_pool_maxsize=41,
479+
geospatial_as_string=False,
479480
)
480481
try:
481482
_, kwargs = mock_kernel_client.call_args
@@ -486,6 +487,7 @@ def test_retry_and_socket_timeout_threaded_into_kernel_client(self):
486487
assert opts["retry_stop_after_attempts_duration"] == 600.0
487488
assert kwargs["request_timeout_secs"] == 12.5
488489
assert kwargs["max_connections"] == 41
490+
assert kwargs["geospatial_as_string"] is False
489491
finally:
490492
conn.close()
491493

0 commit comments

Comments
 (0)