Skip to content

Commit bae7c17

Browse files
authored
Merge pull request #151 from bluedynamics/fix/field-range-numeric-minmax
fix(query): _field_range numeric cast + min/max normalization (#150)
2 parents 6a0a750 + dadb2ca commit bae7c17

3 files changed

Lines changed: 140 additions & 10 deletions

File tree

CHANGES.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@
44

55
### Fixed
66

7+
- ``_field_range`` on FieldIndex no longer silently returns zero rows
8+
for numeric range queries. Two stacked bugs: ``[max, min]`` order
9+
from the caller was not normalized (produced always-false SQL), and
10+
``idx->>'key'`` comparisons ran lexicographically on text — so
11+
``'46.1' <= '5.0' <= '49.0'`` included values outside the range.
12+
``_field_range`` now sorts min/max and casts ``idx->>'key'`` to
13+
``::numeric`` when the range values are ``int`` / ``float``. String
14+
values (ISO dates etc.) keep text comparison. Affected aaf-6 prod
15+
map-widget bbox filter via ``collective.collectionfilter`` — closes
16+
#150.
17+
718
- `_CatalogCompat.getIndex` and `_CatalogIndexesView.__getitem__` no
819
longer silently fall back to the raw ZCatalog index when they
920
cannot find the catalog tool — that fallback returned empty BTrees

src/plone/pgcatalog/query.py

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,16 @@ def _bool_to_lower_str(value):
7777
return str(value)
7878

7979

80+
def _is_numeric_range(values):
81+
"""Return True iff every value is numeric (``int`` or ``float``).
82+
83+
``bool`` is deliberately excluded even though Python treats it as
84+
``int`` — a boolean range is nonsensical and should use the plain
85+
text path.
86+
"""
87+
return all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in values)
88+
89+
8090
def build_query(query_dict):
8191
"""Translate a ZCatalog query dict into SQL components.
8292
@@ -307,23 +317,44 @@ def _handle_field(self, name, idx_key, spec):
307317
self.params[p] = _bool_to_lower_str(not_val)
308318

309319
def _field_range(self, idx_key, value, range_spec):
320+
"""Emit a range clause for a FieldIndex.
321+
322+
Two correctness properties matter (see #150):
323+
324+
1. **Min/max normalization.** ``plone.app.querystring`` and
325+
``collective.collectionfilter`` pass values in caller order,
326+
not sorted. ZCatalog's FieldIndex silently normalizes; we
327+
must do the same or caller-supplied ``[max, min]`` produces
328+
always-false SQL.
329+
2. **Numeric cast.** ``idx->>'key'`` returns ``text``; ``>=`` /
330+
``<=`` on text is lexicographic — ``'46.1' <= '5.0'`` is
331+
true. For numeric values we cast to ``::numeric`` so the
332+
comparison is arithmetic. String values keep plain text
333+
comparison (correct for ISO-format dates and similar
334+
lexicographically-orderable strings).
335+
"""
310336
if range_spec in ("min:max", "minmax") and isinstance(value, (list, tuple)):
337+
v0, v1 = value[0], value[1]
338+
v_min, v_max = (v0, v1) if v0 <= v1 else (v1, v0)
339+
cast = "::numeric" if _is_numeric_range((v_min, v_max)) else ""
340+
col = f"(idx->>'{idx_key}'){cast}" if cast else f"idx->>'{idx_key}'"
311341
p_min = self._pname(idx_key + "_min")
312342
p_max = self._pname(idx_key + "_max")
313-
self.clauses.append(
314-
f"(idx->>'{idx_key}' >= %({p_min})s"
315-
f" AND idx->>'{idx_key}' <= %({p_max})s)"
316-
)
317-
self.params[p_min] = _bool_to_lower_str(value[0])
318-
self.params[p_max] = _bool_to_lower_str(value[1])
343+
self.clauses.append(f"({col} >= %({p_min})s AND {col} <= %({p_max})s)")
344+
self.params[p_min] = v_min if cast else _bool_to_lower_str(v_min)
345+
self.params[p_max] = v_max if cast else _bool_to_lower_str(v_max)
319346
elif range_spec == "min":
347+
cast = "::numeric" if _is_numeric_range((value,)) else ""
348+
col = f"(idx->>'{idx_key}'){cast}" if cast else f"idx->>'{idx_key}'"
320349
p = self._pname(idx_key)
321-
self.clauses.append(f"idx->>'{idx_key}' >= %({p})s")
322-
self.params[p] = _bool_to_lower_str(value)
350+
self.clauses.append(f"{col} >= %({p})s")
351+
self.params[p] = value if cast else _bool_to_lower_str(value)
323352
elif range_spec == "max":
353+
cast = "::numeric" if _is_numeric_range((value,)) else ""
354+
col = f"(idx->>'{idx_key}'){cast}" if cast else f"idx->>'{idx_key}'"
324355
p = self._pname(idx_key)
325-
self.clauses.append(f"idx->>'{idx_key}' <= %({p})s")
326-
self.params[p] = _bool_to_lower_str(value)
356+
self.clauses.append(f"{col} <= %({p})s")
357+
self.params[p] = value if cast else _bool_to_lower_str(value)
327358

328359
# -- KeywordIndex -------------------------------------------------------
329360

tests/test_query.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -882,6 +882,94 @@ def test_dynamic_field_multi_value(self, populated_registry):
882882
assert "= ANY(" in qr["where"]
883883

884884

885+
class TestFieldRangeNumeric:
886+
"""Regression for #150 — FieldIndex range on numeric fields.
887+
888+
Two stacked bugs the old ``_field_range`` had:
889+
890+
1. No min/max normalization: caller-supplied ``[max, min]`` produced
891+
always-false SQL. ``plone.app.querystring`` / ``collective.collectionfilter``
892+
pass values in URL order and do not sort.
893+
2. Lexicographic string comparison on ``idx->>'key'`` — wrong for
894+
numeric fields (``'46.1' <= '5.0' <= '49.0'`` returns true).
895+
896+
Both shipped together — the map-widget bbox filter on aaf-6 silently
897+
returned zero rows.
898+
"""
899+
900+
def _register(self, name):
901+
from plone.pgcatalog.columns import get_registry
902+
from plone.pgcatalog.columns import IndexType
903+
904+
get_registry().register(name, IndexType.FIELD, name)
905+
906+
def test_numeric_field_range_casts_to_numeric(self, populated_registry):
907+
self._register("latitude")
908+
qr = build_query({"latitude": {"query": [46.1, 49.0], "range": "minmax"}})
909+
assert "(idx->>'latitude')::numeric" in qr["where"]
910+
# min param gets the lower value, max param the upper — regardless
911+
# of the caller-supplied order.
912+
params = qr["params"]
913+
min_key = next(k for k in params if k.endswith("_min_1"))
914+
max_key = next(k for k in params if k.endswith("_max_2"))
915+
assert params[min_key] == 46.1
916+
assert params[max_key] == 49.0
917+
918+
def test_numeric_field_range_normalizes_reversed_order(self, populated_registry):
919+
self._register("latitude")
920+
# Caller sends [max, min] — collective.collectionfilter's map
921+
# widget does this on every pan/zoom.
922+
qr = build_query({"latitude": {"query": [49.0, 46.1], "range": "minmax"}})
923+
params = qr["params"]
924+
min_key = next(k for k in params if k.endswith("_min_1"))
925+
max_key = next(k for k in params if k.endswith("_max_2"))
926+
assert params[min_key] == 46.1
927+
assert params[max_key] == 49.0
928+
929+
def test_numeric_field_range_int(self, populated_registry):
930+
self._register("priority")
931+
qr = build_query({"priority": {"query": [5, 1], "range": "minmax"}})
932+
assert "(idx->>'priority')::numeric" in qr["where"]
933+
params = qr["params"]
934+
min_key = next(k for k in params if k.endswith("_min_1"))
935+
max_key = next(k for k in params if k.endswith("_max_2"))
936+
assert params[min_key] == 1
937+
assert params[max_key] == 5
938+
939+
def test_numeric_field_range_min_only(self, populated_registry):
940+
self._register("latitude")
941+
qr = build_query({"latitude": {"query": 46.1, "range": "min"}})
942+
assert "(idx->>'latitude')::numeric >=" in qr["where"]
943+
944+
def test_numeric_field_range_max_only(self, populated_registry):
945+
self._register("latitude")
946+
qr = build_query({"latitude": {"query": 49.0, "range": "max"}})
947+
assert "(idx->>'latitude')::numeric <=" in qr["where"]
948+
949+
def test_string_field_range_keeps_text_comparison(self, populated_registry):
950+
"""Non-numeric values continue to use plain ``idx->>'key'``
951+
comparison (correct for ISO-format dates and similar
952+
lexicographically-orderable strings).
953+
"""
954+
self._register("getId")
955+
qr = build_query({"getId": {"query": ["a", "m"], "range": "minmax"}})
956+
assert "::numeric" not in qr["where"]
957+
assert "idx->>'getId' >=" in qr["where"]
958+
assert "idx->>'getId' <=" in qr["where"]
959+
960+
def test_string_field_range_normalizes_order(self, populated_registry):
961+
"""Normalization applies regardless of type — ``_field_range`` sorts
962+
caller-supplied values even for strings.
963+
"""
964+
self._register("getId")
965+
qr = build_query({"getId": {"query": ["m", "a"], "range": "minmax"}})
966+
params = qr["params"]
967+
min_key = next(k for k in params if k.endswith("_min_1"))
968+
max_key = next(k for k in params if k.endswith("_max_2"))
969+
assert params[min_key] == "a"
970+
assert params[max_key] == "m"
971+
972+
885973
class TestDynamicKeywordIndex:
886974
"""KeywordIndex dynamically registered via registry."""
887975

0 commit comments

Comments
 (0)