exdrf 0.0.1.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. exdrf/__init__.py +0 -0
  2. exdrf/__version__.py +24 -0
  3. exdrf/api.py +51 -0
  4. exdrf/constants.py +30 -0
  5. exdrf/dataset.py +197 -0
  6. exdrf/field.py +554 -0
  7. exdrf/field_types/__init__.py +0 -0
  8. exdrf/field_types/api.py +78 -0
  9. exdrf/field_types/blob_field.py +44 -0
  10. exdrf/field_types/bool_field.py +47 -0
  11. exdrf/field_types/date_field.py +49 -0
  12. exdrf/field_types/date_time.py +52 -0
  13. exdrf/field_types/dur_field.py +44 -0
  14. exdrf/field_types/enum_field.py +41 -0
  15. exdrf/field_types/filter_field.py +11 -0
  16. exdrf/field_types/float_field.py +85 -0
  17. exdrf/field_types/float_list.py +18 -0
  18. exdrf/field_types/formatted.py +39 -0
  19. exdrf/field_types/int_field.py +70 -0
  20. exdrf/field_types/int_list.py +18 -0
  21. exdrf/field_types/ref_base.py +105 -0
  22. exdrf/field_types/ref_m2m.py +39 -0
  23. exdrf/field_types/ref_m2o.py +23 -0
  24. exdrf/field_types/ref_o2m.py +36 -0
  25. exdrf/field_types/ref_o2o.py +32 -0
  26. exdrf/field_types/sort_field.py +18 -0
  27. exdrf/field_types/str_field.py +77 -0
  28. exdrf/field_types/str_list.py +18 -0
  29. exdrf/field_types/time_field.py +49 -0
  30. exdrf/filter.py +653 -0
  31. exdrf/filter_dsl.py +950 -0
  32. exdrf/filter_op_catalog.py +222 -0
  33. exdrf/label_dsl.py +691 -0
  34. exdrf/moment.py +496 -0
  35. exdrf/py.typed +0 -0
  36. exdrf/py_support.py +21 -0
  37. exdrf/resource.py +901 -0
  38. exdrf/sa_fi_item.py +69 -0
  39. exdrf/sa_filter_op.py +324 -0
  40. exdrf/utils.py +17 -0
  41. exdrf/validator.py +45 -0
  42. exdrf/var_bag.py +328 -0
  43. exdrf/visitor.py +58 -0
  44. exdrf-0.0.1.dev0.dist-info/METADATA +42 -0
  45. exdrf-0.0.1.dev0.dist-info/RECORD +57 -0
  46. exdrf-0.0.1.dev0.dist-info/WHEEL +5 -0
  47. exdrf-0.0.1.dev0.dist-info/top_level.txt +3 -0
  48. exdrf_tests/__init__.py +0 -0
  49. exdrf_tests/test_dataset.py +422 -0
  50. exdrf_tests/test_field.py +109 -0
  51. exdrf_tests/test_filter.py +425 -0
  52. exdrf_tests/test_filter_dsl.py +556 -0
  53. exdrf_tests/test_label_dsl.py +234 -0
  54. exdrf_tests/test_resource.py +107 -0
  55. exdrf_tests/test_utils.py +43 -0
  56. exdrf_tests/test_visitor.py +31 -0
  57. exdrf_tests/var_bag_test.py +502 -0
