|
18 | 18 | except ImportError: |
19 | 19 | pytestmark = pytest.mark.skip(reason="Duckdb is not installed") |
20 | 20 |
|
| 21 | +try: |
| 22 | + import geopandas as gpd |
| 23 | + |
| 24 | + from shapely.geometry import Polygon |
| 25 | +except ImportError: |
| 26 | + gpd = None |
| 27 | + |
21 | 28 |
|
22 | 29 | @pytest.fixture |
23 | 30 | def duckdb_file_source(): |
@@ -239,7 +246,10 @@ def test_duckdb_transforms_cache(duckdb_source, source_tables): |
239 | 246 | assert cache_key in duckdb_source._cache |
240 | 247 |
|
241 | 248 | expected = df_test_sql.groupby('B')['A'].sum().reset_index() |
242 | | - pd.testing.assert_frame_equal(duckdb_source._cache[cache_key], expected) |
| 249 | + # DuckDB GROUP BY returns rows in a nondeterministic order; sort by the |
| 250 | + # group key before comparing against pandas' sorted groupby output. |
| 251 | + actual = duckdb_source._cache[cache_key].sort_values('B').reset_index(drop=True) |
| 252 | + pd.testing.assert_frame_equal(actual, expected) |
243 | 253 |
|
244 | 254 | cache_key = duckdb_source._get_key('test_sql', sql_transforms=transforms) |
245 | 255 | assert cache_key in duckdb_source._cache |
@@ -1345,3 +1355,92 @@ def test_read_only_create_sql_expr_source_expands_tables(duckdb_file_source): |
1345 | 1355 |
|
1346 | 1356 | # Ensure new tables did NOT modify the original source |
1347 | 1357 | assert source.execute("SHOW TABLES").shape[0] == 1 # Only 'test' table in original source |
| 1358 | + |
| 1359 | + |
| 1360 | +def _spatial_source(): |
| 1361 | + """Build an in-memory DuckDBSource holding a native GEOMETRY table. |
| 1362 | +
|
| 1363 | + Mirrors the ingest flow: WKB bytes -> ST_GeomFromWKB -> GEOMETRY column. |
| 1364 | + Skips if the duckdb spatial extension cannot be loaded (needs network on |
| 1365 | + first install). |
| 1366 | + """ |
| 1367 | + if gpd is None: |
| 1368 | + pytest.skip("geopandas is not installed") |
| 1369 | + try: |
| 1370 | + source = DuckDBSource( |
| 1371 | + uri=':memory:', |
| 1372 | + initializers=["INSTALL spatial;", "LOAD spatial;"], |
| 1373 | + tables={'geo': 'SELECT * FROM geo_tbl'}, |
| 1374 | + ) |
| 1375 | + except Exception as e: # pragma: no cover - environment dependent |
| 1376 | + pytest.skip(f"duckdb spatial extension unavailable: {e}") |
| 1377 | + gdf = gpd.GeoDataFrame( |
| 1378 | + { |
| 1379 | + 'name': ['a', 'b'], |
| 1380 | + 'pop': [1, 2], |
| 1381 | + 'geometry': [ |
| 1382 | + Polygon([(0, 0), (1, 0), (1, 1)]), |
| 1383 | + Polygon([(2, 0), (3, 0), (3, 1)]), |
| 1384 | + ], |
| 1385 | + }, |
| 1386 | + crs='EPSG:4326', |
| 1387 | + ) |
| 1388 | + tmp = pd.DataFrame( |
| 1389 | + {'name': gdf['name'], 'pop': gdf['pop'], 'geometry': gdf['geometry'].to_wkb()} |
| 1390 | + ) |
| 1391 | + source._connection.register('geo_temp', tmp) |
| 1392 | + source._connection.execute( |
| 1393 | + 'CREATE TABLE geo_tbl AS ' |
| 1394 | + 'SELECT name, pop, ST_GeomFromWKB(geometry) AS geometry FROM geo_temp' |
| 1395 | + ) |
| 1396 | + return source, gpd |
| 1397 | + |
| 1398 | + |
| 1399 | +def test_duckdb_geometry_returns_geodataframe(): |
| 1400 | + """A native GEOMETRY column round-trips to a GeoDataFrame without crashing.""" |
| 1401 | + source, gpd = _spatial_source() |
| 1402 | + result = source.get('geo') |
| 1403 | + assert isinstance(result, gpd.GeoDataFrame) |
| 1404 | + assert 'geometry' in result.columns |
| 1405 | + assert len(result) == 2 |
| 1406 | + assert result.geometry.notna().all() |
| 1407 | + assert str(result.geometry.dtype) == 'geometry' |
| 1408 | + |
| 1409 | + |
| 1410 | +def test_duckdb_geometry_crs_none_by_default(): |
| 1411 | + """Without geometry_crs set the CRS stays None (WKB carries none); no regression.""" |
| 1412 | + source, gpd = _spatial_source() |
| 1413 | + assert source.get('geo').crs is None |
| 1414 | + |
| 1415 | + |
| 1416 | +def test_duckdb_geometry_crs_preserved(): |
| 1417 | + """geometry_crs is reapplied when rebuilding the GeoDataFrame (gh-1904).""" |
| 1418 | + source, gpd = _spatial_source() |
| 1419 | + source.geometry_crs = 'EPSG:4326' |
| 1420 | + result = source.get('geo') |
| 1421 | + assert result.crs is not None |
| 1422 | + assert result.crs.to_epsg() == 4326 |
| 1423 | + |
| 1424 | + |
| 1425 | +def test_duckdb_geometry_crs_propagates_to_derived_source(): |
| 1426 | + """A source created via create_sql_expr_source keeps geometry_crs (gh-1904).""" |
| 1427 | + source, gpd = _spatial_source() |
| 1428 | + source.geometry_crs = 'EPSG:4326' |
| 1429 | + derived = source.create_sql_expr_source( |
| 1430 | + {'geo2': 'SELECT * FROM geo'}, materialize=False |
| 1431 | + ) |
| 1432 | + assert derived.geometry_crs == 'EPSG:4326' |
| 1433 | + assert derived.get('geo').crs.to_epsg() == 4326 |
| 1434 | + |
| 1435 | + |
| 1436 | +def test_duckdb_get_schema_geometry_no_distinct(): |
| 1437 | + """get_schema on a geometry table returns a geospatial marker and does not |
| 1438 | + run DISTINCT/min-max on the geometry column.""" |
| 1439 | + source, gpd = _spatial_source() |
| 1440 | + schema = source.get_schema('geo') |
| 1441 | + assert schema['geometry']['format'] == 'geometry' |
| 1442 | + assert 'enum' not in schema['geometry'] |
| 1443 | + assert 'inclusiveMinimum' not in schema['geometry'] |
| 1444 | + # non-geometry columns still summarised as usual |
| 1445 | + assert schema['pop']['inclusiveMinimum'] == 1 |
| 1446 | + assert schema['pop']['inclusiveMaximum'] == 2 |
0 commit comments