xarray_sql 0.3.0__cp310-abi3-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xarray_sql/__init__.py +12 -0
- xarray_sql/_native.pyd +0 -0
- xarray_sql/cftime.py +248 -0
- xarray_sql/core.py +49 -0
- xarray_sql/df.py +508 -0
- xarray_sql/ds.py +926 -0
- xarray_sql/reader.py +332 -0
- xarray_sql/sql.py +191 -0
- xarray_sql-0.3.0.dist-info/METADATA +354 -0
- xarray_sql-0.3.0.dist-info/RECORD +12 -0
- xarray_sql-0.3.0.dist-info/WHEEL +4 -0
- xarray_sql-0.3.0.dist-info/licenses/LICENSE +202 -0
xarray_sql/ds.py
ADDED
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
"""Reconstruct xarray Datasets from SQL query results.
|
|
2
|
+
|
|
3
|
+
The inverse of the forward Dataset-to-table pivot done by
|
|
4
|
+
:func:`xarray_sql.df.pivot`. Internally defines an :class:`XarrayDataFrame`
|
|
5
|
+
wrapper around the DataFusion ``DataFrame`` returned by
|
|
6
|
+
:meth:`XarrayContext.sql`, with a :meth:`XarrayDataFrame.to_dataset`
|
|
7
|
+
method that round-trips a query result back to ``xr.Dataset``.
|
|
8
|
+
|
|
9
|
+
Reconstruction is controlled by the ``chunks`` argument to
|
|
10
|
+
:meth:`XarrayDataFrame.to_dataset` -- the xarray idiom for tuning how a
|
|
11
|
+
result is partitioned -- rather than by reflecting on the query plan:
|
|
12
|
+
|
|
13
|
+
* **Eager** (``chunks=None``, or the default ``"inherit"`` when the
|
|
14
|
+
result keeps no multi-chunk source dimension): the plan executes
|
|
15
|
+
exactly once via ``execute_stream`` and the result is scattered into a
|
|
16
|
+
dense in-memory Dataset. This is the right default for reductions
|
|
17
|
+
(aggregations), whose results are small, and it never re-executes.
|
|
18
|
+
* **Lazy / chunked** (``chunks`` is a mapping, ``"auto"``, or
|
|
19
|
+
``"inherit"`` over a multi-chunk source dimension): data variables are
|
|
20
|
+
backed by :class:`SQLBackendArray` wrapped in
|
|
21
|
+
``xarray.core.indexing.LazilyIndexedArray`` and chunked via xarray's
|
|
22
|
+
configured chunk manager (dask, cubed, ...). Each chunk maps onto the
|
|
23
|
+
source partitions and reads its coordinate range on access by
|
|
24
|
+
translating the indexer into a DataFusion ``filter`` expression, so only
|
|
25
|
+
the requested partitions are materialized as Arrow ``RecordBatch`` es
|
|
26
|
+
and scattered into numpy.
|
|
27
|
+
|
|
28
|
+
``.compute()`` materializes the whole Dataset in memory.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import warnings
|
|
34
|
+
from collections.abc import Mapping
|
|
35
|
+
from typing import Any, Literal, cast
|
|
36
|
+
|
|
37
|
+
import numpy as np
|
|
38
|
+
import pandas as pd
|
|
39
|
+
import pyarrow as pa
|
|
40
|
+
import xarray as xr
|
|
41
|
+
from datafusion import col, literal
|
|
42
|
+
|
|
43
|
+
Sparsity = Literal["result", "template"]
|
|
44
|
+
"""Output coordinate extent for a filtered round-trip.
|
|
45
|
+
|
|
46
|
+
* ``"result"`` keeps only the dim values present in the query result, so
|
|
47
|
+
the output is sparse and equal to whatever rows came back.
|
|
48
|
+
* ``"template"`` reindexes to the registered Dataset's full coord ranges
|
|
49
|
+
and fills absent cells with ``fill_value``.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Private helpers
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _ds_var_dims(ds: xr.Dataset) -> list[str]:
|
|
59
|
+
"""Return a Dataset's data-variable dim order.
|
|
60
|
+
|
|
61
|
+
The forward path validates that all data variables share the same dims
|
|
62
|
+
tuple, so the first var's dim order is canonical. Falls back to
|
|
63
|
+
``ds.dims`` keys for empty Datasets. Always use this rather than
|
|
64
|
+
``list(ds.dims)`` when round-tripping, since the latter is in
|
|
65
|
+
canonical name order and may not match the variable's axis order.
|
|
66
|
+
"""
|
|
67
|
+
if ds.data_vars:
|
|
68
|
+
return list(next(iter(ds.data_vars.values())).dims)
|
|
69
|
+
return list(ds.dims)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _apply_template(ds: xr.Dataset, template: xr.Dataset) -> xr.Dataset:
|
|
73
|
+
"""Recover metadata that the forward SQL pivot strips.
|
|
74
|
+
|
|
75
|
+
Adds back, where unambiguous:
|
|
76
|
+
|
|
77
|
+
* Data-variable ``attrs`` and ``encoding`` for vars present in
|
|
78
|
+
``template`` (aggregation aliases like ``air_avg`` get nothing).
|
|
79
|
+
Dtype-bound encoding keys (``dtype``, ``_FillValue``,
|
|
80
|
+
``missing_value``) are intentionally dropped: SQL may have
|
|
81
|
+
changed the column's dtype (e.g. ``int16`` -> ``float64`` after
|
|
82
|
+
``AVG`` or a null-introducing filter), and reattaching the
|
|
83
|
+
source's packing would make a later ``ds.to_netcdf()`` write
|
|
84
|
+
corrupt values.
|
|
85
|
+
* Dim-coordinate dtype, where SQL upcasted (datetime is the
|
|
86
|
+
canonical case).
|
|
87
|
+
* Non-dim coordinates whose dims are all present in ``ds`` (scalar
|
|
88
|
+
coords attach as-is; vector coords use ``.sel``).
|
|
89
|
+
* Dataset-level ``attrs``.
|
|
90
|
+
|
|
91
|
+
Skipped coords are warned about once per call.
|
|
92
|
+
"""
|
|
93
|
+
out = ds.copy()
|
|
94
|
+
|
|
95
|
+
# 1. Data-var attrs / encoding for vars present in the template.
|
|
96
|
+
# Aggregation aliases absent from template intentionally inherit nothing.
|
|
97
|
+
for name in list(out.data_vars):
|
|
98
|
+
if name in template.data_vars:
|
|
99
|
+
out[name].attrs = dict(template[name].attrs)
|
|
100
|
+
# Drop dtype-bound encoding keys; SQL may have changed dtype.
|
|
101
|
+
enc = {
|
|
102
|
+
k: v
|
|
103
|
+
for k, v in template[name].encoding.items()
|
|
104
|
+
if k not in {"dtype", "_FillValue", "missing_value"}
|
|
105
|
+
}
|
|
106
|
+
out[name].encoding = enc
|
|
107
|
+
|
|
108
|
+
# 2. Restore dim-coordinate dtype when SQL changed it (e.g. datetime
|
|
109
|
+
# upcast through pyarrow / pandas) and copy the source's dim-coord
|
|
110
|
+
# attrs (``standard_name``, ``long_name``, ``units``, etc.).
|
|
111
|
+
for d in list(out.dims):
|
|
112
|
+
if d in template.coords:
|
|
113
|
+
tdt = template.coords[d].dtype
|
|
114
|
+
if out.coords[d].dtype != tdt:
|
|
115
|
+
try:
|
|
116
|
+
out = out.assign_coords({d: out.coords[d].astype(tdt)})
|
|
117
|
+
except (ValueError, TypeError):
|
|
118
|
+
pass # incompatible cast; leave as-is
|
|
119
|
+
out[d].attrs = dict(template.coords[d].attrs)
|
|
120
|
+
|
|
121
|
+
# 3. Non-dim coordinates whose dims are all present in the result.
|
|
122
|
+
out_dims = set(out.dims)
|
|
123
|
+
skipped: list[str] = []
|
|
124
|
+
for cname, coord in template.coords.items():
|
|
125
|
+
if cname in template.dims:
|
|
126
|
+
continue # dim coord; already in out
|
|
127
|
+
if not set(coord.dims) <= out_dims:
|
|
128
|
+
continue # spans dims the result lacks
|
|
129
|
+
try:
|
|
130
|
+
if not coord.dims:
|
|
131
|
+
# Scalar coord (e.g. weather_dataset.reference_time).
|
|
132
|
+
out = out.assign_coords({cname: coord})
|
|
133
|
+
else:
|
|
134
|
+
sel = {d: out.coords[d] for d in coord.dims}
|
|
135
|
+
out = out.assign_coords({cname: coord.sel(sel)})
|
|
136
|
+
except (KeyError, ValueError, TypeError):
|
|
137
|
+
skipped.append(cname)
|
|
138
|
+
|
|
139
|
+
# 4. Dataset-level attrs.
|
|
140
|
+
out.attrs = dict(template.attrs)
|
|
141
|
+
|
|
142
|
+
if skipped:
|
|
143
|
+
warnings.warn(
|
|
144
|
+
f"Could not re-attach non-dim coordinates from template: {skipped}",
|
|
145
|
+
stacklevel=3,
|
|
146
|
+
)
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _scatter_batches_to_ndarray(
|
|
151
|
+
batches: list[pa.RecordBatch],
|
|
152
|
+
dimension_columns: list[str],
|
|
153
|
+
requested: dict[str, np.ndarray],
|
|
154
|
+
var_name: str,
|
|
155
|
+
out_shape: tuple[int, ...],
|
|
156
|
+
dtype: np.dtype,
|
|
157
|
+
drop_axes: list[int],
|
|
158
|
+
) -> np.ndarray:
|
|
159
|
+
"""Convert filtered Arrow ``RecordBatch`` rows into a dense N-D numpy array.
|
|
160
|
+
|
|
161
|
+
SQL query results arrive as flat rows; xarray expects N-D arrays.
|
|
162
|
+
This bridges the two: each row carries the dim-coord values that
|
|
163
|
+
identify its cell in the output cube plus the value to write there.
|
|
164
|
+
We look up the row's N-D position by binary-searching its coord
|
|
165
|
+
values within the caller's requested coord arrays
|
|
166
|
+
(``np.searchsorted``), then scatter-write the value at that index.
|
|
167
|
+
|
|
168
|
+
Missing combinations (sparse results from filtered queries) stay as
|
|
169
|
+
``NaN`` for floating-point outputs by pre-filling the buffer; integer
|
|
170
|
+
outputs leave them as ``np.empty``-style undefined values.
|
|
171
|
+
"""
|
|
172
|
+
# NaN fill for float outputs; default for int/datetime falls through
|
|
173
|
+
# to ``np.empty``-style undefined values (but every output cell is
|
|
174
|
+
# written below for non-sparse cases).
|
|
175
|
+
out = (
|
|
176
|
+
np.full(out_shape, np.nan, dtype=dtype)
|
|
177
|
+
if np.issubdtype(dtype, np.floating)
|
|
178
|
+
else np.empty(out_shape, dtype=dtype)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# ``requested[d]`` may be in any order (callers can iselect arbitrary
|
|
182
|
+
# positions, and template coords like air_temperature.lat are descending).
|
|
183
|
+
# ``np.searchsorted`` requires ascending input, so we sort each requested
|
|
184
|
+
# array once, search there, and remap back to the original positions.
|
|
185
|
+
sorted_idx = {d: np.argsort(requested[d]) for d in dimension_columns}
|
|
186
|
+
sorted_req = {d: requested[d][sorted_idx[d]] for d in dimension_columns}
|
|
187
|
+
|
|
188
|
+
for batch in batches:
|
|
189
|
+
if batch.num_rows == 0:
|
|
190
|
+
continue
|
|
191
|
+
schema_names = batch.schema.names
|
|
192
|
+
# Build per-dim position arrays for this batch (positions within
|
|
193
|
+
# the caller's requested coord order).
|
|
194
|
+
positions = []
|
|
195
|
+
for d in dimension_columns:
|
|
196
|
+
col_arr = batch.column(schema_names.index(d))
|
|
197
|
+
vals = col_arr.to_numpy(zero_copy_only=False)
|
|
198
|
+
pos_in_sorted = np.searchsorted(sorted_req[d], vals)
|
|
199
|
+
positions.append(sorted_idx[d][pos_in_sorted])
|
|
200
|
+
value_arr = batch.column(schema_names.index(var_name)).to_numpy(
|
|
201
|
+
zero_copy_only=False
|
|
202
|
+
)
|
|
203
|
+
out[tuple(positions)] = value_arr.astype(dtype, copy=False)
|
|
204
|
+
|
|
205
|
+
if drop_axes:
|
|
206
|
+
out = np.squeeze(out, axis=tuple(drop_axes))
|
|
207
|
+
return cast(np.ndarray, out)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class SQLBackendArray(xr.backends.BackendArray):
|
|
211
|
+
"""Read-only lazy N-D array view over a DataFusion DataFrame.
|
|
212
|
+
|
|
213
|
+
Bridges xarray's lazy-indexing interface
|
|
214
|
+
(:class:`xarray.backends.BackendArray`) to a DataFusion query result,
|
|
215
|
+
so an xarray ``Dataset`` can present a SQL query as if it were a
|
|
216
|
+
materialized N-D array without actually loading any data until the
|
|
217
|
+
caller asks for it. This is the workhorse that lets
|
|
218
|
+
:meth:`XarrayDataFrame.to_dataset` return a Dataset cheaply.
|
|
219
|
+
|
|
220
|
+
On each ``__getitem__`` call, the requested xarray indexer is
|
|
221
|
+
translated into a DataFusion filter expression (``df.filter(expr)``)
|
|
222
|
+
and a column projection (``df.select(*cols)``). The filtered
|
|
223
|
+
DataFrame is consumed via ``execute_stream`` as a sequence of Arrow
|
|
224
|
+
``RecordBatch`` es and scattered into a preallocated numpy buffer,
|
|
225
|
+
so only the requested data is materialized.
|
|
226
|
+
|
|
227
|
+
Constraints and caveats:
|
|
228
|
+
|
|
229
|
+
- Read-only: there is no write path; the backend exists to surface
|
|
230
|
+
query results, not to round-trip writes into a SQL store.
|
|
231
|
+
- The underlying DataFusion ``DataFrame`` holds a reference to its
|
|
232
|
+
originating ``SessionContext``, which is not picklable. The class
|
|
233
|
+
therefore overrides ``__copy__`` and ``__deepcopy__`` to return
|
|
234
|
+
``self`` -- this is safe because the backend is read-only.
|
|
235
|
+
- ``IndexingSupport.OUTER``: ``BasicIndexer`` and ``OuterIndexer``
|
|
236
|
+
are translated to filter predicates directly; ``VectorizedIndexer``
|
|
237
|
+
paths through xarray's adapter to outer-then-gather and so still
|
|
238
|
+
works, just less efficiently.
|
|
239
|
+
|
|
240
|
+
Raises:
|
|
241
|
+
ValueError, datafusion exceptions: propagated from the
|
|
242
|
+
underlying ``df.filter().select().execute_stream()`` chain
|
|
243
|
+
if a predicate refers to a missing column, the dtype of a
|
|
244
|
+
literal is incompatible, or the execution itself fails.
|
|
245
|
+
AssertionError: from ``np.searchsorted`` mis-alignment, which
|
|
246
|
+
indicates the result contains coordinate values not present
|
|
247
|
+
in the wrapper's pre-computed coord arrays -- usually a
|
|
248
|
+
symptom of a filtered query whose coord discovery missed a
|
|
249
|
+
value.
|
|
250
|
+
|
|
251
|
+
Constructed by :func:`_build_lazy_scan`; users should not instantiate
|
|
252
|
+
this class directly.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
def __init__(
|
|
256
|
+
self,
|
|
257
|
+
inner_df: Any,
|
|
258
|
+
var_name: str,
|
|
259
|
+
dimension_columns: list[str],
|
|
260
|
+
coord_arrays: dict[str, np.ndarray],
|
|
261
|
+
shape: tuple[int, ...],
|
|
262
|
+
dtype: np.dtype,
|
|
263
|
+
) -> None:
|
|
264
|
+
self._inner_df = inner_df
|
|
265
|
+
self._var_name = var_name
|
|
266
|
+
self._dimension_columns = list(dimension_columns)
|
|
267
|
+
self._coord_arrays = coord_arrays
|
|
268
|
+
self.shape = tuple(shape)
|
|
269
|
+
self.dtype = np.dtype(dtype)
|
|
270
|
+
|
|
271
|
+
def __getitem__(self, key: Any) -> np.ndarray:
|
|
272
|
+
return cast(
|
|
273
|
+
np.ndarray,
|
|
274
|
+
xr.core.indexing.explicit_indexing_adapter(
|
|
275
|
+
key,
|
|
276
|
+
self.shape,
|
|
277
|
+
xr.core.indexing.IndexingSupport.OUTER,
|
|
278
|
+
self._raw_getitem,
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def __copy__(self) -> "SQLBackendArray":
|
|
283
|
+
# The backend is read-only; the underlying DataFusion DataFrame
|
|
284
|
+
# holds a non-picklable SessionContext reference, so sharing the
|
|
285
|
+
# same backend across a copy is both safe and necessary.
|
|
286
|
+
return self
|
|
287
|
+
|
|
288
|
+
def __deepcopy__(self, memo: dict) -> "SQLBackendArray":
|
|
289
|
+
return self
|
|
290
|
+
|
|
291
|
+
# ------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
def _raw_getitem(self, key: tuple) -> np.ndarray:
|
|
294
|
+
"""Materialize the indexed region described by *key* via DataFusion + Arrow.
|
|
295
|
+
|
|
296
|
+
``key`` is a tuple of ``int``/``slice``/1-D integer-array, one per
|
|
297
|
+
dim, in :attr:`_dimension_columns` order.
|
|
298
|
+
"""
|
|
299
|
+
requested: dict[str, np.ndarray] = {}
|
|
300
|
+
# Dims whose indexer covers the full extent (slice(None) or
|
|
301
|
+
# equivalent). For these we omit the filter predicate entirely
|
|
302
|
+
# so DataFusion doesn't have to evaluate a tautology.
|
|
303
|
+
full_dims: set[str] = set()
|
|
304
|
+
drop_axes: list[int] = []
|
|
305
|
+
for axis, (dim, k) in enumerate(
|
|
306
|
+
zip(self._dimension_columns, key, strict=True)
|
|
307
|
+
):
|
|
308
|
+
coord = self._coord_arrays[dim]
|
|
309
|
+
if isinstance(k, slice):
|
|
310
|
+
start = 0 if k.start is None else k.start
|
|
311
|
+
stop = len(coord) if k.stop is None else k.stop
|
|
312
|
+
step = 1 if k.step is None else k.step
|
|
313
|
+
requested[dim] = np.asarray(coord[start:stop:step])
|
|
314
|
+
if start == 0 and stop >= len(coord) and step == 1:
|
|
315
|
+
full_dims.add(dim)
|
|
316
|
+
elif isinstance(k, (int, np.integer)):
|
|
317
|
+
requested[dim] = np.asarray([coord[int(k)]])
|
|
318
|
+
drop_axes.append(axis)
|
|
319
|
+
else:
|
|
320
|
+
arr = np.asarray(k)
|
|
321
|
+
requested[dim] = np.asarray(coord[arr])
|
|
322
|
+
if (
|
|
323
|
+
len(arr) == len(coord)
|
|
324
|
+
and (arr == np.arange(len(coord))).all()
|
|
325
|
+
):
|
|
326
|
+
full_dims.add(dim)
|
|
327
|
+
|
|
328
|
+
out_shape = tuple(len(requested[d]) for d in self._dimension_columns)
|
|
329
|
+
if any(n == 0 for n in out_shape):
|
|
330
|
+
empty = np.empty(out_shape, dtype=self.dtype)
|
|
331
|
+
squeezed = (
|
|
332
|
+
np.squeeze(empty, axis=tuple(drop_axes)) if drop_axes else empty
|
|
333
|
+
)
|
|
334
|
+
return cast(np.ndarray, squeezed)
|
|
335
|
+
|
|
336
|
+
# Build a single DataFusion filter expression as the AND of per-dim
|
|
337
|
+
# predicates. For a single requested value: equality. For multiple:
|
|
338
|
+
# OR-chain of equalities (DataFusion 52.0.0 does not expose a clean
|
|
339
|
+
# ``Expr.in_list`` from Python; OR-chained equalities constant-fold
|
|
340
|
+
# equivalently and stay typed).
|
|
341
|
+
predicates = []
|
|
342
|
+
for dim in self._dimension_columns:
|
|
343
|
+
if dim in full_dims:
|
|
344
|
+
continue
|
|
345
|
+
vals = requested[dim]
|
|
346
|
+
if len(vals) == 1:
|
|
347
|
+
predicates.append(col(f'"{dim}"') == literal(vals[0]))
|
|
348
|
+
else:
|
|
349
|
+
eq = col(f'"{dim}"') == literal(vals[0])
|
|
350
|
+
for v in vals[1:]:
|
|
351
|
+
eq = eq | (col(f'"{dim}"') == literal(v))
|
|
352
|
+
predicates.append(eq)
|
|
353
|
+
|
|
354
|
+
filtered = self._inner_df
|
|
355
|
+
if predicates:
|
|
356
|
+
combined = predicates[0]
|
|
357
|
+
for p in predicates[1:]:
|
|
358
|
+
combined = combined & p
|
|
359
|
+
filtered = filtered.filter(combined)
|
|
360
|
+
projected = filtered.select(
|
|
361
|
+
*(col(f'"{c}"') for c in self._dimension_columns + [self._var_name])
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# Consume the projected DataFrame as Arrow RecordBatches. The
|
|
365
|
+
# DataFusion wrapper exposes ``.to_pyarrow()`` to convert each
|
|
366
|
+
# batch into a true ``pyarrow.RecordBatch``.
|
|
367
|
+
batches = [b.to_pyarrow() for b in projected.execute_stream()]
|
|
368
|
+
return _scatter_batches_to_ndarray(
|
|
369
|
+
batches=batches,
|
|
370
|
+
dimension_columns=self._dimension_columns,
|
|
371
|
+
requested=requested,
|
|
372
|
+
var_name=self._var_name,
|
|
373
|
+
out_shape=out_shape,
|
|
374
|
+
dtype=self.dtype,
|
|
375
|
+
drop_axes=drop_axes,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _materialize(
|
|
380
|
+
inner_df: Any,
|
|
381
|
+
dimension_columns: list[str],
|
|
382
|
+
field_names: list[str],
|
|
383
|
+
field_types: dict[str, Any],
|
|
384
|
+
) -> xr.Dataset:
|
|
385
|
+
"""Execute the query once and build a dense in-memory Dataset.
|
|
386
|
+
|
|
387
|
+
Runs the plan exactly once via ``execute_stream()`` -- streaming the result
|
|
388
|
+
as Arrow ``RecordBatch`` es (``datafusion.RecordBatch.to_pyarrow()``) -- then
|
|
389
|
+
derives both the coordinates and every data variable from that single pass.
|
|
390
|
+
This is the eager path, used when no output chunking is requested. It never
|
|
391
|
+
re-executes, so an aggregation over a remote Zarr scan costs exactly one
|
|
392
|
+
scan, regardless of how many dimensions or variables the result has.
|
|
393
|
+
"""
|
|
394
|
+
batches = [b.to_pyarrow() for b in inner_df.execute_stream()]
|
|
395
|
+
|
|
396
|
+
coord_arrays: dict[str, np.ndarray] = {}
|
|
397
|
+
for d in dimension_columns:
|
|
398
|
+
if not batches:
|
|
399
|
+
coord_arrays[d] = np.asarray([])
|
|
400
|
+
continue
|
|
401
|
+
vals = np.concatenate(
|
|
402
|
+
[
|
|
403
|
+
b.column(b.schema.names.index(d)).to_numpy(zero_copy_only=False)
|
|
404
|
+
for b in batches
|
|
405
|
+
]
|
|
406
|
+
)
|
|
407
|
+
# Preserve the order coordinate values first appear in the result so an
|
|
408
|
+
# ORDER BY direction (e.g. ``ORDER BY level DESC``) carries through to
|
|
409
|
+
# the Dataset dimension instead of being force-sorted ascending.
|
|
410
|
+
# pd.unique keeps first-appearance order; the scatter below argsorts
|
|
411
|
+
# internally, so arbitrarily-ordered coordinates are placed correctly.
|
|
412
|
+
coord_arrays[d] = np.asarray(pd.unique(vals))
|
|
413
|
+
shape = tuple(len(coord_arrays[d]) for d in dimension_columns)
|
|
414
|
+
|
|
415
|
+
data_vars: dict[str, xr.Variable] = {}
|
|
416
|
+
for name in field_names:
|
|
417
|
+
if name in dimension_columns:
|
|
418
|
+
continue
|
|
419
|
+
np_dtype = np.dtype(field_types[name].to_pandas_dtype())
|
|
420
|
+
dense = _scatter_batches_to_ndarray(
|
|
421
|
+
batches=batches,
|
|
422
|
+
dimension_columns=dimension_columns,
|
|
423
|
+
requested=coord_arrays,
|
|
424
|
+
var_name=name,
|
|
425
|
+
out_shape=shape,
|
|
426
|
+
dtype=np_dtype,
|
|
427
|
+
drop_axes=[],
|
|
428
|
+
)
|
|
429
|
+
data_vars[name] = xr.Variable(dimension_columns, dense)
|
|
430
|
+
|
|
431
|
+
coords_arg = {d: coord_arrays[d] for d in dimension_columns}
|
|
432
|
+
return xr.Dataset(data_vars=data_vars, coords=coords_arg)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
_PURE_SCAN_NODES = {"Projection", "Sort", "TableScan", "SubqueryAlias"}
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _unfiltered_scan_table(inner_df: Any) -> str | None:
|
|
439
|
+
"""Return the scanned table name iff the query is a pure unfiltered scan.
|
|
440
|
+
|
|
441
|
+
A pure scan only contains ``Projection``, ``Sort``, ``TableScan``,
|
|
442
|
+
``SubqueryAlias`` nodes and exactly one ``TableScan``. Anything else
|
|
443
|
+
(``Filter``, ``Aggregate``, ``Join``, ``Union``, ``Limit``, multi-table
|
|
444
|
+
joins, ...) returns ``None`` so the caller falls back to per-dim
|
|
445
|
+
discovery. The returned name is the registered table the caller can
|
|
446
|
+
look up to source coord arrays from.
|
|
447
|
+
"""
|
|
448
|
+
try:
|
|
449
|
+
lp = inner_df.logical_plan()
|
|
450
|
+
except Exception:
|
|
451
|
+
return None
|
|
452
|
+
table_name: str | None = None
|
|
453
|
+
stack = [lp]
|
|
454
|
+
while stack:
|
|
455
|
+
node = stack.pop()
|
|
456
|
+
try:
|
|
457
|
+
variant = node.to_variant()
|
|
458
|
+
except Exception:
|
|
459
|
+
return None
|
|
460
|
+
cls = type(variant).__name__
|
|
461
|
+
if cls not in _PURE_SCAN_NODES:
|
|
462
|
+
return None
|
|
463
|
+
if cls == "TableScan":
|
|
464
|
+
try:
|
|
465
|
+
this = variant.table_name()
|
|
466
|
+
except (AttributeError, TypeError):
|
|
467
|
+
return None
|
|
468
|
+
if not isinstance(this, str):
|
|
469
|
+
return None
|
|
470
|
+
if table_name is not None and table_name != this:
|
|
471
|
+
return None # multi-table scan; not a single source
|
|
472
|
+
table_name = this
|
|
473
|
+
stack.extend(node.inputs())
|
|
474
|
+
return table_name
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _maybe_template_coords(
|
|
478
|
+
templates: dict[str, xr.Dataset] | None,
|
|
479
|
+
dimension_columns: list[str],
|
|
480
|
+
inner_df: Any,
|
|
481
|
+
) -> dict[str, np.ndarray] | None:
|
|
482
|
+
"""Use the scanned table's registered coord arrays directly when safe.
|
|
483
|
+
|
|
484
|
+
Returns coord arrays sourced from the registered Dataset for the
|
|
485
|
+
scanned table iff the query is an unfiltered scan over that single
|
|
486
|
+
table and the registered Dataset carries all requested dims. Returns
|
|
487
|
+
``None`` otherwise so the caller falls back to per-dim discovery.
|
|
488
|
+
Skipping discovery avoids one full plan execution per dim and
|
|
489
|
+
preserves the source's coordinate order (xarray-sql#171).
|
|
490
|
+
|
|
491
|
+
Coord values come from the **scanned** registered Dataset, not from
|
|
492
|
+
any user-supplied ``template=`` (which is for metadata recovery
|
|
493
|
+
only). That keeps the fast path correct when a user with multiple
|
|
494
|
+
registered Datasets passes a metadata template that differs from
|
|
495
|
+
the query's source.
|
|
496
|
+
"""
|
|
497
|
+
if not templates:
|
|
498
|
+
return None
|
|
499
|
+
table = _unfiltered_scan_table(inner_df)
|
|
500
|
+
if table is None or table not in templates:
|
|
501
|
+
return None
|
|
502
|
+
source = templates[table]
|
|
503
|
+
if not all(d in source.coords for d in dimension_columns):
|
|
504
|
+
return None
|
|
505
|
+
return {d: np.asarray(source.coords[d].values) for d in dimension_columns}
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _build_lazy_scan(
|
|
509
|
+
inner_df: Any,
|
|
510
|
+
dimension_columns: list[str],
|
|
511
|
+
field_names: list[str],
|
|
512
|
+
field_types: dict[str, Any],
|
|
513
|
+
templates: dict[str, xr.Dataset] | None = None,
|
|
514
|
+
) -> xr.Dataset:
|
|
515
|
+
"""Build a lazy Dataset whose data vars are :class:`SQLBackendArray`.
|
|
516
|
+
|
|
517
|
+
Used when output chunking is requested: each data variable stays lazy and,
|
|
518
|
+
once wrapped by ``Dataset.chunk``, every chunk reads its coordinate range via
|
|
519
|
+
a pushdown filter on first access. Coordinates come either from the
|
|
520
|
+
scanned table's registered Dataset (fast path, for unfiltered scans -- see
|
|
521
|
+
:func:`_maybe_template_coords`) or from per-dim
|
|
522
|
+
``inner_df.select(col(d)).distinct().sort(...).execute_stream()``; the table
|
|
523
|
+
provider projects to that single coordinate column and skips data variables,
|
|
524
|
+
so discovery reads coordinate values only (no data-variable I/O).
|
|
525
|
+
"""
|
|
526
|
+
coord_arrays = _maybe_template_coords(
|
|
527
|
+
templates, dimension_columns, inner_df
|
|
528
|
+
)
|
|
529
|
+
if coord_arrays is None:
|
|
530
|
+
coord_arrays = {}
|
|
531
|
+
for d in dimension_columns:
|
|
532
|
+
dim_only = (
|
|
533
|
+
inner_df.select(col(f'"{d}"'))
|
|
534
|
+
.distinct()
|
|
535
|
+
.sort(col(f'"{d}"').sort())
|
|
536
|
+
)
|
|
537
|
+
chunks = [b.to_pyarrow() for b in dim_only.execute_stream()]
|
|
538
|
+
if not chunks:
|
|
539
|
+
coord_arrays[d] = np.asarray([])
|
|
540
|
+
continue
|
|
541
|
+
coord_arrays[d] = np.concatenate(
|
|
542
|
+
[c.column(0).to_numpy(zero_copy_only=False) for c in chunks]
|
|
543
|
+
)
|
|
544
|
+
shape = tuple(len(coord_arrays[d]) for d in dimension_columns)
|
|
545
|
+
|
|
546
|
+
data_vars: dict[str, xr.Variable] = {}
|
|
547
|
+
for name in field_names:
|
|
548
|
+
if name in dimension_columns:
|
|
549
|
+
continue
|
|
550
|
+
np_dtype = field_types[name].to_pandas_dtype()
|
|
551
|
+
backend = SQLBackendArray(
|
|
552
|
+
inner_df=inner_df,
|
|
553
|
+
var_name=name,
|
|
554
|
+
dimension_columns=dimension_columns,
|
|
555
|
+
coord_arrays=coord_arrays,
|
|
556
|
+
shape=shape,
|
|
557
|
+
dtype=np_dtype,
|
|
558
|
+
)
|
|
559
|
+
lazy = xr.core.indexing.LazilyIndexedArray(backend)
|
|
560
|
+
data_vars[name] = xr.Variable(dimension_columns, lazy)
|
|
561
|
+
|
|
562
|
+
coords_arg = {d: coord_arrays[d] for d in dimension_columns}
|
|
563
|
+
return xr.Dataset(data_vars=data_vars, coords=coords_arg)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _auto_chunk_target_bytes() -> int:
|
|
567
|
+
"""Byte target for ``chunks="auto"`` (the chunk manager's, else 128 MiB)."""
|
|
568
|
+
try:
|
|
569
|
+
import dask
|
|
570
|
+
from dask.utils import parse_bytes
|
|
571
|
+
|
|
572
|
+
return int(parse_bytes(dask.config.get("array.chunk-size")))
|
|
573
|
+
except Exception:
|
|
574
|
+
return 128 * 1024 * 1024
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _auto_chunks(
|
|
578
|
+
template: xr.Dataset | None,
|
|
579
|
+
dimension_columns: list[str],
|
|
580
|
+
field_types: dict[str, Any],
|
|
581
|
+
) -> dict[str, int] | None:
|
|
582
|
+
"""Resolve ``chunks="auto"`` to a source-partition-aligned chunk spec.
|
|
583
|
+
|
|
584
|
+
Sizes chunks to roughly the chunk manager's byte target (dask's
|
|
585
|
+
``array.chunk-size``, default 128 MiB) but snaps boundaries to whole source
|
|
586
|
+
partitions, so every chunk is a union of source partitions -- no chunk splits
|
|
587
|
+
a partition (which would make adjacent chunks re-read it). This is what makes
|
|
588
|
+
``"auto"`` useful for finely partitioned sources (e.g. ERA5
|
|
589
|
+
``chunks={"time": 1}``): it coarsens many tiny partitions into memory-sized,
|
|
590
|
+
aligned chunks. Returns ``None`` when there is no resolvable source grid to
|
|
591
|
+
align to, so the caller falls back to the chunk manager's own ``"auto"``.
|
|
592
|
+
"""
|
|
593
|
+
if template is None:
|
|
594
|
+
return None
|
|
595
|
+
part = template.chunksizes # dim -> tuple of source chunk lengths
|
|
596
|
+
chunked_dims = [
|
|
597
|
+
d for d in dimension_columns if d in part and len(part[d]) > 1
|
|
598
|
+
]
|
|
599
|
+
if not chunked_dims:
|
|
600
|
+
return None
|
|
601
|
+
|
|
602
|
+
itemsizes = [
|
|
603
|
+
np.dtype(t.to_pandas_dtype()).itemsize
|
|
604
|
+
for name, t in field_types.items()
|
|
605
|
+
if name not in dimension_columns
|
|
606
|
+
]
|
|
607
|
+
itemsize = max(itemsizes) if itemsizes else 8
|
|
608
|
+
|
|
609
|
+
# Bytes in one source-partition block: the nominal source chunk length per
|
|
610
|
+
# dimension (``part[d][0]``) multiplied across all dims, times itemsize.
|
|
611
|
+
block_bytes = itemsize
|
|
612
|
+
for d in dimension_columns:
|
|
613
|
+
if d in part:
|
|
614
|
+
block_bytes *= int(part[d][0])
|
|
615
|
+
# Number of source partitions to merge per chunk to approach the target.
|
|
616
|
+
merge = max(1, _auto_chunk_target_bytes() // max(block_bytes, 1))
|
|
617
|
+
|
|
618
|
+
# Absorb the coarsening into the most finely partitioned dimension; the rest
|
|
619
|
+
# keep their source chunk length. xarray caps an oversize chunk at the dim
|
|
620
|
+
# length, so an over-large merge simply yields a single chunk on that dim.
|
|
621
|
+
primary = max(chunked_dims, key=lambda d: len(part[d]))
|
|
622
|
+
return {
|
|
623
|
+
d: int(part[d][0]) * (merge if d == primary else 1)
|
|
624
|
+
for d in chunked_dims
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _result_to_xarray(
|
|
629
|
+
inner_df: Any,
|
|
630
|
+
dimension_columns: list[str],
|
|
631
|
+
template: xr.Dataset | None,
|
|
632
|
+
sparsity: Sparsity,
|
|
633
|
+
fill_value: Any,
|
|
634
|
+
chunks: Mapping[str, int] | str | None,
|
|
635
|
+
templates: dict[str, xr.Dataset] | None = None,
|
|
636
|
+
) -> xr.Dataset:
|
|
637
|
+
"""Reconstruct an ``xr.Dataset`` from a SQL result.
|
|
638
|
+
|
|
639
|
+
``chunks`` (already resolved by :meth:`XarrayDataFrame._resolve_chunks`)
|
|
640
|
+
selects the execution strategy:
|
|
641
|
+
|
|
642
|
+
* ``None`` -> eager: execute once and materialize a dense Dataset
|
|
643
|
+
(:func:`_materialize`). Correct for any query and the right default for
|
|
644
|
+
reductions, whose results are small.
|
|
645
|
+
* a mapping (or ``"auto"``) -> lazy/chunked: build :class:`SQLBackendArray`
|
|
646
|
+
data variables (:func:`_build_lazy_scan`) and wrap them with
|
|
647
|
+
``Dataset.chunk`` so each chunk reads its coordinate range via filter
|
|
648
|
+
pushdown. The chunk grid maps onto the source partitions. Chunking goes
|
|
649
|
+
through xarray's configured chunk manager (dask, cubed, ...), so no
|
|
650
|
+
chunked-array backend is imported directly here.
|
|
651
|
+
"""
|
|
652
|
+
if sparsity not in ("result", "template"):
|
|
653
|
+
raise ValueError(
|
|
654
|
+
f"sparsity must be 'result' or 'template', got {sparsity!r}"
|
|
655
|
+
)
|
|
656
|
+
if sparsity == "template" and template is None:
|
|
657
|
+
raise ValueError(
|
|
658
|
+
"sparsity='template' requires template= to be supplied"
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
schema = inner_df.schema()
|
|
662
|
+
field_names = [f.name for f in schema]
|
|
663
|
+
field_types = {f.name: f.type for f in schema}
|
|
664
|
+
|
|
665
|
+
if chunks is None:
|
|
666
|
+
ds = _materialize(inner_df, dimension_columns, field_names, field_types)
|
|
667
|
+
else:
|
|
668
|
+
ds = _build_lazy_scan(
|
|
669
|
+
inner_df,
|
|
670
|
+
dimension_columns,
|
|
671
|
+
field_names,
|
|
672
|
+
field_types,
|
|
673
|
+
templates=templates,
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
if sparsity == "template":
|
|
677
|
+
assert template is not None
|
|
678
|
+
indexers = {
|
|
679
|
+
d: template.coords[d].values
|
|
680
|
+
for d in dimension_columns
|
|
681
|
+
if d in template.coords and d in template.dims
|
|
682
|
+
}
|
|
683
|
+
if indexers:
|
|
684
|
+
ds = ds.reindex(indexers, fill_value=fill_value)
|
|
685
|
+
|
|
686
|
+
if template is not None:
|
|
687
|
+
ds = _apply_template(ds, template)
|
|
688
|
+
|
|
689
|
+
if chunks is not None:
|
|
690
|
+
if chunks == "auto":
|
|
691
|
+
# Snap the byte-budgeted "auto" sizing to source partition
|
|
692
|
+
# boundaries; fall back to the chunk manager's own "auto" when there
|
|
693
|
+
# is no source grid to align to.
|
|
694
|
+
chunks = (
|
|
695
|
+
_auto_chunks(template, dimension_columns, field_types) or "auto"
|
|
696
|
+
)
|
|
697
|
+
# Wrap the lazy data variables in the configured chunk manager (dask by
|
|
698
|
+
# default). Each chunk reads its coordinate range via pushdown on access.
|
|
699
|
+
ds = ds.chunk(chunks)
|
|
700
|
+
return ds
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
# ---------------------------------------------------------------------------
|
|
704
|
+
# Public wrapper
|
|
705
|
+
# ---------------------------------------------------------------------------
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
class XarrayDataFrame:
|
|
709
|
+
"""Wrapper around a DataFusion ``DataFrame`` with xarray-aware helpers.
|
|
710
|
+
|
|
711
|
+
Returned by :meth:`xarray_sql.XarrayContext.sql`. Forwards every
|
|
712
|
+
attribute it does not define itself to the wrapped DataFrame, so
|
|
713
|
+
``.collect()``, ``.schema()``, ``.show()``, ``.count()`` all work
|
|
714
|
+
unchanged.
|
|
715
|
+
|
|
716
|
+
Carries a private snapshot of the context's registered Datasets so
|
|
717
|
+
:meth:`to_dataset` can default ``dims`` and recover metadata
|
|
718
|
+
dropped by the forward pivot.
|
|
719
|
+
|
|
720
|
+
Users should not construct this class directly; let
|
|
721
|
+
:meth:`XarrayContext.sql` produce it.
|
|
722
|
+
"""
|
|
723
|
+
|
|
724
|
+
def __init__(
|
|
725
|
+
self,
|
|
726
|
+
inner: Any,
|
|
727
|
+
templates: dict[str, xr.Dataset] | None = None,
|
|
728
|
+
) -> None:
|
|
729
|
+
"""Construct a wrapper.
|
|
730
|
+
|
|
731
|
+
Args:
|
|
732
|
+
inner: The underlying ``datafusion.DataFrame`` returned by
|
|
733
|
+
:meth:`XarrayContext.sql`.
|
|
734
|
+
templates: Snapshot of the registered Datasets on the producing
|
|
735
|
+
context, keyed by the SQL identifier each was registered
|
|
736
|
+
under. Used by :meth:`to_dataset` to recover metadata that
|
|
737
|
+
the forward pivot strips. ``None`` means no metadata
|
|
738
|
+
recovery is possible from registrations alone; callers may
|
|
739
|
+
still pass ``template=`` to :meth:`to_dataset` explicitly.
|
|
740
|
+
"""
|
|
741
|
+
object.__setattr__(self, "_inner", inner)
|
|
742
|
+
object.__setattr__(self, "_templates", dict(templates or {}))
|
|
743
|
+
|
|
744
|
+
def to_pandas(self) -> pd.DataFrame:
|
|
745
|
+
"""Materialize the result as a ``pd.DataFrame`` (DataFusion API)."""
|
|
746
|
+
return self._inner.to_pandas()
|
|
747
|
+
|
|
748
|
+
def to_dataset(
|
|
749
|
+
self,
|
|
750
|
+
dims: list[str] | None = None,
|
|
751
|
+
template: xr.Dataset | str | None = None,
|
|
752
|
+
sparsity: Sparsity = "result",
|
|
753
|
+
fill_value: Any = np.nan,
|
|
754
|
+
chunks: Mapping[str, int] | str | None = "inherit",
|
|
755
|
+
) -> xr.Dataset:
|
|
756
|
+
"""Convert the result to an ``xr.Dataset``.
|
|
757
|
+
|
|
758
|
+
Args:
|
|
759
|
+
dims: Result columns to use as Dataset dimensions. When
|
|
760
|
+
``None``, defaults to the dims of the registered Dataset
|
|
761
|
+
referenced by the SQL ``FROM`` clause (if exactly one
|
|
762
|
+
matches), or any single registered Dataset whose dims are
|
|
763
|
+
all present in the result columns.
|
|
764
|
+
template: Source to recover metadata (attrs, encoding, non-dim
|
|
765
|
+
coordinates, dim-coord dtype) from. Either an ``xr.Dataset``
|
|
766
|
+
used directly, or the name of a registered table (e.g.
|
|
767
|
+
``"era5.surface"``) whose Dataset is looked up. When ``None``
|
|
768
|
+
and exactly one Dataset is registered, that one is used.
|
|
769
|
+
sparsity: ``"result"`` (default) keeps only dim values
|
|
770
|
+
present in the result. ``"template"`` reindexes to the
|
|
771
|
+
template's full coord ranges, filling absent cells with
|
|
772
|
+
``fill_value``; requires a template.
|
|
773
|
+
fill_value: Used when ``sparsity="template"`` reindexes
|
|
774
|
+
to a wider extent. Defaults to ``np.nan``.
|
|
775
|
+
chunks: Output chunking, controlling laziness (an xarray idiom).
|
|
776
|
+
|
|
777
|
+
* ``"inherit"`` (default): reuse the source Dataset's chunk
|
|
778
|
+
sizes, but only for dimensions that were genuinely split into
|
|
779
|
+
multiple chunks in the input -- so the output chunk grid maps
|
|
780
|
+
onto the source partitions. A reduction that drops the chunked
|
|
781
|
+
dimension (e.g. a global aggregation) inherits nothing and so
|
|
782
|
+
is materialized eagerly. Falls back to eager when no source
|
|
783
|
+
Dataset is resolvable.
|
|
784
|
+
* ``None``: eager. Execute the query once and return a dense
|
|
785
|
+
in-memory Dataset. Best for reductions (small results).
|
|
786
|
+
* a mapping (e.g. ``{"time": 100}``): chunk explicitly. Each
|
|
787
|
+
chunk reads its coordinate range lazily via filter pushdown on
|
|
788
|
+
access, through xarray's configured chunk manager (dask,
|
|
789
|
+
cubed, ...).
|
|
790
|
+
* ``"auto"``: size chunks to the chunk manager's byte target but
|
|
791
|
+
snap boundaries to whole source partitions, so each chunk is a
|
|
792
|
+
union of source partitions. Useful for finely partitioned
|
|
793
|
+
sources (e.g. ERA5 ``chunks={"time": 1}``), coarsening many
|
|
794
|
+
tiny partitions into memory-sized, aligned chunks.
|
|
795
|
+
|
|
796
|
+
Returns:
|
|
797
|
+
An ``xr.Dataset`` with ``dims`` as dimensions and the
|
|
798
|
+
remaining result columns as data variables.
|
|
799
|
+
|
|
800
|
+
Raises:
|
|
801
|
+
ValueError: ``dims`` cannot be inferred, names a missing
|
|
802
|
+
column, or the result has duplicate dim tuples;
|
|
803
|
+
``template`` names an unknown registered table; or
|
|
804
|
+
``sparsity="template"`` is requested without a
|
|
805
|
+
resolvable template.
|
|
806
|
+
"""
|
|
807
|
+
if not isinstance(template, xr.Dataset):
|
|
808
|
+
# ``template`` is a registered-table name or None; look it up.
|
|
809
|
+
template = self._resolve_template(template)
|
|
810
|
+
if dims is None:
|
|
811
|
+
dims = self._infer_dimension_columns(preferred_template=template)
|
|
812
|
+
resolved_chunks = self._resolve_chunks(chunks, template, dims)
|
|
813
|
+
return _result_to_xarray(
|
|
814
|
+
inner_df=self._inner,
|
|
815
|
+
dimension_columns=dims,
|
|
816
|
+
template=template,
|
|
817
|
+
sparsity=sparsity,
|
|
818
|
+
fill_value=fill_value,
|
|
819
|
+
chunks=resolved_chunks,
|
|
820
|
+
templates=self._templates,
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
# ------------------------------------------------------------------
|
|
824
|
+
# Internals
|
|
825
|
+
# ------------------------------------------------------------------
|
|
826
|
+
|
|
827
|
+
@staticmethod
|
|
828
|
+
def _resolve_chunks(
|
|
829
|
+
chunks: Mapping[str, int] | str | None,
|
|
830
|
+
template: xr.Dataset | None,
|
|
831
|
+
dimension_columns: list[str],
|
|
832
|
+
) -> Mapping[str, int] | str | None:
|
|
833
|
+
"""Resolve the ``chunks`` argument to a concrete spec or ``None``.
|
|
834
|
+
|
|
835
|
+
``None`` selects the eager path; anything else selects the lazy/chunked
|
|
836
|
+
path. ``"inherit"`` reuses the source Dataset's chunk sizes -- but only
|
|
837
|
+
for dimensions actually split into more than one chunk in the input
|
|
838
|
+
(a single full chunk is not "chunked"), so reductions that drop the
|
|
839
|
+
chunked dimension resolve to ``None`` (eager) automatically. Mappings
|
|
840
|
+
pass through unchanged; ``"auto"`` passes through here and is snapped to
|
|
841
|
+
source partition boundaries later (see :func:`_auto_chunks`).
|
|
842
|
+
"""
|
|
843
|
+
if chunks is None:
|
|
844
|
+
return None
|
|
845
|
+
if chunks == "inherit":
|
|
846
|
+
if template is None:
|
|
847
|
+
return None
|
|
848
|
+
sizes = template.chunksizes # dim -> tuple of chunk lengths
|
|
849
|
+
inherited = {
|
|
850
|
+
d: sizes[d][0]
|
|
851
|
+
for d in dimension_columns
|
|
852
|
+
if d in sizes and len(sizes[d]) > 1
|
|
853
|
+
}
|
|
854
|
+
return inherited or None
|
|
855
|
+
return chunks
|
|
856
|
+
|
|
857
|
+
def _resolve_template(self, name: str | None) -> xr.Dataset | None:
|
|
858
|
+
"""Pick a template Dataset for metadata recovery by registered name.
|
|
859
|
+
|
|
860
|
+
Priority:
|
|
861
|
+
1. The named registered table (``name``).
|
|
862
|
+
2. If exactly one Dataset is registered on the context, use it.
|
|
863
|
+
3. None.
|
|
864
|
+
"""
|
|
865
|
+
templates = self._templates
|
|
866
|
+
if name is not None:
|
|
867
|
+
if name not in templates:
|
|
868
|
+
raise ValueError(
|
|
869
|
+
f"template={name!r} is not a registered table on this "
|
|
870
|
+
f"context. Registered: {list(templates)}"
|
|
871
|
+
)
|
|
872
|
+
return templates[name]
|
|
873
|
+
if len(templates) == 1:
|
|
874
|
+
return next(iter(templates.values()))
|
|
875
|
+
return None
|
|
876
|
+
|
|
877
|
+
def _infer_dimension_columns(
|
|
878
|
+
self, preferred_template: xr.Dataset | None = None
|
|
879
|
+
) -> list[str]:
|
|
880
|
+
"""Pick a default ``dimension_columns`` from the registry, or raise.
|
|
881
|
+
|
|
882
|
+
Uses the data variable's dim order (via :func:`_ds_var_dims`) so
|
|
883
|
+
the round-trip preserves the original axis order.
|
|
884
|
+
"""
|
|
885
|
+
result_cols = set(self._result_columns())
|
|
886
|
+
if (
|
|
887
|
+
preferred_template is not None
|
|
888
|
+
and set(preferred_template.dims) <= result_cols
|
|
889
|
+
):
|
|
890
|
+
return _ds_var_dims(preferred_template)
|
|
891
|
+
if not self._templates:
|
|
892
|
+
raise ValueError(
|
|
893
|
+
"dims cannot be inferred (no registered "
|
|
894
|
+
"Dataset on this result); pass dims=[...] "
|
|
895
|
+
"explicitly."
|
|
896
|
+
)
|
|
897
|
+
candidates = [
|
|
898
|
+
_ds_var_dims(t)
|
|
899
|
+
for t in self._templates.values()
|
|
900
|
+
if set(t.dims) <= result_cols
|
|
901
|
+
]
|
|
902
|
+
if len(candidates) == 1:
|
|
903
|
+
return candidates[0]
|
|
904
|
+
if not candidates:
|
|
905
|
+
raise ValueError(
|
|
906
|
+
"dims cannot be inferred: no registered "
|
|
907
|
+
"Dataset has all of its dims present in the result "
|
|
908
|
+
"columns. Pass dims=[...] explicitly."
|
|
909
|
+
)
|
|
910
|
+
raise ValueError(
|
|
911
|
+
"dims cannot be inferred unambiguously: multiple "
|
|
912
|
+
"registered Datasets are compatible with the result. Pass "
|
|
913
|
+
"dims=[...] explicitly."
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
def _result_columns(self) -> list[str]:
|
|
917
|
+
"""Return the result's column names without materializing rows."""
|
|
918
|
+
return [field.name for field in self._inner.schema()]
|
|
919
|
+
|
|
920
|
+
def __getattr__(self, name: str) -> Any:
|
|
921
|
+
# Runs only when ``name`` is not found via normal lookup, so this
|
|
922
|
+
# safely forwards anything we have not overridden.
|
|
923
|
+
return getattr(self._inner, name)
|
|
924
|
+
|
|
925
|
+
def __repr__(self) -> str:
|
|
926
|
+
return repr(self._inner)
|