exdrf/filter.py ADDED
@@ -0,0 +1,653 @@
1
+ """Filter support.
2
+
3
+ This is how the filter is imagined to show in JSON format:
4
+ ```json
5
+ {
6
+ "filter": [
7
+ {"fld": "id", "op": "eq", "vl": 0},
8
+ {"fld": "id", "op": "ne", "vl": 0},
9
+ {"fld": "name", "op": "eq", "vl": "This is a string"},
10
+ {"fld": "name", "op": "ne", "vl": "This is a string"},
11
+ [
12
+ "AND",
13
+ [
14
+ {"fld": "id", "op": "eq", "vl": 0},
15
+ {"fld": "id", "op": "ne", "vl": 0},
16
+ {"fld": "name", "op": "eq", "vl": "This is a string"},
17
+ {"fld": "name", "op": "ne", "vl": "This is a string"},
18
+ [
19
+ "OR",
20
+ [
21
+ ["NOT", {"fld": "id", "op": "eq", "vl": 0}],
22
+ ["NOT", {"fld": "id", "op": "ne", "vl": 0}],
23
+ [
24
+ "NOT",
25
+ {
26
+ "fld": "name",
27
+ "op": "eq",
28
+ "vl": "This is a string",
29
+ },
30
+ ],
31
+ [
32
+ "NOT",
33
+ {
34
+ "fld": "name",
35
+ "op": "ne",
36
+ "vl": "This is a string",
37
+ },
38
+ ],
39
+ ],
40
+ ],
41
+ ],
42
+ ],
43
+ [
44
+ "or",
45
+ [
46
+ {"fld": "id", "op": "eq", "vl": 0},
47
+ {"fld": "id", "op": "ne", "vl": 0},
48
+ {"fld": "name", "op": "eq", "vl": "This is a string"},
49
+ {"fld": "name", "op": "ne", "vl": "This is a string"},
50
+ ],
51
+ ],
52
+ ["not", {"fld": "id", "op": "eq", "vl": 0}],
53
+ ["not", {"fld": "id", "op": "ne", "vl": 0}],
54
+ ["not", {"fld": "name", "op": "eq", "vl": "This is a string"}],
55
+ ["not", {"fld": "name", "op": "ne", "vl": "This is a string"}],
56
+ ]
57
+ }
58
+ ```
59
+ """
60
+
61
+ import logging
62
+ import re
63
+ from enum import StrEnum
64
+ from typing import (
65
+ Any,
66
+ Iterator,
67
+ List,
68
+ Literal,
69
+ Optional,
70
+ Tuple,
71
+ TypedDict,
72
+ Union,
73
+ cast,
74
+ )
75
+
76
+ from attrs import define, field
77
+ from unidecode import unidecode
78
+
79
+ from exdrf.filter_op_catalog import (
80
+ FILTER_OP_EQ,
81
+ FILTER_OP_ILIKE,
82
+ FILTER_OP_REGEX,
83
+ )
84
+
85
+ logger = logging.getLogger(__name__)
86
+
87
+
88
+ @define(slots=True, kw_only=True)
89
+ class FieldFilter:
90
+ """Describes how the results should be filtered by one of the fields.
91
+
92
+ Attributes:
93
+ fld: The field to filter by. This is the unique string key of the field
94
+ in the resource.
95
+ op: The operation to perform. This is the unique string key of the
96
+ operation.
97
+ vl: The value to compare against. Its meaning depends on the operation.
98
+ """
99
+
100
+ fld: str
101
+ op: str
102
+ vl: Any
103
+
104
+ _no_dia: Optional[Tuple[str, str]] = field(default=None, repr=False, init=False)
105
+
106
+ def __getitem__(self, key: str) -> Any:
107
+ if key == "fld":
108
+ return self.fld
109
+ elif key == "op":
110
+ return self.op
111
+ elif key == "vl":
112
+ return self.vl
113
+ else:
114
+ raise KeyError(f"Unknown field: {key}")
115
+
116
+ def __setitem__(self, key: str, value: Any):
117
+ if key == "fld":
118
+ self.fld = value
119
+ elif key == "op":
120
+ self.op = value
121
+ elif key == "vl":
122
+ self.vl = value
123
+ else:
124
+ raise KeyError(f"Unknown field: {key}")
125
+
126
+ def __iter__(self) -> Iterator[Any]:
127
+ yield "fld", self.fld
128
+ yield "op", self.op
129
+ yield "vl", self.vl
130
+
131
+ def __len__(self) -> int:
132
+ return 3
133
+
134
+ def __contains__(self, key: str) -> bool:
135
+ return key in ("fld", "op", "vl") or False
136
+
137
+ @property
138
+ def unidecoded(self) -> str:
139
+ if self.vl is None:
140
+ return ""
141
+ if self._no_dia is None:
142
+ ud = unidecode(self.vl)
143
+ self._no_dia = (self.vl, ud)
144
+ return ud
145
+ else:
146
+ old_vl, ud = self._no_dia
147
+ if old_vl == self.vl:
148
+ return ud
149
+ else:
150
+ ud = unidecode(self.vl)
151
+ self._no_dia = (self.vl, ud)
152
+ return ud
153
+
154
+
155
+ class FieldFilterDict(TypedDict):
156
+ """A dictionary type that has the same keys as FieldFilter."""
157
+
158
+ fld: str # field name
159
+ op: str # operation type (e.g., "eq", "ne", "ilike", etc.)
160
+ vl: Any # value to filter by
161
+
162
+
163
+ LogicAndType = Tuple[Literal["and"], "FilterType"]
164
+ LogicOrType = Tuple[Literal["or"], "FilterType"]
165
+ LogicNotType = Tuple[Literal["not"], FieldFilter]
166
+ FilterType = List[
167
+ Union[FieldFilter, FieldFilterDict, LogicAndType, LogicOrType, LogicNotType]
168
+ ]
169
+
170
+
171
+ @define
172
+ class FilterVisitor:
173
+ """A visitor for the filter.
174
+
175
+ This is a visitor for the filter that allows to visit the filter and
176
+ perform some action on each element.
177
+
178
+ Attributes:
179
+ filter: The filter to visit.
180
+ """
181
+
182
+ filter: FilterType
183
+
184
+ def visit_and(self, filter: LogicAndType):
185
+ """Visit an and filter.
186
+
187
+ Args:
188
+ filter: The and filter to visit.
189
+ """
190
+
191
+ def visit_or(self, filter: LogicOrType):
192
+ """Visit an or filter.
193
+
194
+ Args:
195
+ filter: The or filter to visit.
196
+ """
197
+
198
+ def visit_not(self, filter: LogicNotType):
199
+ """Visit a not filter.
200
+
201
+ Args:
202
+ filter: The not filter to visit.
203
+ """
204
+
205
+ def visit_logic(self, filter: LogicAndType | LogicOrType | LogicNotType):
206
+ """Visit a logic filter.
207
+
208
+ Args:
209
+ filter: The logic filter to visit.
210
+ """
211
+
212
+ def visit_field(self, filter: FieldFilter):
213
+ """Visit a field filter.
214
+
215
+ Args:
216
+ filter: The field filter to visit.
217
+ """
218
+
219
+ def run(self, filter: Any):
220
+ """Run the visitor on the filter.
221
+
222
+ Args:
223
+ filter: The filter to visit.
224
+ """
225
+ if isinstance(filter, list):
226
+ if len(filter) == 0:
227
+ return
228
+
229
+ item = filter[0]
230
+ if isinstance(item, str):
231
+ if len(filter) != 2:
232
+ raise ValueError(
233
+ f"Logic operator {item} must be followed by a filter list"
234
+ )
235
+ item = item.lower()
236
+ if item == "and":
237
+ self.visit_and(cast(LogicAndType, filter))
238
+ elif item == "or":
239
+ self.visit_or(cast(LogicOrType, filter))
240
+ elif item == "not":
241
+ self.visit_not(cast(LogicNotType, filter))
242
+ if not isinstance(filter[1], list):
243
+ self.run(filter[1])
244
+ return
245
+ else:
246
+ raise ValueError(f"Unknown logic operator: {item}")
247
+ self.visit_logic(
248
+ cast(LogicAndType | LogicOrType | LogicNotType, filter)
249
+ )
250
+
251
+ for item in cast(List[FilterType], filter[1]):
252
+ self.run(item)
253
+ return
254
+
255
+ for sub_item in cast(List[FilterType], filter):
256
+ self.run(sub_item)
257
+ elif isinstance(filter, dict):
258
+ self.visit_field(FieldFilter(**filter))
259
+ elif isinstance(filter, FieldFilter):
260
+ self.visit_field(filter)
261
+ else:
262
+ raise ValueError(f"Unknown filter type: {type(filter)}")
263
+
264
+
265
+ def validate_filter(filter: FilterType) -> List[str]:
266
+ """Validate the filter expression.
267
+
268
+ Error codes:
269
+ - invalid_field_filter: The individual field filter is invalid. This occurs
270
+ when the individual field filter is represented as a dictionary and
271
+ a FieldFilter instance could not be constructed out of it.
272
+ - logic_arg_not_a_list: AND and OR require a list with two elements:
273
+ the keyword and a list of arguments. When this code is returned the
274
+ second item in the logic group/top list is not a list..
275
+ - logic_arg_not_2_items: AND, OR and NOT definition is called a logic group.
276
+ It consists of the keyword and a list of arguments. In this case the
277
+ logic group does not contain two items.
278
+ - unknown_logic_operator: The logic operator is unknown. Known operators
279
+ are 'and', 'or' and 'not'.
280
+ - unknown_arg_type: The argument type is unknown. Valid component items
281
+ are: an individual field filter (either in class form or in dictionary
282
+ form), a logic group (a list with two items: the logic operator and
283
+ a list of arguments), and NOT groups (a list with two items: the 'not'
284
+ keyword and a single item).
285
+ - unknown_filter_type: Same as unknown_arg_type but at the top level. At
286
+ the top level a single field filter is allowed. Otherwise, the filter
287
+ is assumed to be the arguments of an implicit AND group so it should
288
+ be a list.
289
+ - none: The top level filter is None.
290
+
291
+ Args:
292
+ filter: The filter to validate.
293
+
294
+ Returns:
295
+ A list of error information. First item is the error code, the rest
296
+ is the path to the invalid item.
297
+ """
298
+
299
+ def validate_logic_arg_bit(item: Any) -> List[str]: # type: ignore
300
+ if isinstance(item, FieldFilter):
301
+ # A single item in class form is acceptable.
302
+ return []
303
+ elif isinstance(item, dict):
304
+ # A single item in dictionary form is acceptable if it has
305
+ # the correct keys.
306
+ try:
307
+ FieldFilter(**item)
308
+ except Exception as exc:
309
+ logger.error("Invalid field filter %s: %s", item, exc)
310
+ return ["invalid_field_filter"]
311
+ elif isinstance(item, list):
312
+ if len(item) == 0:
313
+ # Empty list is allowed.
314
+ return []
315
+
316
+ # A nested list means the start of a new logic group.
317
+ # Logic groups always have length 2:
318
+ # - The first item is the logic operator.
319
+ # - The second item is the list of arguments.
320
+ if len(item) != 2:
321
+ return ["logic_arg_not_2_items"]
322
+
323
+ if not isinstance(item[0], str) or item[0].lower() not in [
324
+ "and",
325
+ "or",
326
+ "not",
327
+ ]:
328
+ return ["unknown_logic_operator"]
329
+
330
+ # Not consists of the 'not' keyword and an item.
331
+ if item[0] == "not":
332
+ return validate_logic_arg_bit(item[1])
333
+
334
+ # And and or consist of a list of arguments.
335
+ return validate_and_or_arg(item[0], item[1])
336
+
337
+ else:
338
+ return ["unknown_arg_type"]
339
+
340
+ def validate_and_or_arg(op: str, arg: Any) -> List[str]:
341
+ if not isinstance(arg, list):
342
+ return ["logic_arg_not_a_list", op]
343
+
344
+ if len(arg) == 0:
345
+ # Empty list is allowed.
346
+ return []
347
+
348
+ # Go through each item that should be and-ed or or-ed.
349
+ for i, item in enumerate(arg):
350
+ tmp = validate_logic_arg_bit(item)
351
+ if tmp:
352
+ tmp.insert(1, f"{op}[{i}]")
353
+ return tmp
354
+
355
+ return []
356
+
357
+ if filter is None:
358
+ # The filter should never be None.
359
+ return ["none"]
360
+ elif isinstance(filter, list):
361
+ if len(filter) == 0:
362
+ # Empty list is allowed.
363
+ return []
364
+
365
+ # Logic group.
366
+ if len(filter) == 2 and isinstance(filter[0], str):
367
+ return validate_logic_arg_bit(filter)
368
+
369
+ # A list at the top level means an implicit and.
370
+ return validate_and_or_arg("and", filter)
371
+ elif isinstance(filter, FieldFilter):
372
+ # A single field filter is acceptable.
373
+ return []
374
+ elif isinstance(filter, dict):
375
+ # The dictionary at the top level indicates that there is a single
376
+ # filter item.
377
+ try:
378
+ FieldFilter(**filter)
379
+ except Exception as exc:
380
+ logger.error("Invalid field filter %s: %s", filter, exc)
381
+ return ["invalid_field_filter"]
382
+ else:
383
+ return ["unknown_filter_type"]
384
+ return []
385
+
386
+
387
+ class SearchType(StrEnum):
388
+ """Used with selectors to indicate the type of search to perform.
389
+
390
+ EXACT: Exact search. The = operator is used, so the value must match
391
+ exactly, including case.
392
+ SIMPLE: Partial search. The ilike operator is used, and the input is
393
+ not altered in any way. The user can use the % wildcard to match any
394
+ number of characters.
395
+ EXTENDED: Extended search. The input is altered in the following ways:
396
+ - All spaces are replaced with %
397
+ - All * are replaced with %
398
+ - If the input contains no wildcards (%, *), then % is added to the
399
+ beginning and end of the input.
400
+ PATTERN: Pattern search. The input is considered to be a regular expression
401
+ pattern. It is not altered in any way.
402
+ """
403
+
404
+ EXACT = "exact"
405
+ SIMPLE = "partial"
406
+ EXTENDED = "extended"
407
+ PATTERN = "pattern"
408
+
409
+ def prepare_input(self, value: str) -> str:
410
+ """Prepare the input for the search.
411
+
412
+ Args:
413
+ input: The input to prepare.
414
+
415
+ Returns:
416
+ The prepared input.
417
+ """
418
+ if self == SearchType.EXTENDED:
419
+ # Wrap only when the user supplied no wildcard (% or *). Spaces are
420
+ # converted afterward, so they do not count as wildcards here.
421
+ if "%" not in value and "*" not in value:
422
+ value = f"%{value}%"
423
+ value = value.replace("*", "%")
424
+ value = value.replace(" ", "%")
425
+ # Adjacent * and space both become %; merge runs for one LIKE token.
426
+ value = re.sub(r"%+", "%", value)
427
+ return value
428
+
429
+ def create_filter(self, field: str, value: str) -> "FieldFilterDict":
430
+ """Create a filter for the search.
431
+
432
+ Args:
433
+ field: The field to filter on.
434
+ value: The value to filter on.
435
+
436
+ Returns:
437
+ A filter for the search.
438
+ """
439
+ value = self.prepare_input(value)
440
+ if self == SearchType.EXACT:
441
+ return {"fld": field, "op": FILTER_OP_EQ, "vl": value}
442
+ elif self == SearchType.SIMPLE:
443
+ return {"fld": field, "op": FILTER_OP_ILIKE, "vl": value}
444
+ elif self == SearchType.EXTENDED:
445
+ return {"fld": field, "op": FILTER_OP_ILIKE, "vl": value}
446
+ elif self == SearchType.PATTERN:
447
+ return {"fld": field, "op": FILTER_OP_REGEX, "vl": value}
448
+ else:
449
+ raise ValueError(f"Invalid search type: {self}")
450
+
451
+
452
+ def create_field_filters(
453
+ field_names: List[str],
454
+ term: str,
455
+ search_type: "SearchType",
456
+ ) -> List[FieldFilter]:
457
+ """Create filters for multiple fields with the same search term.
458
+
459
+ Args:
460
+ field_names: The list of field names to create filters for.
461
+ term: The search term to use.
462
+ search_type: The type of search to perform.
463
+
464
+ Returns:
465
+ A list of FieldFilter objects, one for each field.
466
+ """
467
+ filters = []
468
+ for field_name in field_names:
469
+ filter_dict = search_type.create_filter(field_name, term)
470
+ filters.append(
471
+ FieldFilter(
472
+ fld=filter_dict["fld"],
473
+ op=filter_dict["op"],
474
+ vl=filter_dict["vl"],
475
+ )
476
+ )
477
+ return filters
478
+
479
+
480
+ def extract_field_filters(filter_obj: Any) -> List[FieldFilter]:
481
+ """Extract all FieldFilter objects from a filter structure.
482
+
483
+ Args:
484
+ filter_obj: The filter structure to extract filters from. Can be
485
+ FilterType or any component of it.
486
+
487
+ Returns:
488
+ A list of all FieldFilter objects found in the structure.
489
+ """
490
+ result: List[FieldFilter] = []
491
+
492
+ if isinstance(filter_obj, FieldFilter):
493
+ result.append(filter_obj)
494
+ elif isinstance(filter_obj, dict):
495
+ try:
496
+ result.append(FieldFilter(**filter_obj))
497
+ except Exception:
498
+ logger.error("Invalid field filter %s", filter_obj)
499
+ elif isinstance(filter_obj, list):
500
+ if len(filter_obj) == 0:
501
+ pass
502
+ elif len(filter_obj) == 2 and isinstance(filter_obj[0], str):
503
+ # Logic group (and/or/not)
504
+ if filter_obj[0].lower() in ("and", "or"):
505
+ if isinstance(filter_obj[1], list):
506
+ for item in filter_obj[1]:
507
+ result.extend(extract_field_filters(item))
508
+ elif filter_obj[0].lower() == "not":
509
+ result.extend(extract_field_filters(filter_obj[1]))
510
+ else:
511
+ # Implicit AND list
512
+ for item in filter_obj:
513
+ result.extend(extract_field_filters(item))
514
+
515
+ return result
516
+
517
+
518
+ def create_multi_field_or_filter(
519
+ field_names: List[str],
520
+ term: str,
521
+ search_type: "SearchType",
522
+ ) -> FilterType:
523
+ """Create an OR filter for multiple fields with the same search term.
524
+
525
+ Args:
526
+ field_names: The list of field names to create filters for.
527
+ term: The search term to use.
528
+ search_type: The type of search to perform.
529
+
530
+ Returns:
531
+ A filter with OR logic combining filters for all fields. Returns
532
+ an empty list if no field names are provided or if the term is empty.
533
+ """
534
+ term = term.strip() if term else ""
535
+ if not term or not field_names:
536
+ return []
537
+
538
+ filters = create_field_filters(field_names, term, search_type)
539
+ if len(filters) == 0:
540
+ return []
541
+ if len(filters) == 1:
542
+ return [filters[0]] # type: ignore
543
+ return ["or", filters] # type: ignore
544
+
545
+
546
+ def insert_quick_search(
547
+ field_name: str,
548
+ term: str,
549
+ existing_filter: Optional[FilterType] = None,
550
+ search_type: "SearchType" = SearchType.EXACT,
551
+ ) -> FilterType:
552
+ """Insert a quick search into the filter.
553
+
554
+ Args:
555
+ field_name: The name of the field to search.
556
+ term: The search term to search for.
557
+ existing_filter: The existing filter to insert the quick search into.
558
+ search_type: The type of search to perform.
559
+
560
+ Returns:
561
+ The filter with the quick search inserted.
562
+ """
563
+ term = term.strip() if term else ""
564
+ if not term:
565
+ inserted = None
566
+ else:
567
+ # Use helper function to create the filter
568
+ filters = create_field_filters([field_name], term, search_type)
569
+ inserted = filters[0] if filters else None
570
+
571
+ if existing_filter is None:
572
+ return [inserted] if inserted else []
573
+ elif isinstance(existing_filter, list):
574
+ if len(existing_filter) == 0:
575
+ return [inserted] if inserted else []
576
+
577
+ # Logic group.
578
+ if len(existing_filter) == 2 and isinstance(existing_filter[0], str):
579
+ if existing_filter[0] == "and":
580
+ if not isinstance(existing_filter[1], list):
581
+ raise ValueError(
582
+ f"AND argument is not a list: {existing_filter[1]}"
583
+ )
584
+
585
+ new_and_value: List[Any] = [inserted] if inserted else []
586
+ for part in existing_filter[1]:
587
+ # Remove any existing filter for the same field
588
+ if isinstance(part, FieldFilter) and part.fld == field_name:
589
+ continue
590
+ if (
591
+ isinstance(part, dict) and part.get("fld") == field_name # type: ignore
592
+ ):
593
+ continue
594
+ new_and_value.append(part)
595
+ return cast(FilterType, ["and", new_and_value])
596
+
597
+ new_and_value = [inserted] if inserted else []
598
+ for part in existing_filter:
599
+ # Remove any existing filter for the same field
600
+ if isinstance(part, FieldFilter) and part.fld == field_name:
601
+ continue
602
+ if (
603
+ isinstance(part, dict) and part.get("fld") == field_name # type: ignore
604
+ ):
605
+ continue
606
+ new_and_value.append(part)
607
+
608
+ # A list at the top level means an implicit and.
609
+ return new_and_value
610
+ elif isinstance(existing_filter, FieldFilter):
611
+ if existing_filter.fld == field_name:
612
+ # Get rid of the previous value.
613
+ return [inserted] if inserted else []
614
+ elif isinstance(existing_filter, dict):
615
+ if existing_filter.get("fld") == field_name: # type: ignore
616
+ # Get rid of the previous value.
617
+ return [inserted] if inserted else []
618
+ else:
619
+ raise ValueError(f"Unknown filter type: {type(existing_filter)}")
620
+
621
+ # We give up searching for the field. The old value of the filter
622
+ # will be AND-ed together with the new value.
623
+ return (
624
+ [existing_filter, inserted] if existing_filter and inserted else [] # type: ignore
625
+ )
626
+
627
+
628
+ def compare_filters(f1: "FilterType", f2: "FilterType") -> bool:
629
+ """Compare two filter structures for equality.
630
+
631
+ Recursively compares filter structures, handling nested lists/tuples
632
+ and converting dictionaries to FieldFilter objects for comparison.
633
+
634
+ Args:
635
+ f1: The first filter structure to compare.
636
+ f2: The second filter structure to compare.
637
+
638
+ Returns:
639
+ True if the filters are equal, False otherwise.
640
+ """
641
+ if isinstance(f1, (list, tuple)) and isinstance(f2, (list, tuple)):
642
+ if len(f1) != len(f2):
643
+ return False
644
+ return all(
645
+ compare_filters(cast("FilterType", i1), cast("FilterType", i2))
646
+ for i1, i2 in zip(f1, f2)
647
+ )
648
+ else:
649
+ if isinstance(f1, dict):
650
+ f1 = FieldFilter(**f1)
651
+ if isinstance(f2, dict):
652
+ f2 = FieldFilter(**f2)
653
+ return f1 == f2