diff --git a/tests/test_sql.py b/tests/test_sql.py index 08bde14..b4e2bfd 100644 --- a/tests/test_sql.py +++ b/tests/test_sql.py @@ -1,5 +1,6 @@ """SQL functionality tests for xarray-sql using pytest.""" +import numpy as np import pytest import xarray as xr @@ -127,3 +128,21 @@ def test_cross_join(air_and_stations): "SELECT COUNT(*) AS total FROM air_data CROSS JOIN stations" ).to_pandas() assert result["total"].iloc[0] > 0 + + +def test_string_coordinates(): + """String-typed coordinates should not crash during registration.""" + ds = xr.Dataset( + {"score": (["student", "subject"], np.random.rand(3, 2))}, + coords={ + "student": ["alice", "bob", "charlie"], + "subject": ["math", "science"], + }, + ) + ctx = XarrayContext() + ctx.from_dataset("scores", ds.chunk({"student": 3, "subject": 2})) + result = ctx.sql("SELECT * FROM scores").to_pandas() + assert len(result) == 6 + assert "student" in result.columns + assert "subject" in result.columns + assert "score" in result.columns diff --git a/xarray_sql/df.py b/xarray_sql/df.py index 756de20..0945f06 100644 --- a/xarray_sql/df.py +++ b/xarray_sql/df.py @@ -323,25 +323,31 @@ def _block_metadata(coord_arrays: dict, block: Block) -> PartitionBounds: ranges: PartitionBounds = {} for dim, slc in block.items(): coord_values = coord_arrays[str(dim)][slc] - if len(coord_values) > 0: - # Use actual min/max rather than first/last so that non-monotonic - # coordinate axes (e.g. descending latitude 90→-90) are handled - # correctly. np.min/max work for both numeric and datetime64 arrays. - min_val = coord_values.min() - max_val = coord_values.max() - - if isinstance(min_val, (np.datetime64, pd.Timestamp)): - min_val = int(pd.Timestamp(min_val).value) - max_val = int(pd.Timestamp(max_val).value) - ranges[str(dim)] = (min_val, max_val, "timestamp_ns") - elif hasattr(min_val, "item"): - min_val = min_val.item() - max_val = max_val.item() - dtype = "float64" if isinstance(min_val, float) else "int64" - ranges[str(dim)] = (min_val, max_val, dtype) - else: - dtype = "float64" if isinstance(min_val, float) else "int64" - ranges[str(dim)] = (min_val, max_val, dtype) + if len(coord_values) == 0: + continue + # String/object dtypes are not representable as ScalarBound + # (Int64/Float64/TimestampNanos) and numpy min/max ufuncs do not + # support them. Skip so pruning treats the dimension conservatively. + if coord_values.dtype.kind in ("U", "S", "O"): + continue + # Use actual min/max rather than first/last so that non-monotonic + # coordinate axes (e.g. descending latitude 90→-90) are handled + # correctly. np.min/max work for both numeric and datetime64 arrays. + min_val = coord_values.min() + max_val = coord_values.max() + + if isinstance(min_val, (np.datetime64, pd.Timestamp)): + min_val = int(pd.Timestamp(min_val).value) + max_val = int(pd.Timestamp(max_val).value) + ranges[str(dim)] = (min_val, max_val, "timestamp_ns") + elif hasattr(min_val, "item"): + min_val = min_val.item() + max_val = max_val.item() + dtype = "float64" if isinstance(min_val, float) else "int64" + ranges[str(dim)] = (min_val, max_val, dtype) + else: + dtype = "float64" if isinstance(min_val, float) else "int64" + ranges[str(dim)] = (min_val, max_val, dtype) return ranges