88 cases, ``dimension_columns`` auto-inference vs explicit.
99* Template-based metadata recovery (var attrs / encoding, dataset
1010 attrs, non-dim coords, dim-coord dtype).
11- * Sparse-extent handling and edge cases (null dim rows, fill_value
11+ * Sparsity handling and edge cases (null dim rows, fill_value
1212 dtype behavior, vectorized indexer fallback).
1313"""
1414
@@ -63,7 +63,7 @@ def _clear_encoding(ds: xr.Dataset) -> xr.Dataset:
6363 """Strip ``encoding`` from a Dataset and all its variables.
6464
6565 Round-trip identity tests should not be coupled to encoding choices,
66- since ``_apply_template `` deliberately drops dtype-bound keys.
66+ since ``apply_template `` deliberately drops dtype-bound keys.
6767 """
6868 ds = ds .copy ()
6969 for v in ds .variables .values ():
@@ -315,26 +315,32 @@ def test_lazy_select_star_round_trip_equality(air_dataset_small):
315315 np .testing .assert_array_equal (actual ["air" ].values , expected ["air" ].values )
316316
317317
318- def test_aggregation_uses_eager_path (air_dataset_small ):
319- """Aggregation queries materialize once via the eager path .
318+ def test_aggregation_uses_lazy_backend (air_dataset_small ):
319+ """Aggregation queries return a lazy Dataset just like SELECT * .
320320
321- No assertion on the data type of variable._data here; xarray may
322- wrap eager numpy arrays in NumpyIndexingAdapter. The contract is:
323- values match the source and slicing afterwards is local.
321+ Pushdown and laziness are orthogonal: an aggregation can't push the
322+ request indexer into a useful filter, but the result is still
323+ streamed via execute_stream on first access. The user-visible
324+ contract is values match the source's ``mean(dim="time")``.
324325 """
325326 ctx = XarrayContext ()
326327 ctx .from_dataset ("air" , air_dataset_small )
327328 out = ctx .sql (
328329 "SELECT lat, lon, AVG(air) AS air_avg FROM air GROUP BY lat, lon"
329330 ).to_dataset (dimension_columns = ["lat" , "lon" ])
330- underlying = out ["air_avg" ].variable ._data
331331 from xarray_sql .ds import SQLBackendArray
332332
333- # Aggregation -> not a SQLBackendArray.
334- if hasattr (underlying , "array" ):
335- assert not isinstance (underlying .array , SQLBackendArray )
336- else :
337- assert not isinstance (underlying , SQLBackendArray )
333+ inner = out ["air_avg" ].variable ._data
334+ underlying = inner .array if hasattr (inner , "array" ) else inner
335+ assert isinstance (underlying , SQLBackendArray )
336+ expected = (
337+ air_dataset_small .compute ()
338+ .sortby (["lat" , "lon" ])
339+ .mean (dim = "time" )["air" ]
340+ .values
341+ )
342+ actual = out .sortby (["lat" , "lon" ])["air_avg" ].values
343+ np .testing .assert_allclose (actual , expected )
338344
339345
340346def test_lazy_outer_indexer_array (air_dataset_small ):
@@ -378,12 +384,12 @@ def test_lazy_compute_returns_eager(air_dataset_small):
378384
379385
380386# ---------------------------------------------------------------------------
381- # Sparse-extent handling and edge cases
387+ # Sparsity handling and edge cases
382388# ---------------------------------------------------------------------------
383389
384390
385- def test_sparse_extent_result_default_filters_lazy (air_dataset_small ):
386- """Default sparse_extent ='result' keeps only filtered coords (lazy path)."""
391+ def test_sparsity_result_default_filters_lazy (air_dataset_small ):
392+ """Default sparsity ='result' keeps only filtered coords (lazy path)."""
387393 ctx = XarrayContext ()
388394 ctx .from_dataset ("air" , air_dataset_small )
389395 threshold = float (air_dataset_small ["lat" ].values [5 ])
@@ -392,13 +398,13 @@ def test_sparse_extent_result_default_filters_lazy(air_dataset_small):
392398 assert out .sizes ["lat" ] < air_dataset_small .sizes ["lat" ]
393399
394400
395- def test_sparse_extent_template_full_grid (air_dataset_small ):
396- """sparse_extent ='template' reindexes to the full grid with NaN fills."""
401+ def test_sparsity_template_full_grid (air_dataset_small ):
402+ """sparsity ='template' reindexes to the full grid with NaN fills."""
397403 ctx = XarrayContext ()
398404 ctx .from_dataset ("air" , air_dataset_small )
399405 threshold = float (air_dataset_small ["lat" ].values [5 ])
400406 out = ctx .sql (f"SELECT * FROM air WHERE lat > { threshold } " ).to_dataset (
401- sparse_extent = "template"
407+ sparsity = "template"
402408 )
403409 assert out .sizes ["lat" ] == air_dataset_small .sizes ["lat" ]
404410 lat_vals = out ["lat" ].values
@@ -410,29 +416,32 @@ def test_sparse_extent_template_full_grid(air_dataset_small):
410416 assert not np .isnan (above .values ).any ()
411417
412418
413- def test_sparse_extent_template_requires_template (air_dataset_small ):
414- """No resolvable template -> sparse_extent='template' raises."""
415- from xarray_sql .ds import _eager_to_xarray
416-
417- df = pd .DataFrame ([(0 , 0 , 1.0 ), (1 , 1 , 2.0 )], columns = ["lat" , "lon" , "v" ])
419+ def test_sparsity_template_requires_template (air_dataset_small ):
420+ """No resolvable template -> sparsity='template' raises."""
421+ other = air_dataset_small .copy ()
422+ ctx = XarrayContext ()
423+ # Two registrations so auto-resolve returns None.
424+ ctx .from_dataset ("a" , air_dataset_small )
425+ ctx .from_dataset ("b" , other )
418426 with pytest .raises (ValueError , match = "requires template= to be supplied" ):
419- _eager_to_xarray (
420- df , dimension_columns = ["lat" , "lon" ], sparse_extent = "template"
427+ ctx .sql ("SELECT * FROM a" ).to_dataset (
428+ dimension_columns = ["time" , "lat" , "lon" ],
429+ sparsity = "template" ,
421430 )
422431
423432
424- def test_sparse_extent_invalid_value_raises ( ):
425- from xarray_sql . ds import _eager_to_xarray
426-
427- df = pd . DataFrame ([( 0 , 0 , 1.0 )], columns = [ "lat" , "lon" , "v" ])
428- with pytest . raises ( ValueError , match = "sparse_extent must be" ):
429- _eager_to_xarray (
430- df , dimension_columns = [ "lat " , "lon" ], sparse_extent = "bogus"
433+ def test_sparsity_invalid_value_raises ( air_dataset_small ):
434+ ctx = XarrayContext ()
435+ ctx . from_dataset ( "air" , air_dataset_small )
436+ with pytest . raises ( ValueError , match = "sparsity must be" ):
437+ ctx . sql ( "SELECT * FROM air" ). to_dataset (
438+ dimension_columns = [ "time" , "lat" , "lon" ],
439+ sparsity = "bogus " , # type: ignore[arg-type]
431440 )
432441
433442
434- def test_sparse_extent_template_with_aggregation (air_dataset_small ):
435- """sparse_extent ='template' on an aggregation respects dimension_columns subset."""
443+ def test_sparsity_template_with_aggregation (air_dataset_small ):
444+ """sparsity ='template' on an aggregation respects dimension_columns subset."""
436445 ctx = XarrayContext ()
437446 ctx .from_dataset ("air" , air_dataset_small )
438447 threshold = float (air_dataset_small ["lat" ].values [5 ])
@@ -443,7 +452,7 @@ def test_sparse_extent_template_with_aggregation(air_dataset_small):
443452 WHERE lat > { threshold }
444453 GROUP BY lat, lon
445454 """
446- ).to_dataset (dimension_columns = ["lat" , "lon" ], sparse_extent = "template" )
455+ ).to_dataset (dimension_columns = ["lat" , "lon" ], sparsity = "template" )
447456 assert out .sizes ["lat" ] == air_dataset_small .sizes ["lat" ]
448457 assert "time" not in out .dims
449458 below_mask = out ["lat" ].values <= threshold
@@ -460,60 +469,45 @@ def test_fill_value_int_upcasts_to_float():
460469 ctx = XarrayContext ()
461470 ctx .from_dataset ("t" , ds )
462471 out = ctx .sql ("SELECT * FROM t WHERE lat > 0" ).to_dataset (
463- sparse_extent = "template"
472+ sparsity = "template"
464473 )
465474 assert np .issubdtype (out ["v" ].dtype , np .floating )
466475 assert np .isnan (out ["v" ].sel (lat = 0 ).values ).all ()
467476
468477
469- def test_fill_value_custom_preserves_int ():
478+ def test_fill_value_custom_preserves_int (air_dataset_small ):
470479 """Passing a typed sentinel preserves the data var's int dtype."""
471- from xarray_sql .ds import _eager_to_xarray
472-
473- df = pd .DataFrame (
474- [(1 , 10 , 100 ), (1 , 11 , 101 ), (2 , 10 , 200 ), (2 , 11 , 201 )],
475- columns = ["lat" , "lon" , "v" ],
476- )
477- template = xr .Dataset (
478- {"v" : (("lat" , "lon" ), np .zeros ((3 , 2 ), dtype = np .int64 ))},
480+ # Build a small int-valued Dataset, register, filter out part of the
481+ # extent, and reindex back with an int fill_value via sparsity.
482+ source = xr .Dataset (
483+ {
484+ "v" : (
485+ ("lat" , "lon" ),
486+ np .arange (6 , dtype = np .int64 ).reshape (3 , 2 ) + 1 ,
487+ ),
488+ },
479489 coords = {"lat" : [0 , 1 , 2 ], "lon" : [10 , 11 ]},
480- )
481- out = _eager_to_xarray (
482- df ,
483- dimension_columns = ["lat" , "lon" ],
484- template = template ,
485- sparse_extent = "template" ,
486- fill_value = - 1 ,
490+ ).chunk ({"lat" : 3 })
491+ ctx = XarrayContext ()
492+ ctx .from_dataset ("t" , source )
493+ out = ctx .sql ("SELECT * FROM t WHERE lat > 0" ).to_dataset (
494+ sparsity = "template" , fill_value = - 1
487495 )
488496 assert np .issubdtype (out ["v" ].dtype , np .integer )
489- assert out ["v" ].sel (lat = 0 , lon = 10 ).item () == - 1
490- assert out ["v" ].sel (lat = 2 , lon = 11 ).item () == 201
491-
492-
493- def test_drop_null_dim_rows_warns_once ():
494- """Rows with NaN dim values are dropped with exactly one warning."""
495- from xarray_sql .ds import _eager_to_xarray
496-
497- df = pd .DataFrame (
498- [(0 , 0 , 1.0 ), (np .nan , 0 , 2.0 ), (1 , 0 , 3.0 )],
499- columns = ["lat" , "lon" , "v" ],
500- )
501- with pytest .warns (UserWarning , match = "Dropping 1 row" ):
502- out = _eager_to_xarray (df , dimension_columns = ["lat" , "lon" ])
503- assert out .sizes ["lat" ] == 2
504- assert out .sizes ["lon" ] == 1
497+ assert (out ["v" ].sel (lat = 0 ).values == - 1 ).all ()
498+ assert out ["v" ].sel (lat = 2 , lon = 11 ).item () == 6
505499
506500
507- def test_sparse_extent_template_then_metadata (air_dataset_small ):
508- """sparse_extent ='template' composes with template metadata recovery."""
501+ def test_sparsity_template_then_metadata (air_dataset_small ):
502+ """sparsity ='template' composes with template metadata recovery."""
509503 ds = air_dataset_small .copy ()
510504 ds .attrs = {"src" : "tmpl" }
511505 ds ["air" ].attrs = {"units" : "K" }
512506 ctx = XarrayContext ()
513507 ctx .from_dataset ("air" , ds )
514508 threshold = float (ds ["lat" ].values [5 ])
515509 out = ctx .sql (f"SELECT * FROM air WHERE lat > { threshold } " ).to_dataset (
516- sparse_extent = "template"
510+ sparsity = "template"
517511 )
518512 assert out .attrs == {"src" : "tmpl" }
519513 assert out ["air" ].attrs == {"units" : "K" }
0 commit comments