Skip to content

Commit 5ee15c3

Browse files
Keep ARRAY results as numpy.ndarray and skip pandas for nested columns
Nested columns are converted from Arrow directly, so they are no longer converted through pandas and then replaced. ARRAY values stay numpy.ndarray, the documented type: native dtype when the elements are float, or integer/boolean without NULLs; object dtype with exact int and None when integer elements contain NULL (previously float64 with NaN). MAP stays a list of tuples and STRUCT a dict, with the same conversion applied to their nested values. Signed-off-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
1 parent 7f4ccd1 commit 5ee15c3

3 files changed

Lines changed: 196 additions & 23 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+
- Fix: with pandas enabled (the default), complex-type values no longer lose precision: an ARRAY of integers containing a NULL (at any nesting level, including inside MAP and STRUCT values) is returned as an object `numpy.ndarray` of exact `int`/`None` instead of float64 with NaN, and nested columns are converted from Arrow directly instead of through pandas. Return types are unchanged: ARRAY is a `numpy.ndarray`, MAP a list of tuples, STRUCT a dict.
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.

‎src/databricks/sql/result_set.py‎

Lines changed: 104 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import List, Optional, TYPE_CHECKING, Tuple
55

66
import logging
7+
import numpy
78
import pandas
89

910
try:
@@ -121,36 +122,46 @@ def _convert_arrow_table(self, table):
121122
pyarrow.string(): pandas.StringDtype(),
122123
}
123124

