Coverage for haystack/utils/filters.py: 100%
158 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2#
3# SPDX-License-Identifier: Apache-2.0
5from dataclasses import fields
6from datetime import datetime
7from typing import Any
9import dateutil.parser
11from haystack.dataclasses import ByteStream, Document
12from haystack.errors import FilterError
15def document_matches_filter(
16 filters: dict[str, Any], document: Document | ByteStream, *, strict_datetime_comparison: bool = False
17) -> bool:
18 """
19 Return whether `filters` match the Document or the ByteStream.
21 For a detailed specification of the filters, refer to the
22 `DocumentStore.filter_documents()` protocol documentation.
24 :param strict_datetime_comparison:
25 If `True`, timezone-naive and timezone-aware datetimes never match each other.
26 If `False` (the default), the timezone from the aware datetime is copied to the naive one before comparing.
27 """
28 if "field" in filters:
29 return _comparison_condition(
30 condition=filters, document=document, strict_datetime_comparison=strict_datetime_comparison
31 )
32 return _logic_condition(condition=filters, document=document, strict_datetime_comparison=strict_datetime_comparison)
35def _and(document: Document | ByteStream, conditions: list[dict[str, Any]], strict_datetime_comparison: bool) -> bool:
36 return all(
37 _comparison_condition(
38 condition=condition, document=document, strict_datetime_comparison=strict_datetime_comparison
39 )
40 for condition in conditions
41 )
44def _or(document: Document | ByteStream, conditions: list[dict[str, Any]], strict_datetime_comparison: bool) -> bool:
45 return any(
46 _comparison_condition(
47 condition=condition, document=document, strict_datetime_comparison=strict_datetime_comparison
48 )
49 for condition in conditions
50 )
53def _not(document: Document | ByteStream, conditions: list[dict[str, Any]], strict_datetime_comparison: bool) -> bool:
54 return not _and(document=document, conditions=conditions, strict_datetime_comparison=strict_datetime_comparison)
57LOGICAL_OPERATORS = {"NOT": _not, "OR": _or, "AND": _and}
60def _equal(value: Any, filter_value: Any, strict_datetime_comparison: bool) -> bool:
61 if value == filter_value:
62 return True
63 return _dates_are_equal(value=value, filter_value=filter_value, strict=strict_datetime_comparison)
66def _not_equal(value: Any, filter_value: Any, strict_datetime_comparison: bool) -> bool:
67 return not _equal(value=value, filter_value=filter_value, strict_datetime_comparison=strict_datetime_comparison)
70def _looks_like_iso_date(value: Any) -> bool:
71 """
72 Cheaply reject values that can't be a full ISO 8601 date, before paying for a real parse.
74 Deliberately conservative: a complete `YYYY-MM-DD` prefix is required, so partial dates such as
75 "2025" are not read as dates, and the ISO 8601 basic format ("20250203") is not recognised.
76 """
77 return (
78 isinstance(value, str)
79 and len(value) >= 10
80 and value[:4].isdigit()
81 and value[4] == "-"
82 and value[5:7].isdigit()
83 and value[7] == "-"
84 and value[8:10].isdigit()
85 and (len(value) == 10 or value[10] in {"T", "t", " "})
86 )
89def _parse_iso_date(value: str) -> datetime | None:
90 """Parse a strict ISO 8601 string, returning None if it isn't one."""
91 try:
92 return datetime.fromisoformat(value)
93 except ValueError:
94 # Python 3.10's fromisoformat rejects valid ISO 8601 spellings that later versions accept,
95 # most notably a trailing "Z".
96 try:
97 return dateutil.parser.isoparse(value)
98 except (ValueError, OverflowError):
99 return None
102def _dates_are_equal(value: Any, filter_value: Any, strict: bool) -> bool:
103 """
104 Return whether both values are ISO 8601 datetimes denoting the same point in time.
106 Only strict ISO 8601 strings and `datetime` objects are considered. Mixed-awareness datetimes are
107 reconciled unless strict mode is enabled. Returns False for anything that isn't a pair of comparable
108 datetimes, so that `==` stays total.
109 """
110 parsed: list[datetime] = []
111 for candidate in (value, filter_value):
112 if isinstance(candidate, datetime):
113 parsed.append(candidate)
114 continue
115 if not _looks_like_iso_date(candidate):
116 return False
117 date = _parse_iso_date(candidate)
118 if date is None:
119 return False
120 parsed.append(date)
122 first, second = parsed
123 if strict and (first.tzinfo is None) != (second.tzinfo is None):
124 return False
125 first, second = _ensure_both_dates_naive_or_aware(first, second)
126 return first == second
129def _prepare_ordering_comparison(
130 value: Any, filter_value: Any, strict_datetime_comparison: bool
131) -> tuple[Any, Any, bool]:
132 """
133 Normalize both values for ordering comparisons, parsing strings as dates.
135 :returns:
136 A tuple containing the normalized value, normalized filter value, and whether the values are comparable.
137 The boolean is `False` when strict datetime comparison is enabled and one datetime is timezone-naive while
138 the other is timezone-aware; otherwise it is `True`.
139 """
140 if isinstance(value, str) or isinstance(filter_value, str):
141 if not isinstance(value, datetime):
142 value = _parse_date(value)
143 if not isinstance(filter_value, datetime):
144 filter_value = _parse_date(filter_value)
146 if isinstance(value, datetime) and isinstance(filter_value, datetime):
147 if strict_datetime_comparison and (value.tzinfo is None) != (filter_value.tzinfo is None):
148 return value, filter_value, False
149 value, filter_value = _ensure_both_dates_naive_or_aware(value, filter_value)
151 if isinstance(filter_value, list):
152 msg = f"Filter value can't be of type {type(filter_value)} using operators '>', '>=', '<', '<='"
153 raise FilterError(msg)
154 return value, filter_value, True
157def _greater_than(value: Any, filter_value: Any, strict_datetime_comparison: bool) -> bool:
158 if value is None or filter_value is None:
159 # We can't compare None values reliably using operators '>', '>=', '<', '<='
160 return False
162 value, filter_value, comparable = _prepare_ordering_comparison(
163 value=value, filter_value=filter_value, strict_datetime_comparison=strict_datetime_comparison
164 )
165 if not comparable:
166 return False
167 return value > filter_value
170def _parse_date(value: str) -> datetime:
171 """Try parsing the value as an ISO format date, then fall back to dateutil.parser."""
172 try:
173 return datetime.fromisoformat(value)
174 except (ValueError, TypeError):
175 try:
176 return dateutil.parser.parse(value)
177 except (ValueError, TypeError) as exc:
178 msg = (
179 "Can't compare strings using operators '>', '>=', '<', '<='. "
180 "Strings are only comparable if they are ISO formatted dates."
181 )
182 raise FilterError(msg) from exc
185def _ensure_both_dates_naive_or_aware(date1: datetime, date2: datetime) -> tuple[datetime, datetime]:
186 """Ensure that both dates are either naive or aware."""
187 # Both naive
188 if date1.tzinfo is None and date2.tzinfo is None:
189 return date1, date2
191 # Both aware
192 if date1.tzinfo is not None and date2.tzinfo is not None:
193 return date1, date2
195 # One naive, one aware
196 if date1.tzinfo is None:
197 date1 = date1.replace(tzinfo=date2.tzinfo)
198 else:
199 date2 = date2.replace(tzinfo=date1.tzinfo)
200 return date1, date2
203def _greater_than_equal(value: Any, filter_value: Any, strict_datetime_comparison: bool) -> bool:
204 if value is None or filter_value is None:
205 # We can't compare None values reliably using operators '>', '>=', '<', '<='
206 return False
208 value, filter_value, comparable = _prepare_ordering_comparison(
209 value=value, filter_value=filter_value, strict_datetime_comparison=strict_datetime_comparison
210 )
211 if not comparable:
212 return False
213 return value >= filter_value
216def _less_than(value: Any, filter_value: Any, strict_datetime_comparison: bool) -> bool:
217 if value is None or filter_value is None:
218 # We can't compare None values reliably using operators '>', '>=', '<', '<='
219 return False
221 value, filter_value, comparable = _prepare_ordering_comparison(
222 value=value, filter_value=filter_value, strict_datetime_comparison=strict_datetime_comparison
223 )
224 if not comparable:
225 return False
226 return value < filter_value
229def _less_than_equal(value: Any, filter_value: Any, strict_datetime_comparison: bool) -> bool:
230 if value is None or filter_value is None:
231 # We can't compare None values reliably using operators '>', '>=', '<', '<='
232 return False
234 value, filter_value, comparable = _prepare_ordering_comparison(
235 value=value, filter_value=filter_value, strict_datetime_comparison=strict_datetime_comparison
236 )
237 if not comparable:
238 return False
239 return value <= filter_value
242def _in(value: Any, filter_value: Any, strict_datetime_comparison: bool) -> bool:
243 if not isinstance(filter_value, list):
244 msg = (
245 f"Filter value must be a `list` when using operator 'in' or 'not in', received type '{type(filter_value)}'"
246 )
247 raise FilterError(msg)
248 return any(_equal(e, value, strict_datetime_comparison) for e in filter_value)
251def _not_in(value: Any, filter_value: Any, strict_datetime_comparison: bool) -> bool:
252 return not _in(value=value, filter_value=filter_value, strict_datetime_comparison=strict_datetime_comparison)
255COMPARISON_OPERATORS = {
256 "==": _equal,
257 "!=": _not_equal,
258 ">": _greater_than,
259 ">=": _greater_than_equal,
260 "<": _less_than,
261 "<=": _less_than_equal,
262 "in": _in,
263 "not in": _not_in,
264}
267def _logic_condition(
268 condition: dict[str, Any], document: Document | ByteStream, strict_datetime_comparison: bool
269) -> bool:
270 if "operator" not in condition:
271 msg = f"'operator' key missing in {condition}"
272 raise FilterError(msg)
273 if "conditions" not in condition:
274 msg = f"'conditions' key missing in {condition}"
275 raise FilterError(msg)
276 operator: str = condition["operator"]
277 if operator not in LOGICAL_OPERATORS:
278 msg = f"Unknown logical operator '{operator}'. Valid operators are: {sorted(LOGICAL_OPERATORS)}"
279 raise FilterError(msg)
280 conditions: list[dict[str, Any]] = condition["conditions"]
281 return LOGICAL_OPERATORS[operator](
282 document=document, conditions=conditions, strict_datetime_comparison=strict_datetime_comparison
283 )
286def _comparison_condition(
287 condition: dict[str, Any], document: Document | ByteStream, strict_datetime_comparison: bool
288) -> bool:
289 if "field" not in condition:
290 # 'field' key is only found in comparison dictionaries.
291 # We assume this is a logic dictionary since it's not present.
292 return _logic_condition(
293 condition=condition, document=document, strict_datetime_comparison=strict_datetime_comparison
294 )
295 field: str = condition["field"]
297 if "operator" not in condition:
298 msg = f"'operator' key missing in {condition}"
299 raise FilterError(msg)
300 if "value" not in condition:
301 msg = f"'value' key missing in {condition}"
302 raise FilterError(msg)
304 if "." in field:
305 # Handles fields formatted like so:
306 # 'meta.person.name'
307 parts = field.split(".")
308 document_value = getattr(document, parts[0])
309 for part in parts[1:]:
310 if not isinstance(document_value, dict) or part not in document_value:
311 # If a field is not found (or an intermediate value is not a dict,
312 # e.g. None) we treat it as None
313 document_value = None
314 break
315 document_value = document_value[part]
316 elif field not in [f.name for f in fields(document)]:
317 # Converted legacy filters don't add the `meta.` prefix, so we assume
318 # that all filter fields that are not actual fields in Document are converted
319 # filters.
320 #
321 # We handle this to avoid breaking compatibility with converted legacy filters.
322 # This will be removed as soon as we stop supporting legacy filters.
323 document_value = document.meta.get(field)
324 else:
325 document_value = getattr(document, field)
326 operator: str = condition["operator"]
327 if operator not in COMPARISON_OPERATORS:
328 msg = f"Unknown comparison operator '{operator}'. Valid operators are: {sorted(COMPARISON_OPERATORS)}"
329 raise FilterError(msg)
330 filter_value: Any = condition["value"]
331 return COMPARISON_OPERATORS[operator](
332 filter_value=filter_value, value=document_value, strict_datetime_comparison=strict_datetime_comparison
333 )