sqlmodel-object-helpers 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.
@@ -0,0 +1,97 @@
1
+ """sqlmodel-object-helpers — reusable query helpers for SQLModel projects."""
2
+
3
+ __version__ = "0.0.1"
4
+
5
+ from .constants import QueryHelperSettings, settings
6
+ from .exceptions import (
7
+ DatabaseError,
8
+ InvalidFilterError,
9
+ InvalidLoadPathError,
10
+ MutationError,
11
+ ObjectNotFoundError,
12
+ QueryError,
13
+ )
14
+ from .filters import build_filter, build_flat_filter, flatten_filters
15
+ from .loaders import build_load_chain, build_load_options
16
+ from .operators import SPECIAL_OPERATORS, SUPPORTED_OPERATORS, Operator
17
+ from .mutations import (
18
+ add_object,
19
+ add_objects,
20
+ check_for_related_records,
21
+ delete_object,
22
+ update_object,
23
+ )
24
+ from .query import get_object, get_objects, get_projection
25
+ from .session import create_session_dependency
26
+ from .types.filters import (
27
+ FilterBool,
28
+ FilterDate,
29
+ FilterDatetime,
30
+ FilterExists,
31
+ FilterInt,
32
+ FilterStr,
33
+ FilterTimedelta,
34
+ LogicalFilter,
35
+ OrderAsc,
36
+ OrderBy,
37
+ OrderDesc,
38
+ )
39
+ from .types.pagination import (
40
+ GetAllPagination,
41
+ Pagination,
42
+ PaginationR,
43
+ )
44
+ from .types.projections import ColumnSpec
45
+
46
+ __all__ = [
47
+ # Settings
48
+ "settings",
49
+ "QueryHelperSettings",
50
+ # Query functions (read)
51
+ "get_object",
52
+ "get_objects",
53
+ "get_projection",
54
+ # Mutation functions (create / update / delete)
55
+ "add_object",
56
+ "add_objects",
57
+ "update_object",
58
+ "delete_object",
59
+ "check_for_related_records",
60
+ # Filters
61
+ "build_filter",
62
+ "build_flat_filter",
63
+ "flatten_filters",
64
+ "SUPPORTED_OPERATORS",
65
+ "SPECIAL_OPERATORS",
66
+ "Operator",
67
+ # Loaders
68
+ "build_load_options",
69
+ "build_load_chain",
70
+ # Exceptions
71
+ "QueryError",
72
+ "ObjectNotFoundError",
73
+ "InvalidFilterError",
74
+ "InvalidLoadPathError",
75
+ "DatabaseError",
76
+ "MutationError",
77
+ # Filter types
78
+ "FilterInt",
79
+ "FilterStr",
80
+ "FilterDate",
81
+ "FilterDatetime",
82
+ "FilterTimedelta",
83
+ "FilterBool",
84
+ "FilterExists",
85
+ "OrderAsc",
86
+ "OrderDesc",
87
+ "OrderBy",
88
+ "LogicalFilter",
89
+ # Pagination types
90
+ "Pagination",
91
+ "PaginationR",
92
+ "GetAllPagination",
93
+ # Projection types
94
+ "ColumnSpec",
95
+ # Session
96
+ "create_session_dependency",
97
+ ]
@@ -0,0 +1,34 @@
1
+ """Security and performance limits for query building.
2
+
3
+ All values have sensible defaults. Override at application startup:
4
+
5
+ from sqlmodel_object_helpers import settings
6
+ settings.max_per_page = 300
7
+ settings.max_filter_depth = 50
8
+ """
9
+
10
+ from pydantic import BaseModel
11
+
12
+
13
+ class QueryHelperSettings(BaseModel):
14
+ """Mutable settings for query-helper limits.
15
+
16
+ Attributes:
17
+ max_filter_depth: Maximum recursion depth for build_filter (AND/OR nesting).
18
+ Each depth level ≈ 3 Python call frames, so 100 depth ≈ 300 frames
19
+ (safe under Python's default recursion limit of 1000).
20
+ max_and_or_items: Maximum number of sub-filters in a single AND/OR list.
21
+ max_load_depth: Maximum depth of eager-loading chains
22
+ (e.g. "application.applicant.educations" = 3).
23
+ max_in_list_size: Maximum number of elements in an IN(...) list.
24
+ max_per_page: Maximum value for per_page in pagination.
25
+ """
26
+
27
+ max_filter_depth: int = 100
28
+ max_and_or_items: int = 100
29
+ max_load_depth: int = 10
30
+ max_in_list_size: int = 1000
31
+ max_per_page: int = 500
32
+
33
+
34
+ settings = QueryHelperSettings()
@@ -0,0 +1,52 @@
1
+ from http import HTTPStatus
2
+
3
+
4
+ class QueryError(Exception):
5
+ """Base exception for all library errors."""
6
+
7
+ def __init__(self, message: str, status_code: int = HTTPStatus.BAD_REQUEST):
8
+ self.message = message
9
+ self.status_code = status_code
10
+ super().__init__(message)
11
+
12
+
13
+ class ObjectNotFoundError(QueryError):
14
+ """Raised when a requested object does not exist."""
15
+
16
+ def __init__(self, model_name: str):
17
+ super().__init__(f"Resource not found: {model_name}", status_code=HTTPStatus.NOT_FOUND)
18
+
19
+
20
+ class InvalidFilterError(QueryError):
21
+ """Raised when filter parameters are invalid."""
22
+
23
+ def __init__(self, detail: str):
24
+ super().__init__(detail, status_code=HTTPStatus.BAD_REQUEST)
25
+
26
+
27
+ class InvalidLoadPathError(QueryError):
28
+ """Raised when a load_path references a non-existent field or relationship."""
29
+
30
+ def __init__(self, model_name: str, field_name: str):
31
+ super().__init__(
32
+ f"Model {model_name} has no field {field_name}",
33
+ status_code=HTTPStatus.BAD_REQUEST,
34
+ )
35
+
36
+
37
+ class DatabaseError(QueryError):
38
+ """Raised for unexpected database errors (connection, timeout, etc.)."""
39
+
40
+ def __init__(self, detail: str):
41
+ super().__init__(detail, status_code=HTTPStatus.INTERNAL_SERVER_ERROR)
42
+
43
+
44
+ class MutationError(QueryError):
45
+ """Raised when a create/update/delete operation fails.
46
+
47
+ Covers constraint violations, invalid field names in update data,
48
+ and other mutation-specific errors.
49
+ """
50
+
51
+ def __init__(self, detail: str):
52
+ super().__init__(detail, status_code=HTTPStatus.BAD_REQUEST)
@@ -0,0 +1,440 @@
1
+ from typing import Any
2
+
3
+ from sqlalchemy.orm import RelationshipProperty, aliased
4
+ from sqlmodel import SQLModel, and_, or_
5
+
6
+ from .constants import settings
7
+ from .exceptions import InvalidFilterError
8
+ from .operators import SPECIAL_OPERATORS, SUPPORTED_OPERATORS
9
+
10
+
11
+ def _validate_field_name(field: str) -> None:
12
+ """Reject field names starting with '_' to prevent access to dunder attributes."""
13
+ if field.startswith("_"):
14
+ msg = f"Invalid field name: {field}"
15
+ raise InvalidFilterError(msg)
16
+
17
+
18
+ def flatten_filters(filters: dict[str, Any], parent_key: str = "") -> dict[str, Any]:
19
+ """Convert nested filter dicts into flat dot-notation dicts.
20
+
21
+ Example:
22
+ {"attempt": {"application": {"applicant": {"last_name": {"eq": "test"}}}}}
23
+ becomes
24
+ {"attempt.application.applicant.last_name": {"eq": "test"}}
25
+ """
26
+ flat: dict[str, Any] = {}
27
+ for key, value in filters.items():
28
+ current_key = f"{parent_key}.{key}" if parent_key else key
29
+ if isinstance(value, dict):
30
+ if _is_operator_dict(value):
31
+ flat[current_key] = value
32
+ else:
33
+ nested = flatten_filters(value, current_key)
34
+ flat.update(nested)
35
+ else:
36
+ flat[current_key] = {"eq": value}
37
+ return flat
38
+
39
+
40
+ def _is_operator_dict(value: dict[str, Any]) -> bool:
41
+ """Check if a dict contains operator keys (supported or special like 'exists')."""
42
+ return any(k in SUPPORTED_OPERATORS or k in SPECIAL_OPERATORS for k in value)
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # build_filter — for LogicalFilter (AND/OR/condition) used by get_object
47
+ # ---------------------------------------------------------------------------
48
+
49
+
50
+ def _add_join(
51
+ joins: list[Any],
52
+ seen_joins: set[tuple[Any, ...]],
53
+ join_tuple: tuple[Any, ...],
54
+ ) -> None:
55
+ """Register a join if not already seen."""
56
+ if join_tuple not in seen_joins:
57
+ joins.append(join_tuple)
58
+ seen_joins.add(join_tuple)
59
+
60
+
61
+ def _apply_field_operator(attr: Any, value: dict[str, Any]) -> Any:
62
+ """Apply all operators from value dict to attr. Returns SQLAlchemy expression."""
63
+ expressions: list[Any] = []
64
+ for operator, val in value.items():
65
+ if operator not in SUPPORTED_OPERATORS:
66
+ msg = f"Unsupported operator: '{operator}'"
67
+ raise InvalidFilterError(msg)
68
+ expressions.append(SUPPORTED_OPERATORS[operator](attr, val))
69
+ if len(expressions) == 1:
70
+ return expressions[0]
71
+ return and_(*expressions)
72
+
73
+
74
+ def _handle_and_or(
75
+ combiner: Any,
76
+ sub_filters: list[dict[str, Any]],
77
+ model: type[SQLModel],
78
+ joins: list[Any],
79
+ parent_model: type[SQLModel],
80
+ seen_joins: set[tuple[Any, ...]],
81
+ _depth: int = 0,
82
+ ) -> tuple[Any, list[Any]]:
83
+ """Process AND/OR list of sub-filters recursively."""
84
+ if len(sub_filters) > settings.max_and_or_items:
85
+ msg = f"AND/OR cannot contain more than {settings.max_and_or_items} conditions"
86
+ raise InvalidFilterError(msg)
87
+ expressions: list[Any] = []
88
+ for sub_filter in sub_filters:
89
+ expr, sub_joins = build_filter(
90
+ model, sub_filter, joins, parent_model, seen_joins, _depth=_depth + 1
91
+ )
92
+ expressions.append(expr)
93
+ for j in sub_joins:
94
+ _add_join(joins, seen_joins, j)
95
+ return combiner(*expressions), joins
96
+
97
+
98
+ def _handle_condition(
99
+ condition: dict[str, Any],
100
+ model: type[SQLModel],
101
+ joins: list[Any],
102
+ parent_model: type[SQLModel],
103
+ seen_joins: set[tuple[Any, ...]],
104
+ _depth: int = 0,
105
+ ) -> tuple[Any, list[Any]]:
106
+ """Process a condition block — field-level filters with possible relationships."""
107
+ expressions: list[Any] = []
108
+
109
+ for field, value in condition.items():
110
+ if field in model.__mapper__.relationships: # type: ignore[attr-defined] # SQLAlchemy metaclass
111
+ _process_relationship_field(
112
+ field, value, model, joins, parent_model, seen_joins, expressions,
113
+ _depth=_depth,
114
+ )
115
+ else:
116
+ _process_simple_field(field, value, model, expressions)
117
+
118
+ return and_(*expressions), joins
119
+
120
+
121
+ def _process_relationship_field(
122
+ field: str,
123
+ value: Any,
124
+ model: type[SQLModel],
125
+ joins: list[Any],
126
+ parent_model: type[SQLModel],
127
+ seen_joins: set[tuple[Any, ...]],
128
+ expressions: list[Any],
129
+ _depth: int = 0,
130
+ ) -> None:
131
+ """Process a field that maps to a relationship."""
132
+ rel_prop = model.__mapper__.relationships[field] # type: ignore[attr-defined] # SQLAlchemy metaclass
133
+ rel_model = rel_prop.mapper.class_
134
+ _add_join(joins, seen_joins, (model, rel_prop))
135
+
136
+ if not isinstance(value, dict):
137
+ msg = f"Invalid condition for field '{field}': {value}"
138
+ raise InvalidFilterError(msg)
139
+
140
+ if _is_operator_dict(value):
141
+ try:
142
+ attr = getattr(rel_model, field)
143
+ except AttributeError:
144
+ msg = f"Field '{field}' not found in model {rel_model.__name__}"
145
+ raise InvalidFilterError(msg) from None
146
+ expressions.append(_apply_field_operator(attr, value))
147
+ else:
148
+ sub_expr, sub_joins = build_filter(
149
+ rel_model, {"condition": value}, joins, parent_model, seen_joins,
150
+ _depth=_depth + 1,
151
+ )
152
+ expressions.append(sub_expr)
153
+ for j in sub_joins:
154
+ _add_join(joins, seen_joins, j)
155
+
156
+
157
+ def _process_simple_field(
158
+ field: str,
159
+ value: Any,
160
+ model: type[SQLModel],
161
+ expressions: list[Any],
162
+ ) -> None:
163
+ """Process a simple (non-relationship) field."""
164
+ _validate_field_name(field)
165
+ try:
166
+ attr = getattr(model, field)
167
+ except AttributeError:
168
+ msg = f"Field or relationship '{field}' not found in model {model.__name__}"
169
+ raise InvalidFilterError(msg) from None
170
+
171
+ if isinstance(value, dict) and _is_operator_dict(value):
172
+ expressions.append(_apply_field_operator(attr, value))
173
+ else:
174
+ msg = f"Invalid condition for field '{field}': {value}"
175
+ raise InvalidFilterError(msg)
176
+
177
+
178
+ def _resolve_relationship_chain(
179
+ path_parts: list[str],
180
+ model: type[SQLModel],
181
+ joins: list[Any],
182
+ seen_joins: set[tuple[Any, ...]],
183
+ ) -> tuple[type[SQLModel], Any]:
184
+ """Walk a dot-notation path, registering joins along the way.
185
+
186
+ Returns (final_model, final_attr) for the terminal field.
187
+ """
188
+ current_model = model
189
+
190
+ for i, part in enumerate(path_parts[:-1]):
191
+ _validate_field_name(part)
192
+ if part not in current_model.__mapper__.relationships: # type: ignore[attr-defined] # SQLAlchemy metaclass
193
+ msg = f"Relationship '{part}' not found in model {current_model.__name__}"
194
+ raise InvalidFilterError(msg)
195
+ rel_prop = current_model.__mapper__.relationships[part] # type: ignore[attr-defined] # SQLAlchemy metaclass
196
+ _add_join(joins, seen_joins, (current_model, rel_prop))
197
+ current_model = rel_prop.mapper.class_
198
+
199
+ return current_model, path_parts[-1]
200
+
201
+
202
+ def _handle_dot_notation(
203
+ field: str,
204
+ condition: dict[str, Any],
205
+ model: type[SQLModel],
206
+ joins: list[Any],
207
+ parent_model: type[SQLModel],
208
+ seen_joins: set[tuple[Any, ...]],
209
+ expressions: list[Any],
210
+ _depth: int = 0,
211
+ ) -> None:
212
+ """Process a dot-notation field path with optional relationship traversal."""
213
+ path_parts = field.split(".")
214
+ current_model, terminal_part = _resolve_relationship_chain(
215
+ path_parts, model, joins, seen_joins
216
+ )
217
+
218
+ if terminal_part in current_model.__mapper__.relationships: # type: ignore[attr-defined] # SQLAlchemy metaclass
219
+ rel_prop = current_model.__mapper__.relationships[terminal_part] # type: ignore[attr-defined]
220
+ rel_model = rel_prop.mapper.class_
221
+ _add_join(joins, seen_joins, (current_model, rel_prop))
222
+
223
+ if _is_operator_dict(condition):
224
+ try:
225
+ attr = getattr(rel_model, terminal_part)
226
+ except AttributeError:
227
+ msg = f"Field '{terminal_part}' not found in model {rel_model.__name__}"
228
+ raise InvalidFilterError(msg) from None
229
+ expressions.append(_apply_field_operator(attr, condition))
230
+ else:
231
+ sub_expr, sub_joins = build_filter(
232
+ rel_model, {"condition": condition}, joins, parent_model, seen_joins,
233
+ _depth=_depth + 1,
234
+ )
235
+ expressions.append(sub_expr)
236
+ for j in sub_joins:
237
+ _add_join(joins, seen_joins, j)
238
+ else:
239
+ _validate_field_name(terminal_part)
240
+ try:
241
+ attr = getattr(current_model, terminal_part)
242
+ except AttributeError:
243
+ msg = f"Field '{terminal_part}' not found in model {current_model.__name__}"
244
+ raise InvalidFilterError(msg) from None
245
+
246
+ if _is_operator_dict(condition):
247
+ expressions.append(_apply_field_operator(attr, condition))
248
+ else:
249
+ msg = f"Invalid condition for field '{field}': {condition}"
250
+ raise InvalidFilterError(msg)
251
+
252
+
253
+ def build_filter(
254
+ model: type[SQLModel],
255
+ filters: dict[str, Any],
256
+ joins: list[Any] | None = None,
257
+ parent_model: type[SQLModel] | None = None,
258
+ seen_joins: set[tuple[Any, ...]] | None = None,
259
+ _depth: int = 0,
260
+ ) -> tuple[Any, list[Any]]:
261
+ """Build a SQLAlchemy filter expression from a filter dict.
262
+
263
+ Supports:
264
+ - AND/OR/condition recursive structure (LogicalFilter)
265
+ - Dot-notation field paths with automatic joins
266
+ - Flat dict filters with operators
267
+ """
268
+ if _depth > settings.max_filter_depth:
269
+ msg = f"Filter nesting depth exceeds {settings.max_filter_depth}"
270
+ raise InvalidFilterError(msg)
271
+
272
+ if joins is None:
273
+ joins = []
274
+ if seen_joins is None:
275
+ seen_joins = set()
276
+ if parent_model is None:
277
+ parent_model = model
278
+
279
+ if "AND" in filters and filters["AND"] is not None:
280
+ return _handle_and_or(
281
+ and_, filters["AND"], model, joins, parent_model, seen_joins, _depth=_depth
282
+ )
283
+
284
+ if "OR" in filters and filters["OR"] is not None:
285
+ return _handle_and_or(
286
+ or_, filters["OR"], model, joins, parent_model, seen_joins, _depth=_depth
287
+ )
288
+
289
+ if "condition" in filters:
290
+ return _handle_condition(
291
+ filters["condition"], model, joins, parent_model, seen_joins, _depth=_depth
292
+ )
293
+
294
+ # Dot-notation / flat dict filters
295
+ expressions: list[Any] = []
296
+ for field, condition in filters.items():
297
+ if not isinstance(condition, dict):
298
+ msg = f"Invalid condition for field '{field}': {condition}"
299
+ raise InvalidFilterError(msg)
300
+ _handle_dot_notation(
301
+ field, condition, model, joins, parent_model, seen_joins, expressions,
302
+ _depth=_depth,
303
+ )
304
+
305
+ if not expressions:
306
+ return None, joins
307
+ return and_(*expressions), joins
308
+
309
+
310
+ # ---------------------------------------------------------------------------
311
+ # build_flat_filter — for flattened dict filters used by get_objects
312
+ # Uses aliased() models for join handling (different from build_filter)
313
+ # ---------------------------------------------------------------------------
314
+
315
+
316
+ def _resolve_aliased_path(
317
+ field_parts: list[str],
318
+ model: type[SQLModel],
319
+ model_aliases: dict[str, Any],
320
+ join_entities: list[tuple[Any, Any]],
321
+ suspend_error: bool,
322
+ ) -> Any | None:
323
+ """Resolve a dot-notation path using aliased models.
324
+
325
+ Returns the final attribute, or None if suspend_error=True and path is invalid.
326
+ """
327
+ alias_path = field_parts[:-1]
328
+ related_field = field_parts[-1]
329
+ related_model = model
330
+
331
+ for i, related_name in enumerate(alias_path):
332
+ try:
333
+ _validate_field_name(related_name)
334
+ except InvalidFilterError:
335
+ if not suspend_error:
336
+ raise
337
+ return None
338
+ try:
339
+ relationship_attr = getattr(related_model, related_name)
340
+ except AttributeError:
341
+ if not suspend_error:
342
+ msg = f"Model {model.__name__} has no relationship {related_name}"
343
+ raise InvalidFilterError(msg)
344
+ return None
345
+
346
+ related_model = relationship_attr.property.mapper.class_
347
+
348
+ alias_key = ".".join(alias_path[: i + 1])
349
+ if alias_key not in model_aliases:
350
+ alias = aliased(related_model)
351
+ model_aliases[alias_key] = alias
352
+ join_entities.append((alias, relationship_attr))
353
+
354
+ try:
355
+ _validate_field_name(related_field)
356
+ except InvalidFilterError:
357
+ if not suspend_error:
358
+ raise
359
+ return None
360
+ final_key = ".".join(alias_path)
361
+ return getattr(model_aliases[final_key], related_field)
362
+
363
+
364
+ def _apply_filter_value(
365
+ attr: Any,
366
+ value: Any,
367
+ conditions: list[Any],
368
+ ) -> None:
369
+ """Process a filter value (dict with operators, exists, or list) and append conditions."""
370
+ if isinstance(value, dict):
371
+ if "exists" in value:
372
+ if hasattr(attr, "prop") and isinstance(attr.prop, RelationshipProperty):
373
+ cond = attr.any() if value["exists"] else ~attr.any()
374
+ else:
375
+ cond = attr.is_not(None) if value["exists"] else attr.is_(None)
376
+ conditions.append(cond)
377
+ else:
378
+ field_conditions = [
379
+ SUPPORTED_OPERATORS[op](attr, op_val)
380
+ for op, op_val in value.items()
381
+ if op in SUPPORTED_OPERATORS and op_val is not None
382
+ ]
383
+ if field_conditions:
384
+ conditions.append(and_(*field_conditions))
385
+ elif isinstance(value, list):
386
+ field_conditions = [
387
+ SUPPORTED_OPERATORS[cond[0]](attr, cond[1])
388
+ if isinstance(cond, (list, tuple)) and len(cond) == 2 and cond[0] in SUPPORTED_OPERATORS
389
+ else attr == cond
390
+ for cond in value
391
+ ]
392
+ conditions.append(and_(*field_conditions))
393
+
394
+
395
+ def build_flat_filter(
396
+ model: type[SQLModel],
397
+ filters: dict[str, Any],
398
+ suspend_error: bool = False,
399
+ ) -> tuple[list[Any], list[tuple[Any, Any]]]:
400
+ """Build filter conditions from a flattened dict (dot-notation keys).
401
+
402
+ Uses aliased() models for join handling.
403
+ Returns (conditions, join_entities).
404
+ """
405
+ conditions: list[Any] = []
406
+ model_aliases: dict[str, Any] = {}
407
+ join_entities: list[tuple[Any, Any]] = []
408
+
409
+ for field, value in filters.items():
410
+ field_parts = field.split(".")
411
+
412
+ if any(not part for part in field_parts):
413
+ if not suspend_error:
414
+ raise InvalidFilterError(f"Empty segment in filter path: {field!r}")
415
+ continue
416
+
417
+ if len(field_parts) > 1:
418
+ attr = _resolve_aliased_path(
419
+ field_parts, model, model_aliases, join_entities, suspend_error
420
+ )
421
+ if attr is None:
422
+ continue
423
+ else:
424
+ try:
425
+ _validate_field_name(field_parts[0])
426
+ except InvalidFilterError:
427
+ if not suspend_error:
428
+ raise
429
+ continue
430
+ try:
431
+ attr = getattr(model, field_parts[0])
432
+ except AttributeError:
433
+ if not suspend_error:
434
+ msg = f"Model {model.__name__} has no field {field_parts[0]}"
435
+ raise InvalidFilterError(msg)
436
+ continue
437
+
438
+ _apply_filter_value(attr, value, conditions)
439
+
440
+ return conditions, join_entities