124-
# Need to rename columns, as the to_pandas function cannot handle duplicate column names
125-
table_renamed = table.rename_columns([str(c) for c in range(table.num_columns)])
126-
df = table_renamed.to_pandas(
127-
types_mapper=dtype_mapping.get,
128-
date_as_object=True,
129-
timestamp_as_object=True,
130-
)
131-
132-
res = df.to_numpy(na_value=None, dtype="object")
133-
134-
# pandas converts nested (list/map/struct) values through numpy, which
135-
# turns e.g. ARRAY<BIGINT> with a NULL element into float64 (precision
136-
# loss beyond 2**53, NULL as NaN). Take nested columns straight from
137-
# Arrow, as the disable_pandas path does.
125+
# Nested (ARRAY/MAP/STRUCT) columns are converted from Arrow directly
126+
# and never pass through pandas: pandas turns an integer array
127+
# containing a NULL into float64 (precision loss beyond 2**53, NULL as
128+
# NaN). See _nested_column_to_python for the returned shapes.
138129
nested = {
139-
index: table.column(index).to_pylist()
130+
index: _nested_column_to_python(table.column(index))
140131
for index, field in enumerate(table.schema)
141132
if pyarrow.types.is_nested(field.type)
142133
}
134+
scalar_indices = [i for i in range(table.num_columns) if i not in nested]
135+
136+
res = None
137+
if scalar_indices:
138+
scalar_table = table.select(scalar_indices) if nested else table
139+
# Need to rename columns, as the to_pandas function cannot handle duplicate column names
140+
scalar_table = scalar_table.rename_columns(
141+
[str(c) for c in range(scalar_table.num_columns)]
142+
)
143+
df = scalar_table.to_pandas(
144+
types_mapper=dtype_mapping.get,
145+
date_as_object=True,
146+
timestamp_as_object=True,
147+
)
148+
res = df.to_numpy(na_value=None, dtype="object")
149+
143150
if not nested:
144151
return [ResultRow(*v) for v in res]
145-
return [
146-
ResultRow(
147-
*[
148-
nested[index][position] if index in nested else value
149-
for index, value in enumerate(row)
150-
]
152+
153+
rows = []
154+
for position in range(table.num_rows):
155+
scalars = iter(res[position]) if res is not None else iter(())
156+
rows.append(
157+
ResultRow(
158+
*[
159+
nested[index][position] if index in nested else next(scalars)
160+
for index in range(table.num_columns)
161+
]
162+
)
151163
)
152-
for position, row in enumerate(res)
153-
]
164+
return rows
154165

155166
@property
156167
def rownumber(self):
@@ -491,3 +502,73 @@ def map_col_type(type_):
491502
(column.name, map_col_type(column.datatype), None, None, None, None, None)
492503
for column in table_schema_message.columns
493504
]
505+
506+
507+
def _object_array(values: list) -> "numpy.ndarray":
508+
"""A 1-D object ndarray holding ``values`` as-is (no numpy broadcasting)."""
509+
out = numpy.empty(len(values), dtype=object)
510+
for i, value in enumerate(values):
511+
out[i] = value
512+
return out
513+
514+
515+
def _arrow_array_to_python(array) -> list:
516+
"""Convert one Arrow array to a list of Python values, one per slot.
517+
518+
Shapes match what the pandas conversion has always returned for complex
519+
types (see ``_use_arrow_native_complex_types``): ARRAY is a
520+
``numpy.ndarray``, MAP is a list of ``(key, value)`` tuples, STRUCT is a
521+
dict, NULL is None. The difference is that integer/boolean array elements
522+
are exact: an ARRAY<BIGINT> without NULLs is an int64 ndarray as before,
523+
and one with NULLs is an object ndarray of ``int``/``None`` instead of a
524+
float64 ndarray with NaN.
525+
"""
526+
t = array.type
527+
if pyarrow.types.is_map(t):
528+
result: list = []
529+
for scalar in array:
530+
if not scalar.is_valid:
531+
result.append(None)
532+
continue
533+
keys, items = scalar.values.flatten()
534+
result.append(
535+
list(zip(_arrow_array_to_python(keys), _arrow_array_to_python(items)))
536+
)
537+
return result
538+
if (
539+
pyarrow.types.is_list(t)
540+
or pyarrow.types.is_large_list(t)
541+
or pyarrow.types.is_fixed_size_list(t)
542+
):
543+
return [
544+
_arrow_list_values_to_ndarray(scalar.values) if scalar.is_valid else None
545+
for scalar in array
546+
]
547+
if pyarrow.types.is_struct(t):
548+
names = [t.field(i).name for i in range(t.num_fields)]
549+
children = [_arrow_array_to_python(child) for child in array.flatten()]
550+
return [
551+
dict(zip(names, (child[i] for child in children))) if valid else None
552+
for i, valid in enumerate(array.is_valid().to_pylist())
553+
]
554+
return array.to_pylist()
555+
556+
557+
def _arrow_list_values_to_ndarray(values) -> "numpy.ndarray":
558+
"""The elements of one ARRAY value as a numpy.ndarray."""
559+
t = values.type
560+
if pyarrow.types.is_floating(t) or (
561+
(pyarrow.types.is_integer(t) or pyarrow.types.is_boolean(t))
562+
and values.null_count == 0
563+
):
564+
# Native dtype, as pandas produced; floats keep NaN for NULL.
565+
return values.to_numpy(zero_copy_only=False, writable=True)
566+
return _object_array(_arrow_array_to_python(values))
567+
568+
569+
def _nested_column_to_python(column) -> list:
570+
"""Convert a (chunked) nested Arrow column to one Python value per row."""
571+
result: list = []
572+
for chunk in column.chunks:
573+
result.extend(_arrow_array_to_python(chunk))
574+
return result

‎tests/unit/test_pandas_compatibility.py‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from decimal import Decimal
1212
from unittest.mock import Mock
1313

14+
import numpy
1415
import pandas
1516
import pytest
1617

@@ -308,10 +309,98 @@ def test_list_of_bigint_with_null_is_exact(self):
308309

309310
rows = _make_result_set(description)._convert_arrow_table(table)
310311

312+
# Still a numpy.ndarray (the documented ARRAY type), but object dtype
313+
# holding exact ints and None rather than float64 with NaN.
314+
self.assertIsInstance(rows[0].list_col, numpy.ndarray)
311315
values = list(rows[0].list_col)
312316
self.assertEqual(values, [big, -9223372036854775808, None])
313317
self.assertTrue(all(type(v) is int for v in values[:2]))
314318

