strapi-kit 0.0.1__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.
- strapi_kit/__init__.py +97 -0
- strapi_kit/__version__.py +15 -0
- strapi_kit/_version.py +34 -0
- strapi_kit/auth/__init__.py +7 -0
- strapi_kit/auth/api_token.py +48 -0
- strapi_kit/cache/__init__.py +5 -0
- strapi_kit/cache/schema_cache.py +211 -0
- strapi_kit/client/__init__.py +11 -0
- strapi_kit/client/async_client.py +1032 -0
- strapi_kit/client/base.py +460 -0
- strapi_kit/client/sync_client.py +980 -0
- strapi_kit/config_provider.py +368 -0
- strapi_kit/exceptions/__init__.py +37 -0
- strapi_kit/exceptions/errors.py +205 -0
- strapi_kit/export/__init__.py +10 -0
- strapi_kit/export/exporter.py +384 -0
- strapi_kit/export/importer.py +619 -0
- strapi_kit/export/media_handler.py +322 -0
- strapi_kit/export/relation_resolver.py +172 -0
- strapi_kit/models/__init__.py +104 -0
- strapi_kit/models/bulk.py +69 -0
- strapi_kit/models/config.py +174 -0
- strapi_kit/models/enums.py +97 -0
- strapi_kit/models/export_format.py +166 -0
- strapi_kit/models/import_options.py +142 -0
- strapi_kit/models/request/__init__.py +1 -0
- strapi_kit/models/request/fields.py +65 -0
- strapi_kit/models/request/filters.py +611 -0
- strapi_kit/models/request/pagination.py +168 -0
- strapi_kit/models/request/populate.py +281 -0
- strapi_kit/models/request/query.py +429 -0
- strapi_kit/models/request/sort.py +147 -0
- strapi_kit/models/response/__init__.py +1 -0
- strapi_kit/models/response/base.py +75 -0
- strapi_kit/models/response/component.py +67 -0
- strapi_kit/models/response/media.py +91 -0
- strapi_kit/models/response/meta.py +44 -0
- strapi_kit/models/response/normalized.py +168 -0
- strapi_kit/models/response/relation.py +48 -0
- strapi_kit/models/response/v4.py +70 -0
- strapi_kit/models/response/v5.py +57 -0
- strapi_kit/models/schema.py +93 -0
- strapi_kit/operations/__init__.py +16 -0
- strapi_kit/operations/media.py +226 -0
- strapi_kit/operations/streaming.py +144 -0
- strapi_kit/parsers/__init__.py +5 -0
- strapi_kit/parsers/version_detecting.py +171 -0
- strapi_kit/protocols.py +455 -0
- strapi_kit/utils/__init__.py +15 -0
- strapi_kit/utils/rate_limiter.py +201 -0
- strapi_kit/utils/uid.py +88 -0
- strapi_kit-0.0.1.dist-info/METADATA +1098 -0
- strapi_kit-0.0.1.dist-info/RECORD +55 -0
- strapi_kit-0.0.1.dist-info/WHEEL +4 -0
- strapi_kit-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
"""Filter builder for Strapi API queries.
|
|
2
|
+
|
|
3
|
+
Provides a fluent API for constructing complex filters with:
|
|
4
|
+
- 24 filter operators (eq, gt, contains, etc.)
|
|
5
|
+
- Logical operators (AND, OR, NOT)
|
|
6
|
+
- Deep filtering on relations
|
|
7
|
+
- Nested filter groups
|
|
8
|
+
|
|
9
|
+
Examples:
|
|
10
|
+
Simple filter:
|
|
11
|
+
>>> filter_builder = FilterBuilder().eq("status", "published")
|
|
12
|
+
>>> filter_builder.to_query_dict()
|
|
13
|
+
{'status': {'$eq': 'published'}}
|
|
14
|
+
|
|
15
|
+
Complex filter with logical operators:
|
|
16
|
+
>>> filter_builder = (FilterBuilder()
|
|
17
|
+
... .eq("status", "published")
|
|
18
|
+
... .gt("views", 100)
|
|
19
|
+
... .or_group(
|
|
20
|
+
... FilterBuilder().contains("title", "Python"),
|
|
21
|
+
... FilterBuilder().contains("title", "Django")
|
|
22
|
+
... ))
|
|
23
|
+
|
|
24
|
+
Deep filtering on relations:
|
|
25
|
+
>>> filter_builder = FilterBuilder().eq("author.name", "John Doe")
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import TYPE_CHECKING, Any
|
|
31
|
+
|
|
32
|
+
from pydantic import BaseModel, Field
|
|
33
|
+
|
|
34
|
+
from strapi_kit.models.enums import FilterOperator
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FilterCondition(BaseModel):
|
|
41
|
+
"""A single filter condition.
|
|
42
|
+
|
|
43
|
+
Represents a field-operator-value triple like "status = published".
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
field: Field name (supports dot notation for relations, e.g., "author.name")
|
|
47
|
+
operator: Filter operator from FilterOperator enum
|
|
48
|
+
value: Value to filter against (type depends on operator)
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
field: str = Field(..., min_length=1, description="Field name to filter on")
|
|
52
|
+
operator: FilterOperator = Field(..., description="Filter operator")
|
|
53
|
+
value: Any = Field(..., description="Value to compare against")
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict[str, Any]:
|
|
56
|
+
"""Convert to dictionary format for query parameters.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Dictionary with nested structure for field path and operator.
|
|
60
|
+
|
|
61
|
+
Examples:
|
|
62
|
+
>>> FilterCondition(field="status", operator=FilterOperator.EQ, value="published").to_dict()
|
|
63
|
+
{'status': {'$eq': 'published'}}
|
|
64
|
+
|
|
65
|
+
>>> FilterCondition(field="author.name", operator=FilterOperator.EQ, value="John").to_dict()
|
|
66
|
+
{'author': {'name': {'$eq': 'John'}}}
|
|
67
|
+
"""
|
|
68
|
+
# Handle dot notation for nested fields (e.g., "author.name")
|
|
69
|
+
parts = self.field.split(".")
|
|
70
|
+
result: dict[str, Any] = {self.operator.value: self.value}
|
|
71
|
+
|
|
72
|
+
# Build nested dictionary from right to left
|
|
73
|
+
for part in reversed(parts):
|
|
74
|
+
result = {part: result}
|
|
75
|
+
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class FilterGroup(BaseModel):
|
|
80
|
+
"""A group of filter conditions combined with logical operators.
|
|
81
|
+
|
|
82
|
+
Supports AND, OR, and NOT logical operators for combining conditions.
|
|
83
|
+
|
|
84
|
+
Attributes:
|
|
85
|
+
conditions: List of filter conditions
|
|
86
|
+
logical_operator: Logical operator combining conditions (default: AND)
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
conditions: list[FilterCondition | FilterGroup] = Field(
|
|
90
|
+
default_factory=list, description="Filter conditions or nested groups"
|
|
91
|
+
)
|
|
92
|
+
logical_operator: FilterOperator | None = Field(
|
|
93
|
+
None, description="Logical operator (AND, OR, NOT)"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def to_dict(self) -> dict[str, Any]:
|
|
97
|
+
"""Convert to dictionary format for query parameters.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Dictionary with conditions merged or wrapped in logical operator.
|
|
101
|
+
|
|
102
|
+
Examples:
|
|
103
|
+
>>> # Simple AND (default)
|
|
104
|
+
>>> group = FilterGroup(conditions=[
|
|
105
|
+
... FilterCondition(field="status", operator=FilterOperator.EQ, value="published")
|
|
106
|
+
... ])
|
|
107
|
+
>>> group.to_dict()
|
|
108
|
+
{'status': {'$eq': 'published'}}
|
|
109
|
+
|
|
110
|
+
>>> # Explicit OR
|
|
111
|
+
>>> group = FilterGroup(
|
|
112
|
+
... conditions=[
|
|
113
|
+
... FilterCondition(field="views", operator=FilterOperator.GT, value=100),
|
|
114
|
+
... FilterCondition(field="likes", operator=FilterOperator.GT, value=50)
|
|
115
|
+
... ],
|
|
116
|
+
... logical_operator=FilterOperator.OR
|
|
117
|
+
... )
|
|
118
|
+
>>> group.to_dict()
|
|
119
|
+
{'$or': [{'views': {'$gt': 100}}, {'likes': {'$gt': 50}}]}
|
|
120
|
+
"""
|
|
121
|
+
if not self.conditions:
|
|
122
|
+
return {}
|
|
123
|
+
|
|
124
|
+
# Convert all conditions to dictionaries
|
|
125
|
+
condition_dicts = [
|
|
126
|
+
cond.to_dict() if isinstance(cond, FilterCondition) else cond.to_dict()
|
|
127
|
+
for cond in self.conditions
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
# If no logical operator, merge dictionaries (implicit AND)
|
|
131
|
+
if self.logical_operator is None:
|
|
132
|
+
result: dict[str, Any] = {}
|
|
133
|
+
for cond_dict in condition_dicts:
|
|
134
|
+
# Deep merge dictionaries
|
|
135
|
+
self._deep_merge(result, cond_dict)
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
# Wrap in logical operator (OR, AND, NOT)
|
|
139
|
+
return {self.logical_operator.value: condition_dicts}
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _deep_merge(target: dict[str, Any], source: dict[str, Any]) -> None:
|
|
143
|
+
"""Deep merge source dictionary into target dictionary.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
target: Target dictionary to merge into (modified in place)
|
|
147
|
+
source: Source dictionary to merge from
|
|
148
|
+
"""
|
|
149
|
+
for key, value in source.items():
|
|
150
|
+
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
|
|
151
|
+
FilterGroup._deep_merge(target[key], value)
|
|
152
|
+
else:
|
|
153
|
+
target[key] = value
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class FilterBuilder:
|
|
157
|
+
"""Fluent API for building Strapi filters.
|
|
158
|
+
|
|
159
|
+
Provides chainable methods for all 24 Strapi filter operators plus
|
|
160
|
+
logical grouping with AND/OR/NOT.
|
|
161
|
+
|
|
162
|
+
Examples:
|
|
163
|
+
>>> # Simple filter
|
|
164
|
+
>>> builder = FilterBuilder().eq("status", "published")
|
|
165
|
+
|
|
166
|
+
>>> # Chained filters (implicit AND)
|
|
167
|
+
>>> builder = (FilterBuilder()
|
|
168
|
+
... .eq("status", "published")
|
|
169
|
+
... .gt("views", 100)
|
|
170
|
+
... .contains("title", "Python"))
|
|
171
|
+
|
|
172
|
+
>>> # OR group
|
|
173
|
+
>>> builder = FilterBuilder().or_group(
|
|
174
|
+
... FilterBuilder().eq("category", "tech"),
|
|
175
|
+
... FilterBuilder().eq("category", "science")
|
|
176
|
+
... )
|
|
177
|
+
|
|
178
|
+
>>> # Complex nested filters
|
|
179
|
+
>>> builder = (FilterBuilder()
|
|
180
|
+
... .eq("status", "published")
|
|
181
|
+
... .or_group(
|
|
182
|
+
... FilterBuilder().gt("views", 1000),
|
|
183
|
+
... FilterBuilder().gt("likes", 500)
|
|
184
|
+
... ))
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
def __init__(self) -> None:
|
|
188
|
+
"""Initialize an empty filter builder."""
|
|
189
|
+
self._conditions: list[FilterCondition | FilterGroup] = []
|
|
190
|
+
|
|
191
|
+
def _add_condition(self, field: str, operator: FilterOperator, value: Any) -> FilterBuilder:
|
|
192
|
+
"""Add a filter condition to the builder.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
field: Field name to filter on
|
|
196
|
+
operator: Filter operator
|
|
197
|
+
value: Value to compare against
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Self for method chaining
|
|
201
|
+
"""
|
|
202
|
+
self._conditions.append(FilterCondition(field=field, operator=operator, value=value))
|
|
203
|
+
return self
|
|
204
|
+
|
|
205
|
+
# Equality operators
|
|
206
|
+
def eq(self, field: str, value: Any) -> FilterBuilder:
|
|
207
|
+
"""Equal to (case-sensitive).
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
field: Field name
|
|
211
|
+
value: Value to match
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Self for chaining
|
|
215
|
+
|
|
216
|
+
Examples:
|
|
217
|
+
>>> FilterBuilder().eq("status", "published")
|
|
218
|
+
"""
|
|
219
|
+
return self._add_condition(field, FilterOperator.EQ, value)
|
|
220
|
+
|
|
221
|
+
def eqi(self, field: str, value: str) -> FilterBuilder:
|
|
222
|
+
"""Equal to (case-insensitive).
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
field: Field name
|
|
226
|
+
value: String value to match
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Self for chaining
|
|
230
|
+
|
|
231
|
+
Examples:
|
|
232
|
+
>>> FilterBuilder().eqi("status", "PUBLISHED")
|
|
233
|
+
"""
|
|
234
|
+
return self._add_condition(field, FilterOperator.EQI, value)
|
|
235
|
+
|
|
236
|
+
def ne(self, field: str, value: Any) -> FilterBuilder:
|
|
237
|
+
"""Not equal to (case-sensitive).
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
field: Field name
|
|
241
|
+
value: Value to exclude
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Self for chaining
|
|
245
|
+
"""
|
|
246
|
+
return self._add_condition(field, FilterOperator.NE, value)
|
|
247
|
+
|
|
248
|
+
def nei(self, field: str, value: str) -> FilterBuilder:
|
|
249
|
+
"""Not equal to (case-insensitive).
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
field: Field name
|
|
253
|
+
value: String value to exclude
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
Self for chaining
|
|
257
|
+
"""
|
|
258
|
+
return self._add_condition(field, FilterOperator.NEI, value)
|
|
259
|
+
|
|
260
|
+
# Comparison operators
|
|
261
|
+
def lt(self, field: str, value: Any) -> FilterBuilder:
|
|
262
|
+
"""Less than.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
field: Field name
|
|
266
|
+
value: Upper bound (exclusive)
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Self for chaining
|
|
270
|
+
|
|
271
|
+
Examples:
|
|
272
|
+
>>> FilterBuilder().lt("price", 100)
|
|
273
|
+
"""
|
|
274
|
+
return self._add_condition(field, FilterOperator.LT, value)
|
|
275
|
+
|
|
276
|
+
def lte(self, field: str, value: Any) -> FilterBuilder:
|
|
277
|
+
"""Less than or equal to.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
field: Field name
|
|
281
|
+
value: Upper bound (inclusive)
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
Self for chaining
|
|
285
|
+
"""
|
|
286
|
+
return self._add_condition(field, FilterOperator.LTE, value)
|
|
287
|
+
|
|
288
|
+
def gt(self, field: str, value: Any) -> FilterBuilder:
|
|
289
|
+
"""Greater than.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
field: Field name
|
|
293
|
+
value: Lower bound (exclusive)
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
Self for chaining
|
|
297
|
+
|
|
298
|
+
Examples:
|
|
299
|
+
>>> FilterBuilder().gt("views", 1000)
|
|
300
|
+
"""
|
|
301
|
+
return self._add_condition(field, FilterOperator.GT, value)
|
|
302
|
+
|
|
303
|
+
def gte(self, field: str, value: Any) -> FilterBuilder:
|
|
304
|
+
"""Greater than or equal to.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
field: Field name
|
|
308
|
+
value: Lower bound (inclusive)
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
Self for chaining
|
|
312
|
+
"""
|
|
313
|
+
return self._add_condition(field, FilterOperator.GTE, value)
|
|
314
|
+
|
|
315
|
+
# String matching operators
|
|
316
|
+
def contains(self, field: str, value: str) -> FilterBuilder:
|
|
317
|
+
"""Contains substring (case-sensitive).
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
field: Field name
|
|
321
|
+
value: Substring to search for
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
Self for chaining
|
|
325
|
+
|
|
326
|
+
Examples:
|
|
327
|
+
>>> FilterBuilder().contains("title", "Python")
|
|
328
|
+
"""
|
|
329
|
+
return self._add_condition(field, FilterOperator.CONTAINS, value)
|
|
330
|
+
|
|
331
|
+
def not_contains(self, field: str, value: str) -> FilterBuilder:
|
|
332
|
+
"""Does not contain substring (case-sensitive).
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
field: Field name
|
|
336
|
+
value: Substring to exclude
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
Self for chaining
|
|
340
|
+
"""
|
|
341
|
+
return self._add_condition(field, FilterOperator.NOT_CONTAINS, value)
|
|
342
|
+
|
|
343
|
+
def containsi(self, field: str, value: str) -> FilterBuilder:
|
|
344
|
+
"""Contains substring (case-insensitive).
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
field: Field name
|
|
348
|
+
value: Substring to search for
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
Self for chaining
|
|
352
|
+
"""
|
|
353
|
+
return self._add_condition(field, FilterOperator.CONTAINSI, value)
|
|
354
|
+
|
|
355
|
+
def not_containsi(self, field: str, value: str) -> FilterBuilder:
|
|
356
|
+
"""Does not contain substring (case-insensitive).
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
field: Field name
|
|
360
|
+
value: Substring to exclude
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
Self for chaining
|
|
364
|
+
"""
|
|
365
|
+
return self._add_condition(field, FilterOperator.NOT_CONTAINSI, value)
|
|
366
|
+
|
|
367
|
+
def starts_with(self, field: str, value: str) -> FilterBuilder:
|
|
368
|
+
"""Starts with string (case-sensitive).
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
field: Field name
|
|
372
|
+
value: Prefix to match
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
Self for chaining
|
|
376
|
+
"""
|
|
377
|
+
return self._add_condition(field, FilterOperator.STARTS_WITH, value)
|
|
378
|
+
|
|
379
|
+
def starts_withi(self, field: str, value: str) -> FilterBuilder:
|
|
380
|
+
"""Starts with string (case-insensitive).
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
field: Field name
|
|
384
|
+
value: Prefix to match
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
Self for chaining
|
|
388
|
+
"""
|
|
389
|
+
return self._add_condition(field, FilterOperator.STARTS_WITHI, value)
|
|
390
|
+
|
|
391
|
+
def ends_with(self, field: str, value: str) -> FilterBuilder:
|
|
392
|
+
"""Ends with string (case-sensitive).
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
field: Field name
|
|
396
|
+
value: Suffix to match
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
Self for chaining
|
|
400
|
+
"""
|
|
401
|
+
return self._add_condition(field, FilterOperator.ENDS_WITH, value)
|
|
402
|
+
|
|
403
|
+
def ends_withi(self, field: str, value: str) -> FilterBuilder:
|
|
404
|
+
"""Ends with string (case-insensitive).
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
field: Field name
|
|
408
|
+
value: Suffix to match
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
Self for chaining
|
|
412
|
+
"""
|
|
413
|
+
return self._add_condition(field, FilterOperator.ENDS_WITHI, value)
|
|
414
|
+
|
|
415
|
+
# Array operators
|
|
416
|
+
def in_(self, field: str, values: list[Any]) -> FilterBuilder:
|
|
417
|
+
"""Value is in array.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
field: Field name
|
|
421
|
+
values: List of acceptable values
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
Self for chaining
|
|
425
|
+
|
|
426
|
+
Examples:
|
|
427
|
+
>>> FilterBuilder().in_("status", ["published", "draft"])
|
|
428
|
+
"""
|
|
429
|
+
return self._add_condition(field, FilterOperator.IN, values)
|
|
430
|
+
|
|
431
|
+
def not_in(self, field: str, values: list[Any]) -> FilterBuilder:
|
|
432
|
+
"""Value is not in array.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
field: Field name
|
|
436
|
+
values: List of values to exclude
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
Self for chaining
|
|
440
|
+
"""
|
|
441
|
+
return self._add_condition(field, FilterOperator.NOT_IN, values)
|
|
442
|
+
|
|
443
|
+
# Null operators
|
|
444
|
+
def null(self, field: str, is_null: bool = True) -> FilterBuilder:
|
|
445
|
+
"""Value is null.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
field: Field name
|
|
449
|
+
is_null: True to match null, False to match not null
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
Self for chaining
|
|
453
|
+
|
|
454
|
+
Examples:
|
|
455
|
+
>>> FilterBuilder().null("deletedAt") # Match null values
|
|
456
|
+
>>> FilterBuilder().null("deletedAt", False) # Match non-null values
|
|
457
|
+
"""
|
|
458
|
+
return self._add_condition(field, FilterOperator.NULL, is_null)
|
|
459
|
+
|
|
460
|
+
def not_null(self, field: str) -> FilterBuilder:
|
|
461
|
+
"""Value is not null.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
field: Field name
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
Self for chaining
|
|
468
|
+
"""
|
|
469
|
+
return self._add_condition(field, FilterOperator.NOT_NULL, True)
|
|
470
|
+
|
|
471
|
+
# Range operators
|
|
472
|
+
def between(self, field: str, start: Any, end: Any) -> FilterBuilder:
|
|
473
|
+
"""Value is between start and end (inclusive).
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
field: Field name
|
|
477
|
+
start: Lower bound
|
|
478
|
+
end: Upper bound
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
Self for chaining
|
|
482
|
+
|
|
483
|
+
Examples:
|
|
484
|
+
>>> FilterBuilder().between("price", 10, 100)
|
|
485
|
+
>>> FilterBuilder().between("publishedAt", "2024-01-01", "2024-12-31")
|
|
486
|
+
"""
|
|
487
|
+
return self._add_condition(field, FilterOperator.BETWEEN, [start, end])
|
|
488
|
+
|
|
489
|
+
# Logical operators
|
|
490
|
+
def and_group(self, *builders: FilterBuilder) -> FilterBuilder:
|
|
491
|
+
"""Create an AND group of filters.
|
|
492
|
+
|
|
493
|
+
Each builder is wrapped as a sub-group to preserve logical structure.
|
|
494
|
+
For example, (a AND b) AND (c AND d) is preserved correctly.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
*builders: FilterBuilder instances to combine with AND
|
|
498
|
+
|
|
499
|
+
Returns:
|
|
500
|
+
Self for chaining
|
|
501
|
+
|
|
502
|
+
Examples:
|
|
503
|
+
>>> FilterBuilder().and_group(
|
|
504
|
+
... FilterBuilder().eq("status", "published"),
|
|
505
|
+
... FilterBuilder().gt("views", 100)
|
|
506
|
+
... )
|
|
507
|
+
"""
|
|
508
|
+
# Wrap each builder as a sub-group to preserve grouping
|
|
509
|
+
conditions: list[FilterCondition | FilterGroup] = []
|
|
510
|
+
for builder in builders:
|
|
511
|
+
if len(builder._conditions) == 1:
|
|
512
|
+
# Single condition - add directly
|
|
513
|
+
conditions.append(builder._conditions[0])
|
|
514
|
+
else:
|
|
515
|
+
# Multiple conditions - wrap as implicit AND group
|
|
516
|
+
conditions.append(
|
|
517
|
+
FilterGroup(conditions=builder._conditions, logical_operator=None)
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
group = FilterGroup(conditions=conditions, logical_operator=FilterOperator.AND)
|
|
521
|
+
self._conditions.append(group)
|
|
522
|
+
return self
|
|
523
|
+
|
|
524
|
+
def or_group(self, *builders: FilterBuilder) -> FilterBuilder:
|
|
525
|
+
"""Create an OR group of filters.
|
|
526
|
+
|
|
527
|
+
Each builder is wrapped as a sub-group to preserve logical structure.
|
|
528
|
+
For example, (a AND b) OR c preserves the grouping correctly.
|
|
529
|
+
|
|
530
|
+
Args:
|
|
531
|
+
*builders: FilterBuilder instances to combine with OR
|
|
532
|
+
|
|
533
|
+
Returns:
|
|
534
|
+
Self for chaining
|
|
535
|
+
|
|
536
|
+
Examples:
|
|
537
|
+
>>> FilterBuilder().or_group(
|
|
538
|
+
... FilterBuilder().eq("category", "tech"),
|
|
539
|
+
... FilterBuilder().eq("category", "science")
|
|
540
|
+
... )
|
|
541
|
+
"""
|
|
542
|
+
# Wrap each builder as a sub-group to preserve grouping
|
|
543
|
+
conditions: list[FilterCondition | FilterGroup] = []
|
|
544
|
+
for builder in builders:
|
|
545
|
+
if len(builder._conditions) == 1:
|
|
546
|
+
# Single condition - add directly
|
|
547
|
+
conditions.append(builder._conditions[0])
|
|
548
|
+
else:
|
|
549
|
+
# Multiple conditions - wrap as implicit AND group
|
|
550
|
+
conditions.append(
|
|
551
|
+
FilterGroup(conditions=builder._conditions, logical_operator=None)
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
group = FilterGroup(conditions=conditions, logical_operator=FilterOperator.OR)
|
|
555
|
+
self._conditions.append(group)
|
|
556
|
+
return self
|
|
557
|
+
|
|
558
|
+
def not_group(self, builder: FilterBuilder) -> FilterBuilder:
|
|
559
|
+
"""Create a NOT group (negation).
|
|
560
|
+
|
|
561
|
+
The builder's conditions are wrapped as a group to preserve logical structure.
|
|
562
|
+
|
|
563
|
+
Args:
|
|
564
|
+
builder: FilterBuilder instance to negate
|
|
565
|
+
|
|
566
|
+
Returns:
|
|
567
|
+
Self for chaining
|
|
568
|
+
|
|
569
|
+
Examples:
|
|
570
|
+
>>> FilterBuilder().not_group(
|
|
571
|
+
... FilterBuilder().eq("status", "draft")
|
|
572
|
+
... )
|
|
573
|
+
"""
|
|
574
|
+
# Wrap builder conditions appropriately
|
|
575
|
+
if len(builder._conditions) == 1:
|
|
576
|
+
# Single condition - wrap directly
|
|
577
|
+
conditions: list[FilterCondition | FilterGroup] = list(builder._conditions)
|
|
578
|
+
else:
|
|
579
|
+
# Multiple conditions - wrap as implicit AND group first
|
|
580
|
+
conditions = [FilterGroup(conditions=builder._conditions, logical_operator=None)]
|
|
581
|
+
|
|
582
|
+
group = FilterGroup(conditions=conditions, logical_operator=FilterOperator.NOT)
|
|
583
|
+
self._conditions.append(group)
|
|
584
|
+
return self
|
|
585
|
+
|
|
586
|
+
def to_query_dict(self) -> dict[str, Any]:
|
|
587
|
+
"""Convert filter builder to dictionary format for query parameters.
|
|
588
|
+
|
|
589
|
+
Returns:
|
|
590
|
+
Dictionary with nested filter structure
|
|
591
|
+
|
|
592
|
+
Examples:
|
|
593
|
+
>>> builder = FilterBuilder().eq("status", "published").gt("views", 100)
|
|
594
|
+
>>> builder.to_query_dict()
|
|
595
|
+
{'status': {'$eq': 'published'}, 'views': {'$gt': 100}}
|
|
596
|
+
"""
|
|
597
|
+
if not self._conditions:
|
|
598
|
+
return {}
|
|
599
|
+
|
|
600
|
+
# If single condition, return it directly
|
|
601
|
+
if len(self._conditions) == 1:
|
|
602
|
+
cond = self._conditions[0]
|
|
603
|
+
return cond.to_dict() if isinstance(cond, FilterCondition) else cond.to_dict()
|
|
604
|
+
|
|
605
|
+
# Multiple conditions - wrap in implicit AND
|
|
606
|
+
group = FilterGroup(conditions=self._conditions, logical_operator=None)
|
|
607
|
+
return group.to_dict()
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
# Rebuild models to resolve forward references
|
|
611
|
+
FilterGroup.model_rebuild()
|