|
1 | 1 | import uuid |
2 | 2 |
|
| 3 | +import numpy as np |
3 | 4 | import pyarrow as pa |
4 | 5 | import xarray as xr |
5 | 6 | from datafusion import SessionContext |
6 | 7 | from datafusion.catalog import Schema |
| 8 | +from datafusion.plan import ExecutionPlan |
7 | 9 | from types import ModuleType |
8 | 10 |
|
9 | 11 | from . import cftime as cft |
@@ -219,9 +221,185 @@ def sql(self, query: str, *args, **kwargs) -> XarrayDataFrame: |
219 | 221 | Returns: |
220 | 222 | An [XarrayDataFrame][xarray_sql.ds.XarrayDataFrame] wrapping the DataFusion DataFrame. |
221 | 223 | """ |
222 | | - inner = super().sql(query, *args, **kwargs) |
| 224 | + return self._wrap(super().sql(query, *args, **kwargs)) |
| 225 | + |
| 226 | + def prepare(self, query: str) -> "PreparedQuery": |
| 227 | + """Plan a query once, to run it many times. |
| 228 | +
|
| 229 | + A training loop runs the same query on every step, and only the |
| 230 | + values in its tables change. DataFusion plans a query each time it |
| 231 | + runs, and for a query over small tables, planning can take far |
| 232 | + longer than running. ``prepare`` plans the query once, and calling |
| 233 | + the result runs that plan, as ``jax.jit`` traces a function once |
| 234 | + and runs the compiled function after that. A call can first write |
| 235 | + new values into the query's tables:: |
| 236 | +
|
| 237 | + step = ctx.prepare( |
| 238 | + "SELECT x.i, SUM(x.v * w.v) AS y " |
| 239 | + "FROM x JOIN w ON x.j = w.j GROUP BY x.i" |
| 240 | + ) |
| 241 | + for weights in trajectory: # Datasets shaped like w's |
| 242 | + rows = step(w=weights) |
| 243 | +
|
| 244 | + See [PreparedQuery][xarray_sql.PreparedQuery] for what a call |
| 245 | + can write, and which plans are run again rather than planned again. |
| 246 | +
|
| 247 | + Args: |
| 248 | + query: A SQL query string. |
| 249 | +
|
| 250 | + Returns: |
| 251 | + A [PreparedQuery][xarray_sql.PreparedQuery]; call it to run |
| 252 | + the query. |
| 253 | + """ |
| 254 | + return PreparedQuery(self, query) |
| 255 | + |
| 256 | + def _write(self, writes: dict[str, xr.Dataset]) -> None: |
| 257 | + """Write each Dataset's values into the arrays of the Dataset |
| 258 | + registered under its name, all or none of them.""" |
| 259 | + copies = [] |
| 260 | + for name, ds in writes.items(): |
| 261 | + registered = self._registered_datasets[name] |
| 262 | + if set(ds.data_vars) != set(registered.data_vars): |
| 263 | + raise ValueError( |
| 264 | + f"{name!r} has the data variables " |
| 265 | + f"{sorted(registered.data_vars)}, and the new Dataset " |
| 266 | + f"has {sorted(ds.data_vars)}" |
| 267 | + ) |
| 268 | + for dim in registered.dims: |
| 269 | + if dim not in ds.dims or not ds[dim].equals(registered[dim]): |
| 270 | + raise ValueError( |
| 271 | + f"the new Dataset for {name!r} has other coordinates " |
| 272 | + f"along {dim!r}; register it again, and prepare the " |
| 273 | + "query again" |
| 274 | + ) |
| 275 | + for var in registered.data_vars: |
| 276 | + old, new = registered[var].variable, ds[var].variable |
| 277 | + if new.dims != old.dims or new.dtype != old.dtype: |
| 278 | + raise ValueError( |
| 279 | + f"{name}.{var} is {old.dtype} over {old.dims}, and " |
| 280 | + f"the new values are {new.dtype} over {new.dims}" |
| 281 | + ) |
| 282 | + target = old.data |
| 283 | + # A table shares a C-contiguous NumPy array's memory; any |
| 284 | + # other array, it read or copied when it was registered. |
| 285 | + if not ( |
| 286 | + isinstance(target, np.ndarray) |
| 287 | + and target.flags.c_contiguous |
| 288 | + and target.flags.writeable |
| 289 | + ): |
| 290 | + raise ValueError( |
| 291 | + f"{name}.{var} isn't a writable, C-contiguous NumPy " |
| 292 | + "array, which its table would share; register the " |
| 293 | + "new Dataset instead, and prepare the query again" |
| 294 | + ) |
| 295 | + values = np.asarray(new.values) |
| 296 | + # A table works out which values are NULL (NaN) when it is |
| 297 | + # registered, so a write must keep its NaNs in place. |
| 298 | + if values.dtype.kind in "fc" and not np.array_equal( |
| 299 | + np.isnan(values), np.isnan(target) |
| 300 | + ): |
| 301 | + raise ValueError( |
| 302 | + f"the new values of {name}.{var} have NaN in other " |
| 303 | + "places; xarray-sql reads a NaN as NULL, so register " |
| 304 | + "the new Dataset instead, and prepare the query again" |
| 305 | + ) |
| 306 | + copies.append((target, values)) |
| 307 | + for target, values in copies: |
| 308 | + np.copyto(target, values) |
| 309 | + |
| 310 | + def _wrap(self, inner) -> XarrayDataFrame: |
| 311 | + """Wrap a DataFusion DataFrame so it can round-trip to xarray.""" |
223 | 312 | return XarrayDataFrame(inner, templates=self._registered_datasets) |
224 | 313 |
|
225 | 314 |
|
| 315 | +class PreparedQuery: |
| 316 | + """A query planned once by [Context.prepare][xarray_sql.Context.prepare]. |
| 317 | +
|
| 318 | + Call it to run the query, and get its rows as a ``pyarrow.Table`` |
| 319 | + ([to_dataset][xarray_sql.to_dataset] turns them back into a Dataset). |
| 320 | + ``query()`` runs it on the values its tables hold now. ``query(w=ds)`` |
| 321 | + first writes ``ds``'s values into the table ``w``. |
| 322 | +
|
| 323 | + A write goes into the arrays of the Dataset registered as ``w``, as |
| 324 | + ``w_ds["v"].values[...] = ds["v"].values`` would. That Dataset changes |
| 325 | + too, and so does what any other query of the table returns. The new |
| 326 | + Dataset must have the registered one's data variables, dtypes, dims and |
| 327 | + coordinates. Its NaNs must be in the same places too, since xarray-sql |
| 328 | + reads a NaN as NULL, and a table works out which values are NULL when it |
| 329 | + is registered. To change anything else, register the new Dataset, and |
| 330 | + prepare the query again: a prepared query reads the tables that were |
| 331 | + registered when it was planned, and refuses to write into a name that |
| 332 | + has been registered again since. |
| 333 | +
|
| 334 | + A plan keeps state from a run: a hash join, for one, keeps the table it |
| 335 | + built. So each call runs a fresh copy of the plan, decoded from its |
| 336 | + serialized form, which is much cheaper than planning. Some plans can't |
| 337 | + be copied that way: a plan that reads a lazy table (a Dask array or a |
| 338 | + lazily opened file), or one nested too deep to decode. These are |
| 339 | + planned again on every call, so they return the right rows, without |
| 340 | + the savings. |
| 341 | + """ |
| 342 | + |
| 343 | + def __init__(self, ctx: Context, query: str): |
| 344 | + self._ctx = ctx |
| 345 | + self._query = query |
| 346 | + # The Datasets a call may write into: those registered now. |
| 347 | + self._registered = dict(ctx._registered_datasets) |
| 348 | + self._plan: ExecutionPlan | None = None |
| 349 | + df = SessionContext.sql(ctx, query) |
| 350 | + self._schema = df.schema() |
| 351 | + plan = df.execution_plan() |
| 352 | + try: |
| 353 | + _copy(ctx, plan) |
| 354 | + except Exception: # noqa: BLE001 - DataFusion raises a bare Exception |
| 355 | + # It reads a table the plan's serialized form can't hold, or is |
| 356 | + # nested deeper than protobuf decodes. |
| 357 | + return |
| 358 | + self._plan = plan |
| 359 | + |
| 360 | + def __call__(self, **tables: xr.Dataset) -> pa.Table: |
| 361 | + """Run the query, after writing each Dataset given into the table |
| 362 | + of its name. |
| 363 | +
|
| 364 | + Args: |
| 365 | + **tables: A Dataset for each table to write new values into. |
| 366 | +
|
| 367 | + Returns: |
| 368 | + The query's rows. |
| 369 | + """ |
| 370 | + for name in tables: |
| 371 | + if name not in self._registered: |
| 372 | + raise ValueError( |
| 373 | + f"no Dataset was registered as {name!r} when the query " |
| 374 | + "was prepared" |
| 375 | + ) |
| 376 | + now = self._ctx._registered_datasets.get(name) |
| 377 | + if now is not self._registered[name]: |
| 378 | + raise ValueError( |
| 379 | + f"{name!r} has been registered again since the query was " |
| 380 | + "prepared, and the query reads the table it was planned " |
| 381 | + "with; prepare the query again" |
| 382 | + ) |
| 383 | + self._ctx._write(tables) |
| 384 | + if self._plan is None: |
| 385 | + return self._ctx.sql(self._query).to_arrow_table() |
| 386 | + plan = _copy(self._ctx, self._plan) |
| 387 | + batches = [ |
| 388 | + batch.to_pyarrow() |
| 389 | + for partition in range(plan.partition_count) |
| 390 | + for batch in self._ctx.execute(plan, partition) |
| 391 | + ] |
| 392 | + return pa.Table.from_batches(batches, self._schema) |
| 393 | + |
| 394 | + |
| 395 | +def _copy(ctx: SessionContext, plan: ExecutionPlan) -> ExecutionPlan: |
| 396 | + """A copy of ``plan`` with none of its state from a run, reading its |
| 397 | + in-memory tables' values as they are now.""" |
| 398 | + # TODO(#283): reset the plan's state with datafusion-python's binding of |
| 399 | + # reset_plan_states, once it has one, rather than decode it again. |
| 400 | + # `plan` itself never runs, so it has no state to leave behind. |
| 401 | + return ExecutionPlan.from_bytes(ctx, plan.to_bytes()) |
| 402 | + |
| 403 | + |
226 | 404 | XarrayContext = Context |
227 | 405 | """The original name of [Context][xarray_sql.Context], kept for compatibility.""" |
0 commit comments