Skip to content

Commit 5b0df34

Browse files
ghostiee-11alxmrs
authored andcommitted
fix: register dimensions without coordinates as columns (#216)
Dimensions without coordinates (e.g. an image's channel/height/width axes) were dropped from the SQL schema, so `SELECT *` returned too few columns (2 instead of 5 in the #203 repro). This materialises a default integer index for each coordinate-less dimension at the reader boundary, so they show up as columns and keep their absolute position through chunked reads. Adds regression tests. Closes #203. ### Before / after (issue repro) Before (main), 2 columns: ``` sample images 1 9.0 1 10.0 1 11.0 columns: ['sample', 'images'] ``` After (this PR), 5 columns, values verified against xarray: ``` sample channel height width images 4 0 0 0 36.0 4 0 0 1 37.0 4 0 0 2 38.0 columns: ['sample', 'channel', 'height', 'width', 'images'] values equal xarray to_dataframe(): True ``` Full non-integration test suite passes locally. (cherry picked from commit d8ffa52)
1 parent 3b365f6 commit 5b0df34

3 files changed

Lines changed: 139 additions & 0 deletions

File tree

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: 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+
"""Dimensions without coordinates must still be emitted as columns,
514+
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."""
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. Datasets whose dimensions already have coordinates are returned
86+
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
395+
has a coordinate and appears as a column.
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
@@ -23,6 +23,7 @@
2323
DEFAULT_BATCH_SIZE,
2424
_block_metadata,
2525
_block_slices_from_resolved,
26+
_ensure_default_indexes,
2627
_parse_schema,
2728
block_slices,
2829
iter_record_batches,
@@ -184,6 +185,7 @@ def read_xarray(ds: xr.Dataset, chunks: Chunks = None) -> pa.RecordBatchReader:
184185
A PyArrow RecordBatchReader, which is a table representation of the input
185186
Dataset.
186187
"""
188+
ds = _ensure_default_indexes(ds)
187189
reader = XarrayRecordBatchReader(ds, chunks=chunks)
188190
return pa.RecordBatchReader.from_stream(reader)
189191

@@ -253,6 +255,7 @@ def read_xarray_table(
253255
"""
254256
from ._native import LazyArrowStreamTable
255257

258+
ds = _ensure_default_indexes(ds)
256259
schema = _parse_schema(ds)
257260

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

0 commit comments

Comments
 (0)