319+
def test_list_without_null_keeps_native_dtype(self):
320+
table = pa.table(
321+
{
322+
"ints": pa.array([[1, 2], []], type=pa.list_(pa.int64())),
323+
"floats": pa.array([[1.5, None], [2.0]], type=pa.list_(pa.float64())),
324+
"strings": pa.array([["a", "b"], ["c"]], type=pa.list_(pa.string())),
325+
}
326+
)
327+
description = [
328+
("ints", "array", None, None, None, None, None),
329+
("floats", "array", None, None, None, None, None),
330+
("strings", "array", None, None, None, None, None),
331+
]
332+
333+
rows = _make_result_set(description)._convert_arrow_table(table)
334+
335+
self.assertIsInstance(rows[0].ints, numpy.ndarray)
336+
self.assertEqual(rows[0].ints.dtype, numpy.int64)
337+
self.assertEqual(rows[1].ints.tolist(), [])
338+
self.assertEqual(rows[0].floats.dtype, numpy.float64)
339+
self.assertTrue(numpy.isnan(rows[0].floats[1]))
340+
self.assertIsInstance(rows[1].strings, numpy.ndarray)
341+
self.assertEqual(rows[1].strings.dtype, object)
342+
self.assertEqual(rows[0].strings.tolist(), ["a", "b"])
343+
344+
def test_nested_complex_values_are_exact(self):
345+
big = 9007199254740993
346+
table = pa.table(
347+
{
348+
"id": pa.array([1, 2], type=pa.int64()),
349+
"array_array": pa.array(
350+
[[[big, None], [1]], None], type=pa.list_(pa.list_(pa.int64()))
351+
),
352+
"map_array": pa.array(
353+
[[("a", [big, None])], [("b", None)]],
354+
type=pa.map_(pa.string(), pa.list_(pa.int64())),
355+
),
356+
"struct_col": pa.array(
357+
[{"x": big, "y": [None, 2]}, {"x": None, "y": None}],
358+
type=pa.struct([("x", pa.int64()), ("y", pa.list_(pa.int64()))]),
359+
),
360+
"name": pa.array(["a", None], type=pa.string()),
361+
}
362+
)
363+
description = [
364+
(name, "t", None, None, None, None, None) for name in table.column_names
365+
]
366+
367+
rows = _make_result_set(description)._convert_arrow_table(table)
368+
369+
self.assertEqual([r.id for r in rows], [1, 2])
370+
self.assertEqual([r.name for r in rows], ["a", None])
371+
372+
outer = rows[0].array_array
373+
self.assertIsInstance(outer, numpy.ndarray)
374+
self.assertIsInstance(outer[0], numpy.ndarray)
375+
self.assertEqual(outer[0].tolist(), [big, None])
376+
self.assertEqual(outer[1].dtype, numpy.int64)
377+
self.assertIsNone(rows[1].array_array)
378+
379+
((key, value),) = rows[0].map_array
380+
self.assertEqual(key, "a")
381+
self.assertIsInstance(value, numpy.ndarray)
382+
self.assertEqual(value.tolist(), [big, None])
383+
self.assertEqual(rows[1].map_array, [("b", None)])
384+
385+
self.assertEqual(rows[0].struct_col["x"], big)
386+
self.assertEqual(rows[0].struct_col["y"].tolist(), [None, 2])
387+
self.assertEqual(rows[1].struct_col, {"x": None, "y": None})
388+
389+
def test_only_nested_columns_across_chunks(self):
390+
list_type = pa.list_(pa.int64())
391+
column = pa.chunked_array(
392+
[pa.array([[1]], list_type), pa.array([None, [2, None]], list_type)]
393+
)
394+
table = pa.table({"list_col": column})
395+
description = [("list_col", "array", None, None, None, None, None)]
396+
397+
rows = _make_result_set(description)._convert_arrow_table(table)
398+
399+
self.assertEqual(len(rows), 3)
400+
self.assertEqual(rows[0].list_col.tolist(), [1])
401+
self.assertIsNone(rows[1].list_col)
402+
self.assertEqual(rows[2].list_col.tolist(), [2, None])
403+
315404
def test_struct_type(self):
316405
table = pa.table(
317406
{

0 commit comments

Comments
 (0)