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.
- exdrf/__init__.py +0 -0
- exdrf/__version__.py +24 -0
- exdrf/api.py +51 -0
- exdrf/constants.py +30 -0
- exdrf/dataset.py +197 -0
- exdrf/field.py +554 -0
- exdrf/field_types/__init__.py +0 -0
- exdrf/field_types/api.py +78 -0
- exdrf/field_types/blob_field.py +44 -0
- exdrf/field_types/bool_field.py +47 -0
- exdrf/field_types/date_field.py +49 -0
- exdrf/field_types/date_time.py +52 -0
- exdrf/field_types/dur_field.py +44 -0
- exdrf/field_types/enum_field.py +41 -0
- exdrf/field_types/filter_field.py +11 -0
- exdrf/field_types/float_field.py +85 -0
- exdrf/field_types/float_list.py +18 -0
- exdrf/field_types/formatted.py +39 -0
- exdrf/field_types/int_field.py +70 -0
- exdrf/field_types/int_list.py +18 -0
- exdrf/field_types/ref_base.py +105 -0
- exdrf/field_types/ref_m2m.py +39 -0
- exdrf/field_types/ref_m2o.py +23 -0
- exdrf/field_types/ref_o2m.py +36 -0
- exdrf/field_types/ref_o2o.py +32 -0
- exdrf/field_types/sort_field.py +18 -0
- exdrf/field_types/str_field.py +77 -0
- exdrf/field_types/str_list.py +18 -0
- exdrf/field_types/time_field.py +49 -0
- exdrf/filter.py +653 -0
- exdrf/filter_dsl.py +950 -0
- exdrf/filter_op_catalog.py +222 -0
- exdrf/label_dsl.py +691 -0
- exdrf/moment.py +496 -0
- exdrf/py.typed +0 -0
- exdrf/py_support.py +21 -0
- exdrf/resource.py +901 -0
- exdrf/sa_fi_item.py +69 -0
- exdrf/sa_filter_op.py +324 -0
- exdrf/utils.py +17 -0
- exdrf/validator.py +45 -0
- exdrf/var_bag.py +328 -0
- exdrf/visitor.py +58 -0
- exdrf-0.0.1.dev0.dist-info/METADATA +42 -0
- exdrf-0.0.1.dev0.dist-info/RECORD +57 -0
- exdrf-0.0.1.dev0.dist-info/WHEEL +5 -0
- exdrf-0.0.1.dev0.dist-info/top_level.txt +3 -0
- exdrf_tests/__init__.py +0 -0
- exdrf_tests/test_dataset.py +422 -0
- exdrf_tests/test_field.py +109 -0
- exdrf_tests/test_filter.py +425 -0
- exdrf_tests/test_filter_dsl.py +556 -0
- exdrf_tests/test_label_dsl.py +234 -0
- exdrf_tests/test_resource.py +107 -0
- exdrf_tests/test_utils.py +43 -0
- exdrf_tests/test_visitor.py +31 -0
- 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
|