Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 10 additions & 1 deletion src/plone/pgcatalog/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions tests/test_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading