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.
- sqlmodel_object_helpers/__init__.py +97 -0
- sqlmodel_object_helpers/constants.py +34 -0
- sqlmodel_object_helpers/exceptions.py +52 -0
- sqlmodel_object_helpers/filters.py +440 -0
- sqlmodel_object_helpers/loaders.py +88 -0
- sqlmodel_object_helpers/mutations.py +339 -0
- sqlmodel_object_helpers/operators.py +43 -0
- sqlmodel_object_helpers/query.py +563 -0
- sqlmodel_object_helpers/session.py +36 -0
- sqlmodel_object_helpers/types/__init__.py +0 -0
- sqlmodel_object_helpers/types/filters.py +126 -0
- sqlmodel_object_helpers/types/pagination.py +15 -0
- sqlmodel_object_helpers/types/projections.py +8 -0
- sqlmodel_object_helpers-0.0.1.dist-info/METADATA +515 -0
- sqlmodel_object_helpers-0.0.1.dist-info/RECORD +17 -0
- sqlmodel_object_helpers-0.0.1.dist-info/WHEEL +4 -0
- sqlmodel_object_helpers-0.0.1.dist-info/licenses/LICENSE +75 -0
|
@@ -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
|