Skip to content

Commit 0e122f5

Browse files
authored
fix(#319): strip H3 index/partition columns from value_columns (#320)
register_hex_tiles listed every non-leading SELECT column as a value_column, including H3 join/partition keys (h0, h3, …) the caller carries through. Since downstream defaults value_column to value_columns[0], a `SELECT h8, h0, <value>` that omits value_column would color the map by meaningless H3 integers instead of the metric, and h0 also polluted suggested_scale + cost a wasted value_stats scan. Filter sql_value_columns through a `^h\d+$` match in prepare_hex_tiles, before the empty-value guard so an H3-only SELECT still raises. The build is unaffected: Phase 1 derives h0 itself, so partitioning never depended on the caller carrying it.
1 parent 0ec68a9 commit 0e122f5

2 files changed

Lines changed: 79 additions & 4 deletions

File tree

tests/test_tile_pyramid.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,12 @@ def test_non_count_requires_value_columns(self):
197197
import os
198198
import duckdb
199199
from pathlib import Path
200-
from tiles.pyramid import register_hex_tiles, _inspect_user_sql
200+
from tiles.pyramid import (
201+
register_hex_tiles,
202+
prepare_hex_tiles,
203+
_inspect_user_sql,
204+
_strip_h3_columns,
205+
)
201206

202207

203208
@pytest.fixture
@@ -238,6 +243,54 @@ def test_still_extracts_value_cols_when_present(self, h3_conn):
238243
assert value_cols == ["v1", "v2"]
239244

240245

246+
class TestStripH3Columns:
247+
# #319: carried H3 index/partition columns (h0, h3, …) are not values and
248+
# must never reach value_columns — downstream defaults value_column to
249+
# value_columns[0] and would color by meaningless H3 integers.
250+
def test_drops_h0_partition_key(self):
251+
assert _strip_h3_columns(["h0", "hw_frac"]) == ["hw_frac"]
252+
253+
def test_drops_any_h_res_index(self):
254+
assert _strip_h3_columns(["h3", "h8", "val", "h0"]) == ["val"]
255+
256+
def test_keeps_non_h3_columns(self):
257+
assert _strip_h3_columns(["val", "mean_carbon", "frac"]) == [
258+
"val", "mean_carbon", "frac"]
259+
260+
def test_does_not_drop_hprefixed_word_columns(self):
261+
# Only bare `h<digits>` are H3 columns; `height`, `hw_frac`, `h_index`
262+
# are genuine values and must survive.
263+
assert _strip_h3_columns(["height", "hw_frac", "h_index"]) == [
264+
"height", "hw_frac", "h_index"]
265+
266+
267+
class TestPrepareValueColumns:
268+
def test_carried_h0_excluded_from_value_columns(self, local_bucket, h3_conn):
269+
# #319: SELECT that carries the h0 partition key through for a join must
270+
# not advertise h0 as a value column.
271+
user_sql = (
272+
"SELECT h3_latlng_to_cell(37.8, -122.3, 5) AS h5, "
273+
"h3_cell_to_parent(h3_latlng_to_cell(37.8, -122.3, 5), 0) AS h0, "
274+
"0.5 AS hw_frac"
275+
)
276+
plan = prepare_hex_tiles(
277+
con=h3_conn, sql=user_sql, finest_res=5, min_res=2, agg="SUM",
278+
)
279+
assert plan["value_columns"] == ["hw_frac"]
280+
281+
def test_only_h3_columns_raises(self, h3_conn):
282+
# SELECT h8, h0 with no real value: after stripping h0, nothing is left,
283+
# so the existing empty-value guard must still fire (agg != COUNT).
284+
user_sql = (
285+
"SELECT h3_latlng_to_cell(37.8, -122.3, 8) AS h8, "
286+
"h3_cell_to_parent(h3_latlng_to_cell(37.8, -122.3, 8), 0) AS h0"
287+
)
288+
with pytest.raises(ValueError, match="at least one value column"):
289+
prepare_hex_tiles(
290+
con=h3_conn, sql=user_sql, finest_res=8, min_res=2, agg="SUM",
291+
)
292+
293+
241294
class TestRegisterHexTiles:
242295
def test_finest_h0_partitioned_coarse_levels_single_file(self, local_bucket, h3_conn):
243296
# #189: only the FINEST level is partitioned by h0 (file-level pruning

tiles/pyramid.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66
import json
77
import os
8+
import re
89
import sys
910
import time
1011
from decimal import Decimal
@@ -267,6 +268,22 @@ def _json_dumps_escaped(obj) -> str:
267268
return json.dumps(obj).replace("'", "''")
268269

269270

271+
# H3 cell/index/partition columns follow the `h<res>` convention (h0..h15) —
272+
# `h0` is the hive partition key the pyramid build derives itself, and callers
273+
# routinely carry an `h<res>` index column through the SELECT for joins. None of
274+
# these are values, so they must not appear in value_columns (#319): they'd
275+
# corrupt suggested_scale, waste a value_stats scan, and — since downstream
276+
# defaults value_column to value_columns[0] — color the map by meaningless H3
277+
# integers instead of the real metric.
278+
_H3_COLUMN_RE = re.compile(r"^h\d+$")
279+
280+
281+
def _strip_h3_columns(columns: List[str]) -> List[str]:
282+
"""Drop H3 cell/index/partition columns (names matching `^h\\d+$`) from a
283+
list of candidate value columns. See #319."""
284+
return [c for c in columns if not _H3_COLUMN_RE.match(c)]
285+
286+
270287
def _inspect_user_sql(con: duckdb.DuckDBPyConnection, user_sql: str):
271288
"""Run user SQL with LIMIT 0 to extract column names without materializing data.
272289
@@ -453,12 +470,15 @@ def prepare_hex_tiles(
453470
if agg.upper() == "COUNT":
454471
value_columns = ["count"]
455472
else:
456-
if not sql_value_columns:
473+
# Drop carried H3 index/partition columns (h0, h3, …) so a partition key
474+
# is never advertised as a value (#319). Runs before the empty-check so
475+
# `SELECT h8, h0` (no real value) still raises below.
476+
value_columns = _strip_h3_columns(sql_value_columns)
477+
if not value_columns:
457478
raise ValueError(
458479
"user SQL must return at least one value column after the H3 index "
459480
"(or use agg='COUNT')"
460481
)
461-
value_columns = sql_value_columns
462482

463483
h = content_hash(sql=sql, finest_res=finest_res, min_res=min_res, agg=agg, zoom_offset=zoom_offset)
464484
paths = tile_paths_for_hash(h)
@@ -647,7 +667,9 @@ def register_hex_tiles(
647667
Any extra columns in the user SQL are ignored.
648668
- Other aggs: user SQL must return at least one value column after the H3
649669
index. Each is aggregated via `agg` at parent resolutions and passed
650-
through raw at the finest level.
670+
through raw at the finest level. Carried H3 index/partition columns
671+
(names matching `h<res>`, e.g. h0, h3) are dropped from value_columns —
672+
they're join/partition keys, not values (#319).
651673
"""
652674
plan = prepare_hex_tiles(
653675
con=con, sql=sql, finest_res=finest_res,

0 commit comments

Comments
 (0)