Skip to content

Commit 6ef2a7e

Browse files
committed
fix: register dimensions without coordinates as columns (closes #203)
A Dataset can have "dimensions without coordinates" (e.g. an image's channel/height/width axes). These are absent from ds.coords, so _parse_schema dropped them from the SQL schema and SELECT * returned too few columns. Even if they were added, slicing a block with isel synthesizes their index relative to the block (restarting at 0 per partition), which would corrupt values on chunked reads. Materialise a default integer index coordinate for every coordinate-less dimension once, at the reader entry points (read_xarray_table and XarrayRecordBatchReader), via a new ensure_default_indexes() helper. This turns them into ordinary dimension coordinates so the existing schema, record-batch, filter-pushdown and round-trip machinery handles them correctly and they carry their absolute position through chunked reads. Adds regression tests for the schema, both record-batch builders (chunked along a coordinate-less dim), and the from_dataset query path.
1 parent f085271 commit 6ef2a7e

4 files changed

Lines changed: 218 additions & 0 deletions

File tree

tests/test_df.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
block_slices,
1313
compute_chunks,
1414
dataset_to_record_batch,
15+
ensure_default_indexes,
1516
explode,
1617
from_map,
1718
from_map_batched,
@@ -244,6 +245,84 @@ def test_dataset_to_record_batch_row_count(air_small):
244245
assert batch.num_rows == expected_rows
245246

246247

248+
def _coordless_ds():
249+
"""A 2-D dataset whose dimensions have NO coordinates (issue #203)."""
250+
return xr.Dataset(
251+
{"a": (("x", "y"), np.arange(6, dtype="float32").reshape(3, 2))}
252+
)
253+
254+
255+
def test_ensure_default_indexes_materializes_missing_coords():
256+
"""ensure_default_indexes gives coordinate-less dims a 0..N-1 index and
257+
leaves already-coordinated dims untouched (issue #203)."""
258+
ds = _coordless_ds()
259+
assert "x" not in ds.coords and "y" not in ds.coords
260+
261+
fixed = ensure_default_indexes(ds)
262+
assert list(fixed.coords["x"].values) == [0, 1, 2]
263+
assert list(fixed.coords["y"].values) == [0, 1]
264+
265+
already = xr.Dataset(
266+
{"a": (("x",), np.arange(3))}, coords={"x": [10, 11, 12]}
267+
)
268+
assert list(ensure_default_indexes(already).coords["x"].values) == [
269+
10,
270+
11,
271+
12,
272+
]
273+
274+
275+
def test_parse_schema_includes_coordless_dims_after_materialize():
276+
"""Once default indexes are materialised, every dimension is a column."""
277+
schema = _parse_schema(ensure_default_indexes(_coordless_ds()))
278+
assert set(schema.names) == {"x", "y", "a"}
279+
# Coordinate-less dims fall back to xarray's default integer index.
280+
assert pa.types.is_integer(schema.field("x").type)
281+
assert pa.types.is_integer(schema.field("y").type)
282+
283+
284+
def test_record_batch_paths_carry_absolute_coordless_index():
285+
"""With materialised indexes, both record-batch builders emit the ABSOLUTE
286+
0..N-1 position for a coordinate-less dim, even when that dim is chunked so
287+
each block is sliced separately (issue #203)."""
288+
ds = ensure_default_indexes(_coordless_ds())
289+
schema = _parse_schema(ds)
290+
291+
# Chunk along the coordinate-less 'x' dim: each block is a separate slice,
292+
# which is exactly where a block-relative index would corrupt the values.
293+
frames_iter, frames_single = [], []
294+
for block in block_slices(ds, chunks={"x": 1}):
295+
ds_block = ds.isel(block)
296+
frames_iter.append(
297+
pa.Table.from_batches(
298+
list(iter_record_batches(ds_block, schema))
299+
).to_pandas()
300+
)
301+
frames_single.append(
302+
dataset_to_record_batch(ds_block, schema).to_pandas()
303+
)
304+
305+
got_iter = (
306+
pd.concat(frames_iter).sort_values(["x", "y"]).reset_index(drop=True)
307+
)
308+
got_single = (
309+
pd.concat(frames_single).sort_values(["x", "y"]).reset_index(drop=True)
310+
)
311+
pd.testing.assert_frame_equal(got_iter, got_single)
312+
313+
# Absolute (not block-relative) index: x spans 0..2 across the 3 chunks.
314+
assert sorted(got_iter["x"].unique().tolist()) == [0, 1, 2]
315+
assert sorted(got_iter["y"].unique().tolist()) == [0, 1]
316+
317+
expected = (
318+
ds.to_dataframe()
319+
.reset_index()
320+
.sort_values(["x", "y"])
321+
.reset_index(drop=True)
322+
)
323+
pd.testing.assert_frame_equal(got_iter, expected, check_dtype=False)
324+
325+
247326
def test_from_map_batched_basic_functionality(air_small):
248327
blocks = list(
249328
block_slices(air_small, chunks={"time": 4, "lat": 3, "lon": 4})

tests/test_sql.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,3 +488,116 @@ def test_table_names_is_keyword_only(self, mixed_ds):
488488
ctx = XarrayContext()
489489
with pytest.raises(TypeError):
490490
ctx.from_dataset("era5", mixed_ds, {("time",): "x"})
491+
492+
@pytest.fixture
493+
def coordless_dims_ds(self):
494+
"""Mirror the fashion-mnist layout from issue #203: a dimension
495+
coordinate (``sample``) alongside dimensions without coordinates
496+
(``channel``/``height``/``width``)."""
497+
n_sample, n_channel, n_height, n_width = 4, 1, 3, 3
498+
return xr.Dataset(
499+
{
500+
"images": (
501+
["sample", "channel", "height", "width"],
502+
np.arange(
503+
n_sample * n_channel * n_height * n_width,
504+
dtype="float32",
505+
).reshape(n_sample, n_channel, n_height, n_width),
506+
),
507+
"labels": (["sample"], np.arange(n_sample, dtype="int64")),
508+
},
509+
coords={"sample": ("sample", np.arange(n_sample, dtype="int64"))},
510+
).chunk({"sample": 1})
511+
512+
def test_coordless_dims_appear_as_columns(self, coordless_dims_ds):
513+
"""Regression for #203: dimensions without coordinates must still be
514+
emitted as columns, not silently dropped from the schema."""
515+
ctx = XarrayContext()
516+
ctx.from_dataset(
517+
"mnist",
518+
coordless_dims_ds,
519+
table_names={
520+
("sample", "channel", "height", "width"): "X",
521+
("sample",): "y",
522+
},
523+
)
524+
result = ctx.sql('SELECT * FROM mnist."X"').to_pandas()
525+
assert set(result.columns) == {
526+
"sample",
527+
"channel",
528+
"height",
529+
"width",
530+
"images",
531+
}
532+
533+
def test_coordless_dims_values_match_xarray(self, coordless_dims_ds):
534+
"""The X table's rows must match xarray's own pivot exactly, including
535+
the synthesized index values for the coordinate-less dimensions."""
536+
ctx = XarrayContext()
537+
ctx.from_dataset(
538+
"mnist",
539+
coordless_dims_ds,
540+
table_names={
541+
("sample", "channel", "height", "width"): "X",
542+
("sample",): "y",
543+
},
544+
)
545+
dim_cols = ["sample", "channel", "height", "width"]
546+
result = (
547+
ctx.sql('SELECT * FROM mnist."X"')
548+
.to_pandas()
549+
.sort_values(dim_cols)
550+
.reset_index(drop=True)
551+
)
552+
expected = (
553+
coordless_dims_ds[["images"]]
554+
.to_dataframe()
555+
.reset_index()
556+
.sort_values(dim_cols)
557+
.reset_index(drop=True)
558+
)
559+
pd.testing.assert_frame_equal(
560+
result, expected, check_dtype=False, check_like=True
561+
)
562+
563+
def test_coordless_dims_y_table_unaffected(self, coordless_dims_ds):
564+
"""The 1-D ``y`` group (sample coordinate + labels) is unchanged."""
565+
ctx = XarrayContext()
566+
ctx.from_dataset(
567+
"mnist",
568+
coordless_dims_ds,
569+
table_names={
570+
("sample", "channel", "height", "width"): "X",
571+
("sample",): "y",
572+
},
573+
)
574+
result = ctx.sql('SELECT * FROM mnist."y"').to_pandas()
575+
assert set(result.columns) == {"sample", "labels"}
576+
assert len(result) == coordless_dims_ds.sizes["sample"]
577+
578+
def test_single_table_all_coordless_dims(self):
579+
"""A uniform-dim dataset whose dims lack coordinates registers as one
580+
table with every dimension present as a column, and the coordinate-less
581+
dimensions carry their ABSOLUTE index even when chunked (issue #203)."""
582+
ds = xr.Dataset(
583+
{"a": (("x", "y"), np.arange(6, dtype="float32").reshape(3, 2))}
584+
).chunk({"x": 1}) # chunked along the coordinate-less 'x' dim
585+
ctx = XarrayContext()
586+
ctx.from_dataset("grid", ds)
587+
result = (
588+
ctx.sql("SELECT * FROM grid")
589+
.to_pandas()
590+
.sort_values(["x", "y"])
591+
.reset_index(drop=True)
592+
)
593+
assert set(result.columns) == {"x", "y", "a"}
594+
# x must span 0..2 across the three chunks, not restart at 0 each block.
595+
expected = (
596+
ds.to_dataframe()
597+
.reset_index()
598+
.sort_values(["x", "y"])
599+
.reset_index(drop=True)
600+
)
601+
pd.testing.assert_frame_equal(
602+
result, expected, check_dtype=False, check_like=True
603+
)

xarray_sql/df.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,24 @@ def resolve_chunks(
7373
return {d: tuple(c) for d, c in ds.chunks.items()}
7474

7575

76+
def ensure_default_indexes(ds: xr.Dataset) -> xr.Dataset:
77+
"""Attach a default integer index coordinate to every dimension lacking one.
78+
79+
xarray allows "dimensions without coordinates"; these are absent from
80+
``ds.coords``, so they are dropped from the SQL schema and, once a block is
81+
sliced out with ``isel``, their position is synthesized *relative to the
82+
block* (restarting at 0 in every partition). Materialising an explicit
83+
``arange`` index up front turns them into ordinary dimension coordinates, so
84+
they appear as columns and carry their absolute position through chunked
85+
reads (issue #203). Datasets whose dimensions already have coordinates are
86+
returned unchanged.
87+
"""
88+
missing = {
89+
dim: np.arange(ds.sizes[dim]) for dim in ds.dims if dim not in ds.coords
90+
}
91+
return ds.assign_coords(missing) if missing else ds
92+
93+
7694
def _block_slices_from_resolved(
7795
ds: xr.Dataset, resolved: Mapping[Hashable, tuple[int, ...]]
7896
) -> Iterator[Block]:
@@ -371,6 +389,11 @@ def iter_record_batches(
371389
def _parse_schema(ds: xr.Dataset) -> pa.Schema:
372390
"""Extracts a `pa.Schema` from the Dataset, treating dims and data_vars as columns.
373391
392+
Only *dimension coordinates* become dimension columns, so a dimension
393+
without a coordinate would be dropped. Callers must run the Dataset through
394+
:func:`ensure_default_indexes` first (the readers do) so every dimension has
395+
a coordinate and appears as a column (issue #203).
396+
374397
Uses the xarray index type to detect cftime coordinates without
375398
materializing their data — important for Dask/Zarr-backed datasets
376399
where .values would trigger eager computation.

xarray_sql/reader.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
_block_slices_from_resolved,
2727
_parse_schema,
2828
block_slices,
29+
ensure_default_indexes,
2930
iter_record_batches,
3031
resolve_chunks,
3132
)
@@ -84,6 +85,7 @@ def __init__(
8485
each block dict just before it's converted to Arrow. This
8586
allows tests to track when iteration actually occurs.
8687
"""
88+
ds = ensure_default_indexes(ds)
8789
self._ds = ds
8890
self._chunks = chunks
8991
self._batch_size = batch_size
@@ -254,6 +256,7 @@ def read_xarray_table(
254256
"""
255257
from ._native import LazyArrowStreamTable
256258

259+
ds = ensure_default_indexes(ds)
257260
schema = _parse_schema(ds)
258261

259262
# Hoist coordinate reads once; avoids N_partitions remote I/O calls for

0 commit comments

Comments
 (0)