diff --git a/CHANGES.md b/CHANGES.md index 8a4e992..de0667f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,18 @@ # Changelog +## 1.0.0b62 + +### Fixed + +- ``_handle_keyword`` no longer crashes with ``TypeError: 'DateTime' + object is not iterable`` when a caller passes a non-str, non-iterable + value (e.g. a Zope ``DateTime`` or a Python ``datetime``) as the + query for a KeywordIndex. The old coercion assumed the value was + either a ``str`` or iterable; anything else (``DateTime``, ``int``, + …) hit ``list(value)`` and raised. Values are now coerced via + ``str()`` into a single-element list, matching the JSONB storage + shape (keyword arrays always contain strings). Closes #152. + ## 1.0.0b61 ### Fixed diff --git a/src/plone/pgcatalog/query.py b/src/plone/pgcatalog/query.py index 0d0f50d..2fc01ea 100644 --- a/src/plone/pgcatalog/query.py +++ b/src/plone/pgcatalog/query.py @@ -365,7 +365,16 @@ def _handle_keyword(self, name, idx_key, spec): operator = spec.get("operator", "or") - query_val = [query_val] if isinstance(query_val, str) else list(query_val) + # Coerce to a list of strings. The only "iterable" shape we want + # to expand is a real list/tuple/set — everything else (str, int, + # Python ``datetime``, Zope ``DateTime``, …) is a single-value + # scalar. Without this, ``list(scalar)`` raises ``TypeError: + # object is not iterable`` on Zope DateTime and friends; str() + # coercion matches what JSONB keyword arrays store (#152). + if isinstance(query_val, (list, tuple, set, frozenset)): + query_val = [str(v) for v in query_val] + else: + query_val = [str(query_val)] # Check for dedicated TEXT[] column (generic ExtraIdxColumn mechanism) from plone.pgcatalog.columns import get_extra_idx_column_for_key diff --git a/tests/test_query.py b/tests/test_query.py index 61d52e9..748787c 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -86,6 +86,44 @@ def test_default_operator_is_or(self): qr = build_query({"Subject": {"query": ["Python", "Zope"]}}) assert "?|" in qr["where"] + def test_non_iterable_non_str_coerced_to_single_value(self): + """Regression for #152: a caller accidentally passing a non-string, + non-iterable value (e.g. a Zope ``DateTime``, a number, etc.) must + not crash the query builder with ``TypeError: object is not + iterable``. Observed on aaf-6 prod where an addon passed a + ``DateTime`` through a keyword criterion by mistake. + """ + + class _NotIterable: + """Mimics Zope ``DateTime`` — has ``__str__`` but no + ``__iter__`` and isn't a ``str``.""" + + def __str__(self): + return "2026-04-20" + + qr = build_query({"Subject": _NotIterable()}) + # Falls into the single-value branch — containment (`@>`). + assert "idx @>" in qr["where"] + # Value was coerced to its str form. + param = _find_json_param(qr["params"]) + assert param.obj == {"Subject": ["2026-04-20"]} + + def test_zope_datetime_as_keyword_does_not_crash(self): + """The specific shape that surfaced in production: a Zope + ``DateTime`` passed as a keyword-index query value. It should + be coerced via ``str()`` into a single-element list, not crash. + """ + from datetime import datetime + from datetime import UTC + + # Python ``datetime`` is also not iterable — exactly the same + # bug class. + dt = datetime(2026, 4, 20, tzinfo=UTC) + qr = build_query({"Subject": dt}) + assert "idx @>" in qr["where"] + param = _find_json_param(qr["params"]) + assert len(param.obj["Subject"]) == 1 + # --------------------------------------------------------------------------- # BooleanIndex