pyrsql 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.
- pyrsql/__init__.py +63 -0
- pyrsql/adapters/__init__.py +3 -0
- pyrsql/adapters/fastapi/__init__.py +15 -0
- pyrsql/adapters/fastapi/config.py +76 -0
- pyrsql/adapters/fastapi/criteria.py +56 -0
- pyrsql/adapters/fastapi/dependency.py +217 -0
- pyrsql/adapters/fastapi/errors.py +107 -0
- pyrsql/api.py +76 -0
- pyrsql/core/__init__.py +45 -0
- pyrsql/core/compiler.py +81 -0
- pyrsql/core/conversion.py +206 -0
- pyrsql/core/custom.py +14 -0
- pyrsql/core/field_policy.py +97 -0
- pyrsql/core/joins.py +11 -0
- pyrsql/core/json/__init__.py +19 -0
- pyrsql/core/json/options.py +31 -0
- pyrsql/core/json/path.py +35 -0
- pyrsql/core/json/query.py +39 -0
- pyrsql/core/json/values.py +72 -0
- pyrsql/core/options.py +416 -0
- pyrsql/core/page.py +115 -0
- pyrsql/core/procedure_policy.py +61 -0
- pyrsql/core/query.py +160 -0
- pyrsql/core/sort.py +153 -0
- pyrsql/integrations/__init__.py +8 -0
- pyrsql/integrations/fastapi/__init__.py +8 -0
- pyrsql/integrations/fastapi/sqlalchemy.py +251 -0
- pyrsql/orms/__init__.py +15 -0
- pyrsql/orms/base.py +99 -0
- pyrsql/orms/sqlalchemy/__init__.py +39 -0
- pyrsql/orms/sqlalchemy/coercion.py +57 -0
- pyrsql/orms/sqlalchemy/compiled.py +56 -0
- pyrsql/orms/sqlalchemy/compiled_page.py +40 -0
- pyrsql/orms/sqlalchemy/compiled_sort.py +56 -0
- pyrsql/orms/sqlalchemy/custom.py +25 -0
- pyrsql/orms/sqlalchemy/errors.py +17 -0
- pyrsql/orms/sqlalchemy/introspection.py +107 -0
- pyrsql/orms/sqlalchemy/json_path.py +351 -0
- pyrsql/orms/sqlalchemy/orm.py +65 -0
- pyrsql/orms/sqlalchemy/resolver.py +202 -0
- pyrsql/orms/sqlalchemy/sorter.py +173 -0
- pyrsql/orms/sqlalchemy/statement.py +40 -0
- pyrsql/orms/sqlalchemy/translator.py +560 -0
- pyrsql/orms/sqlalchemy/type_inference.py +27 -0
- pyrsql/orms/sqlalchemy/types.py +64 -0
- pyrsql/parsing/__init__.py +32 -0
- pyrsql/parsing/ast.py +61 -0
- pyrsql/parsing/errors.py +25 -0
- pyrsql/parsing/lexer.py +264 -0
- pyrsql/parsing/limits.py +15 -0
- pyrsql/parsing/operators.py +201 -0
- pyrsql/parsing/parser.py +292 -0
- pyrsql/parsing/source.py +48 -0
- pyrsql/parsing/tokens.py +30 -0
- pyrsql/py.typed +0 -0
- pyrsql/selector/__init__.py +30 -0
- pyrsql/selector/analyzer.py +58 -0
- pyrsql/selector/ast.py +31 -0
- pyrsql/selector/parser.py +130 -0
- pyrsql/selector/semantic.py +34 -0
- pyrsql/semantic/__init__.py +23 -0
- pyrsql/semantic/analyzer.py +116 -0
- pyrsql/semantic/ast.py +35 -0
- pyrsql/semantic/errors.py +40 -0
- pyrsql/sorting/__init__.py +26 -0
- pyrsql/sorting/analyzer.py +74 -0
- pyrsql/sorting/ast.py +46 -0
- pyrsql/sorting/errors.py +21 -0
- pyrsql/sorting/limits.py +15 -0
- pyrsql/sorting/parser.py +150 -0
- pyrsql/sorting/semantic.py +15 -0
- pyrsql-0.0.1.dist-info/METADATA +72 -0
- pyrsql-0.0.1.dist-info/RECORD +74 -0
- pyrsql-0.0.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""JSON path expression building for the SQLAlchemy orm."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
import sqlalchemy as sa
|
|
9
|
+
from sqlalchemy.dialects import postgresql
|
|
10
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
11
|
+
|
|
12
|
+
from pyrsql.core.json.options import JSONOptions
|
|
13
|
+
from pyrsql.core.json.path import JSONPath
|
|
14
|
+
from pyrsql.core.json.query import JSONPathComparison
|
|
15
|
+
from pyrsql.core.json.values import JSONScalarValue
|
|
16
|
+
from pyrsql.orms.sqlalchemy.errors import SQLAlchemyORMError
|
|
17
|
+
from pyrsql.parsing.operators import (
|
|
18
|
+
BETWEEN,
|
|
19
|
+
EQUAL,
|
|
20
|
+
GREATER_THAN,
|
|
21
|
+
GREATER_THAN_OR_EQUAL,
|
|
22
|
+
IGNORE_CASE,
|
|
23
|
+
IGNORE_CASE_LIKE,
|
|
24
|
+
IGNORE_CASE_NOT_LIKE,
|
|
25
|
+
IN,
|
|
26
|
+
IS_NULL,
|
|
27
|
+
LESS_THAN,
|
|
28
|
+
LESS_THAN_OR_EQUAL,
|
|
29
|
+
LIKE,
|
|
30
|
+
NOT_BETWEEN,
|
|
31
|
+
NOT_EQUAL,
|
|
32
|
+
NOT_IN,
|
|
33
|
+
NOT_LIKE,
|
|
34
|
+
NOT_NULL,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
_ISO_DATE_TIME_PATTERN_TZ = re.compile(
|
|
38
|
+
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$"
|
|
39
|
+
)
|
|
40
|
+
_ISO_TIME_PATTERN_TZ = re.compile(
|
|
41
|
+
r"^\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$"
|
|
42
|
+
)
|
|
43
|
+
_ISO_DATE_TIME_PATTERN = re.compile(
|
|
44
|
+
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?$"
|
|
45
|
+
)
|
|
46
|
+
_ISO_DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
47
|
+
_ISO_TIME_PATTERN = re.compile(r"^\d{2}:\d{2}:\d{2}(\.\d+)?$")
|
|
48
|
+
_DEFAULT_JSON_OPTIONS = JSONOptions()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SQLAlchemyJSONPathExpressionBuilder:
|
|
52
|
+
"""Builds PostgreSQL JSON path expressions for SQLAlchemy."""
|
|
53
|
+
|
|
54
|
+
def build_filter_expression(
|
|
55
|
+
self,
|
|
56
|
+
column: ColumnElement[Any],
|
|
57
|
+
comparison: JSONPathComparison,
|
|
58
|
+
*,
|
|
59
|
+
options: JSONOptions | None = None,
|
|
60
|
+
) -> ColumnElement[bool]:
|
|
61
|
+
"""Builds a PostgreSQL JSONB path-exists predicate."""
|
|
62
|
+
function_call = self._build_filter_call(
|
|
63
|
+
comparison,
|
|
64
|
+
options=options or _DEFAULT_JSON_OPTIONS,
|
|
65
|
+
)
|
|
66
|
+
jsonb_column = cast(
|
|
67
|
+
ColumnElement[Any],
|
|
68
|
+
sa.cast(column, postgresql.JSONB),
|
|
69
|
+
)
|
|
70
|
+
if function_call.use_timezone_function:
|
|
71
|
+
function_expression = getattr(
|
|
72
|
+
sa.func,
|
|
73
|
+
function_call.path_exists_tz_function,
|
|
74
|
+
)(
|
|
75
|
+
jsonb_column,
|
|
76
|
+
sa.literal(function_call.json_path_expression),
|
|
77
|
+
)
|
|
78
|
+
return cast(ColumnElement[bool], function_expression)
|
|
79
|
+
if options and options.path_exists_function != "jsonb_path_exists":
|
|
80
|
+
function_expression = getattr(
|
|
81
|
+
sa.func,
|
|
82
|
+
options.path_exists_function,
|
|
83
|
+
)(
|
|
84
|
+
jsonb_column,
|
|
85
|
+
sa.literal(function_call.json_path_expression),
|
|
86
|
+
)
|
|
87
|
+
return cast(ColumnElement[bool], function_expression)
|
|
88
|
+
return cast(
|
|
89
|
+
ColumnElement[bool],
|
|
90
|
+
jsonb_column.path_exists(
|
|
91
|
+
sa.literal(function_call.json_path_expression)
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def build_sort_expression(
|
|
96
|
+
self,
|
|
97
|
+
column: ColumnElement[Any],
|
|
98
|
+
json_path: JSONPath,
|
|
99
|
+
) -> ColumnElement[Any]:
|
|
100
|
+
"""Builds a PostgreSQL JSON path extraction expression for sort."""
|
|
101
|
+
jsonb_column = cast(
|
|
102
|
+
ColumnElement[Any],
|
|
103
|
+
sa.cast(column, postgresql.JSONB),
|
|
104
|
+
)
|
|
105
|
+
if json_path.is_root:
|
|
106
|
+
return jsonb_column
|
|
107
|
+
return cast(
|
|
108
|
+
ColumnElement[Any],
|
|
109
|
+
jsonb_column[json_path.segments].as_string(),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def _build_filter_call(
|
|
113
|
+
self,
|
|
114
|
+
comparison: JSONPathComparison,
|
|
115
|
+
*,
|
|
116
|
+
options: JSONOptions,
|
|
117
|
+
) -> "_JSONPathFilterCall":
|
|
118
|
+
"""Builds the function call payload for one jsonpath predicate."""
|
|
119
|
+
target_path = (
|
|
120
|
+
"$"
|
|
121
|
+
if comparison.path.is_root
|
|
122
|
+
else "$." + comparison.path.to_dot_path()
|
|
123
|
+
)
|
|
124
|
+
use_datetime = options.use_datetime
|
|
125
|
+
use_timezone_function = use_datetime and any(
|
|
126
|
+
self._is_timezone_datetime_value(value)
|
|
127
|
+
for value in comparison.values
|
|
128
|
+
)
|
|
129
|
+
value_reference = "@"
|
|
130
|
+
if use_datetime and any(
|
|
131
|
+
self._is_datetime_value(value) for value in comparison.values
|
|
132
|
+
):
|
|
133
|
+
value_reference = "@.datetime()"
|
|
134
|
+
if comparison.operator_name == NOT_NULL.name:
|
|
135
|
+
comparison_clause = "(@ != null)"
|
|
136
|
+
elif comparison.operator_name == EQUAL.name:
|
|
137
|
+
comparison_clause = self._equality_comparison(
|
|
138
|
+
comparison.values[0],
|
|
139
|
+
use_datetime=use_datetime,
|
|
140
|
+
)
|
|
141
|
+
elif comparison.operator_name == NOT_EQUAL.name:
|
|
142
|
+
equality_clause = self._equality_comparison(
|
|
143
|
+
comparison.values[0],
|
|
144
|
+
use_datetime=use_datetime,
|
|
145
|
+
)
|
|
146
|
+
comparison_clause = f"!{equality_clause}"
|
|
147
|
+
elif comparison.operator_name == GREATER_THAN.name:
|
|
148
|
+
value_literal = self._print_value(
|
|
149
|
+
comparison.values[0],
|
|
150
|
+
use_datetime=use_datetime,
|
|
151
|
+
)
|
|
152
|
+
comparison_clause = f"({value_reference} > {value_literal})"
|
|
153
|
+
elif comparison.operator_name == GREATER_THAN_OR_EQUAL.name:
|
|
154
|
+
value_literal = self._print_value(
|
|
155
|
+
comparison.values[0],
|
|
156
|
+
use_datetime=use_datetime,
|
|
157
|
+
)
|
|
158
|
+
comparison_clause = f"({value_reference} >= {value_literal})"
|
|
159
|
+
elif comparison.operator_name == LESS_THAN.name:
|
|
160
|
+
value_literal = self._print_value(
|
|
161
|
+
comparison.values[0],
|
|
162
|
+
use_datetime=use_datetime,
|
|
163
|
+
)
|
|
164
|
+
comparison_clause = f"({value_reference} < {value_literal})"
|
|
165
|
+
elif comparison.operator_name == LESS_THAN_OR_EQUAL.name:
|
|
166
|
+
value_literal = self._print_value(
|
|
167
|
+
comparison.values[0],
|
|
168
|
+
use_datetime=use_datetime,
|
|
169
|
+
)
|
|
170
|
+
comparison_clause = f"({value_reference} <= {value_literal})"
|
|
171
|
+
elif comparison.operator_name == IN.name:
|
|
172
|
+
comparison_clause = self._membership_comparison(
|
|
173
|
+
value_reference,
|
|
174
|
+
comparison.values,
|
|
175
|
+
operator="==",
|
|
176
|
+
join_operator="||",
|
|
177
|
+
use_datetime=use_datetime,
|
|
178
|
+
)
|
|
179
|
+
elif comparison.operator_name == NOT_IN.name:
|
|
180
|
+
comparison_clause = self._membership_comparison(
|
|
181
|
+
value_reference,
|
|
182
|
+
comparison.values,
|
|
183
|
+
operator="!=",
|
|
184
|
+
join_operator="&&",
|
|
185
|
+
use_datetime=use_datetime,
|
|
186
|
+
)
|
|
187
|
+
elif comparison.operator_name == IS_NULL.name:
|
|
188
|
+
comparison_clause = "(@ == null)"
|
|
189
|
+
elif comparison.operator_name == LIKE.name:
|
|
190
|
+
comparison_clause = self._regex_comparison(
|
|
191
|
+
comparison.values[0],
|
|
192
|
+
ignore_case=False,
|
|
193
|
+
)
|
|
194
|
+
elif comparison.operator_name == NOT_LIKE.name:
|
|
195
|
+
comparison_clause = "!" + self._regex_comparison(
|
|
196
|
+
comparison.values[0],
|
|
197
|
+
ignore_case=False,
|
|
198
|
+
)
|
|
199
|
+
elif comparison.operator_name == IGNORE_CASE.name:
|
|
200
|
+
comparison_clause = self._regex_comparison(
|
|
201
|
+
comparison.values[0],
|
|
202
|
+
ignore_case=True,
|
|
203
|
+
)
|
|
204
|
+
elif comparison.operator_name == IGNORE_CASE_LIKE.name:
|
|
205
|
+
comparison_clause = self._regex_comparison(
|
|
206
|
+
comparison.values[0],
|
|
207
|
+
ignore_case=True,
|
|
208
|
+
)
|
|
209
|
+
elif comparison.operator_name == IGNORE_CASE_NOT_LIKE.name:
|
|
210
|
+
comparison_clause = "!" + self._regex_comparison(
|
|
211
|
+
comparison.values[0],
|
|
212
|
+
ignore_case=True,
|
|
213
|
+
)
|
|
214
|
+
elif comparison.operator_name == BETWEEN.name:
|
|
215
|
+
lower_literal = self._print_value(
|
|
216
|
+
comparison.values[0],
|
|
217
|
+
use_datetime=use_datetime,
|
|
218
|
+
)
|
|
219
|
+
upper_literal = self._print_value(
|
|
220
|
+
comparison.values[1],
|
|
221
|
+
use_datetime=use_datetime,
|
|
222
|
+
)
|
|
223
|
+
comparison_clause = (
|
|
224
|
+
f"({value_reference} >= {lower_literal}"
|
|
225
|
+
f" && {value_reference} <= {upper_literal})"
|
|
226
|
+
)
|
|
227
|
+
elif comparison.operator_name == NOT_BETWEEN.name:
|
|
228
|
+
lower_literal = self._print_value(
|
|
229
|
+
comparison.values[0],
|
|
230
|
+
use_datetime=use_datetime,
|
|
231
|
+
)
|
|
232
|
+
upper_literal = self._print_value(
|
|
233
|
+
comparison.values[1],
|
|
234
|
+
use_datetime=use_datetime,
|
|
235
|
+
)
|
|
236
|
+
comparison_clause = (
|
|
237
|
+
f"({value_reference} < {lower_literal}"
|
|
238
|
+
f" || {value_reference} > {upper_literal})"
|
|
239
|
+
)
|
|
240
|
+
else:
|
|
241
|
+
raise SQLAlchemyORMError(
|
|
242
|
+
f"Unsupported JSON operator {comparison.operator_name!r}."
|
|
243
|
+
)
|
|
244
|
+
return _JSONPathFilterCall(
|
|
245
|
+
json_path_expression=f"{target_path} ? {comparison_clause}",
|
|
246
|
+
use_timezone_function=use_timezone_function,
|
|
247
|
+
path_exists_tz_function=options.path_exists_tz_function,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def _equality_comparison(
|
|
251
|
+
self,
|
|
252
|
+
value: JSONScalarValue,
|
|
253
|
+
*,
|
|
254
|
+
use_datetime: bool,
|
|
255
|
+
) -> str:
|
|
256
|
+
"""Builds equality or wildcard comparison for JSON values."""
|
|
257
|
+
if value.python_type is str and "*" in str(value.value):
|
|
258
|
+
return self._regex_comparison(
|
|
259
|
+
value,
|
|
260
|
+
ignore_case=False,
|
|
261
|
+
)
|
|
262
|
+
value_reference = (
|
|
263
|
+
"@.datetime()"
|
|
264
|
+
if use_datetime and self._is_datetime_value(value)
|
|
265
|
+
else "@"
|
|
266
|
+
)
|
|
267
|
+
return (
|
|
268
|
+
f"({value_reference} == "
|
|
269
|
+
f"{self._print_value(value, use_datetime=use_datetime)})"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def _regex_comparison(
|
|
273
|
+
self,
|
|
274
|
+
value: JSONScalarValue,
|
|
275
|
+
*,
|
|
276
|
+
ignore_case: bool,
|
|
277
|
+
) -> str:
|
|
278
|
+
"""Builds a jsonpath like_regex comparison."""
|
|
279
|
+
if value.python_type is not str:
|
|
280
|
+
raise SQLAlchemyORMError(
|
|
281
|
+
"LIKE-style JSON operators require string values."
|
|
282
|
+
)
|
|
283
|
+
normalized = str(value.value)
|
|
284
|
+
if "*" not in normalized:
|
|
285
|
+
normalized = f"*{normalized}*"
|
|
286
|
+
pattern = re.escape(normalized).replace(r"\*", ".*")
|
|
287
|
+
literal = json.dumps(pattern)
|
|
288
|
+
if ignore_case:
|
|
289
|
+
return f'(@ like_regex {literal} flag "i")'
|
|
290
|
+
return f"(@ like_regex {literal})"
|
|
291
|
+
|
|
292
|
+
def _membership_comparison(
|
|
293
|
+
self,
|
|
294
|
+
value_reference: str,
|
|
295
|
+
values: tuple[JSONScalarValue, ...],
|
|
296
|
+
*,
|
|
297
|
+
operator: str,
|
|
298
|
+
join_operator: str,
|
|
299
|
+
use_datetime: bool,
|
|
300
|
+
) -> str:
|
|
301
|
+
"""Builds one JSON membership comparison clause."""
|
|
302
|
+
comparisons = (
|
|
303
|
+
(
|
|
304
|
+
f"({value_reference} {operator} "
|
|
305
|
+
f"{self._print_value(value, use_datetime=use_datetime)})"
|
|
306
|
+
)
|
|
307
|
+
for value in values
|
|
308
|
+
)
|
|
309
|
+
return "(" + f" {join_operator} ".join(comparisons) + ")"
|
|
310
|
+
|
|
311
|
+
def _print_value(
|
|
312
|
+
self,
|
|
313
|
+
value: JSONScalarValue,
|
|
314
|
+
*,
|
|
315
|
+
use_datetime: bool,
|
|
316
|
+
) -> str:
|
|
317
|
+
"""Prints one JSON value for PostgreSQL jsonpath expressions."""
|
|
318
|
+
if use_datetime and self._is_datetime_value(value):
|
|
319
|
+
return f'"{value.value}".datetime()'
|
|
320
|
+
return value.json_literal
|
|
321
|
+
|
|
322
|
+
def _is_datetime_value(self, value: JSONScalarValue) -> bool:
|
|
323
|
+
"""Returns whether a value is a supported ISO temporal string."""
|
|
324
|
+
if value.python_type is not str:
|
|
325
|
+
return False
|
|
326
|
+
normalized = str(value.value)
|
|
327
|
+
return (
|
|
328
|
+
_ISO_DATE_TIME_PATTERN.fullmatch(normalized) is not None
|
|
329
|
+
or _ISO_DATE_PATTERN.fullmatch(normalized) is not None
|
|
330
|
+
or _ISO_TIME_PATTERN.fullmatch(normalized) is not None
|
|
331
|
+
or self._is_timezone_datetime_value(value)
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def _is_timezone_datetime_value(self, value: JSONScalarValue) -> bool:
|
|
335
|
+
"""Returns whether a value is a timezone-aware ISO temporal string."""
|
|
336
|
+
if value.python_type is not str:
|
|
337
|
+
return False
|
|
338
|
+
normalized = str(value.value)
|
|
339
|
+
return (
|
|
340
|
+
_ISO_DATE_TIME_PATTERN_TZ.fullmatch(normalized) is not None
|
|
341
|
+
or _ISO_TIME_PATTERN_TZ.fullmatch(normalized) is not None
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@dataclass(frozen=True, slots=True)
|
|
346
|
+
class _JSONPathFilterCall:
|
|
347
|
+
"""Represents one PostgreSQL JSON path filter call."""
|
|
348
|
+
|
|
349
|
+
json_path_expression: str
|
|
350
|
+
use_timezone_function: bool
|
|
351
|
+
path_exists_tz_function: str
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""SQLAlchemy ORM entry point."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from pyrsql.orms import base
|
|
7
|
+
from pyrsql.orms.sqlalchemy.compiled import SQLAlchemyCompiledQuery
|
|
8
|
+
from pyrsql.orms.sqlalchemy.compiled_page import SQLAlchemyCompiledPageRequest
|
|
9
|
+
from pyrsql.orms.sqlalchemy.compiled_sort import SQLAlchemyCompiledSort
|
|
10
|
+
from pyrsql.orms.sqlalchemy.custom import SQLAlchemyCustomPredicate
|
|
11
|
+
from pyrsql.orms.sqlalchemy.sorter import SQLAlchemySortTranslator
|
|
12
|
+
from pyrsql.orms.sqlalchemy.translator import SQLAlchemyExpressionTranslator
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from pyrsql.core.page import PageRequest
|
|
16
|
+
from pyrsql.core.query import Query
|
|
17
|
+
from pyrsql.core.sort import Sort
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SQLAlchemyORM(base.ORM):
|
|
21
|
+
"""ORM adapter for SQLAlchemy 2.0 integration."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
translator: SQLAlchemyExpressionTranslator | None = None,
|
|
27
|
+
sort_translator: SQLAlchemySortTranslator | None = None,
|
|
28
|
+
custom_predicates: (
|
|
29
|
+
Mapping[str, SQLAlchemyCustomPredicate] | None
|
|
30
|
+
) = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._translator = translator or SQLAlchemyExpressionTranslator(
|
|
33
|
+
custom_predicates=custom_predicates,
|
|
34
|
+
)
|
|
35
|
+
self._sort_translator = sort_translator or SQLAlchemySortTranslator()
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def name(self) -> str:
|
|
39
|
+
"""Returns the ORM name."""
|
|
40
|
+
return "sqlalchemy"
|
|
41
|
+
|
|
42
|
+
def compile_query(self, query: "Query") -> SQLAlchemyCompiledQuery:
|
|
43
|
+
"""Compiles a pyrsql query for SQLAlchemy."""
|
|
44
|
+
if query.semantic_expression is None:
|
|
45
|
+
raise ValueError("Query must carry a semantic expression.")
|
|
46
|
+
return SQLAlchemyCompiledQuery(
|
|
47
|
+
expression=query.semantic_expression,
|
|
48
|
+
options=query.options,
|
|
49
|
+
translator=self._translator,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def compile_sort(self, sort: "Sort") -> SQLAlchemyCompiledSort:
|
|
53
|
+
"""Compiles a pyrsql sort for SQLAlchemy."""
|
|
54
|
+
return SQLAlchemyCompiledSort(
|
|
55
|
+
fields=sort.semantic_fields,
|
|
56
|
+
options=sort.options,
|
|
57
|
+
translator=self._sort_translator,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def compile_page_request(
|
|
61
|
+
self,
|
|
62
|
+
page_request: "PageRequest",
|
|
63
|
+
) -> SQLAlchemyCompiledPageRequest:
|
|
64
|
+
"""Compiles a pyrsql page request for SQLAlchemy."""
|
|
65
|
+
return SQLAlchemyCompiledPageRequest(page_request=page_request)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Path resolution for SQLAlchemy ORM models."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
6
|
+
|
|
7
|
+
from pyrsql.core.field_policy import FieldPolicySet
|
|
8
|
+
from pyrsql.core.joins import JoinHint
|
|
9
|
+
from pyrsql.core.json.path import JSONPath
|
|
10
|
+
from pyrsql.orms.sqlalchemy.errors import SQLAlchemyPathResolutionError
|
|
11
|
+
from pyrsql.orms.sqlalchemy.introspection import SQLAlchemyModelInspector
|
|
12
|
+
from pyrsql.orms.sqlalchemy.types import (
|
|
13
|
+
SQLAlchemyAttributeKind,
|
|
14
|
+
SQLAlchemyJoinPlan,
|
|
15
|
+
SQLAlchemyMappedAttribute,
|
|
16
|
+
SQLAlchemyResolvedPath,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SQLAlchemyPathResolver:
|
|
21
|
+
"""Resolves semantic field paths into ORM joins and leaf attributes."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
inspector: SQLAlchemyModelInspector | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self._inspector = inspector or SQLAlchemyModelInspector()
|
|
29
|
+
self._default_resolution_cache: dict[
|
|
30
|
+
tuple[type[Any], str], SQLAlchemyResolvedPath
|
|
31
|
+
] = {}
|
|
32
|
+
|
|
33
|
+
def resolve(
|
|
34
|
+
self,
|
|
35
|
+
model: type[Any],
|
|
36
|
+
field_path: str,
|
|
37
|
+
*,
|
|
38
|
+
field_policy: FieldPolicySet | None = None,
|
|
39
|
+
) -> SQLAlchemyResolvedPath:
|
|
40
|
+
"""Resolves a dotted field path against a mapped SQLAlchemy model."""
|
|
41
|
+
if field_policy is None or field_policy.is_empty:
|
|
42
|
+
cache_key = (model, field_path)
|
|
43
|
+
cached_path = self._default_resolution_cache.get(cache_key)
|
|
44
|
+
if cached_path is not None:
|
|
45
|
+
return cached_path
|
|
46
|
+
resolved_path = self._resolve_with_field_policy(
|
|
47
|
+
model,
|
|
48
|
+
field_path,
|
|
49
|
+
field_policy=None,
|
|
50
|
+
)
|
|
51
|
+
self._default_resolution_cache[cache_key] = resolved_path
|
|
52
|
+
return resolved_path
|
|
53
|
+
return self._resolve_with_field_policy(
|
|
54
|
+
model,
|
|
55
|
+
field_path,
|
|
56
|
+
field_policy=field_policy,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def _resolve_with_field_policy(
|
|
60
|
+
self,
|
|
61
|
+
model: type[Any],
|
|
62
|
+
field_path: str,
|
|
63
|
+
*,
|
|
64
|
+
field_policy: FieldPolicySet | None,
|
|
65
|
+
) -> SQLAlchemyResolvedPath:
|
|
66
|
+
"""Resolves a dotted field path with the provided field policy."""
|
|
67
|
+
if not field_path:
|
|
68
|
+
raise SQLAlchemyPathResolutionError("Field path cannot be empty.")
|
|
69
|
+
|
|
70
|
+
segments = tuple(
|
|
71
|
+
segment for segment in field_path.split(".") if segment
|
|
72
|
+
)
|
|
73
|
+
if not segments:
|
|
74
|
+
raise SQLAlchemyPathResolutionError(
|
|
75
|
+
f"Field path {field_path!r} is invalid."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
current_model = model
|
|
79
|
+
joins: list[SQLAlchemyJoinPlan] = []
|
|
80
|
+
leaf_attribute: SQLAlchemyMappedAttribute | None = None
|
|
81
|
+
segments_to_resolve = list(segments)
|
|
82
|
+
segment_index = 0
|
|
83
|
+
expansion_count = 0
|
|
84
|
+
|
|
85
|
+
while segment_index < len(segments_to_resolve):
|
|
86
|
+
segment = segments_to_resolve[segment_index]
|
|
87
|
+
if field_policy is not None:
|
|
88
|
+
mapped_segment = field_policy.map_model_field(
|
|
89
|
+
current_model,
|
|
90
|
+
segment,
|
|
91
|
+
)
|
|
92
|
+
if mapped_segment != segment:
|
|
93
|
+
mapped_segments = tuple(
|
|
94
|
+
part for part in mapped_segment.split(".") if part
|
|
95
|
+
)
|
|
96
|
+
if not mapped_segments:
|
|
97
|
+
raise SQLAlchemyPathResolutionError(
|
|
98
|
+
f"Mapped field {segment!r} on "
|
|
99
|
+
f"{current_model.__name__!r} is invalid."
|
|
100
|
+
)
|
|
101
|
+
segments_to_resolve[segment_index : segment_index + 1] = (
|
|
102
|
+
mapped_segments
|
|
103
|
+
)
|
|
104
|
+
expansion_count += 1
|
|
105
|
+
if expansion_count > 32:
|
|
106
|
+
raise SQLAlchemyPathResolutionError(
|
|
107
|
+
"Field mapping expansion exceeded the supported "
|
|
108
|
+
"limit."
|
|
109
|
+
)
|
|
110
|
+
continue
|
|
111
|
+
mapped_attribute = self._inspector.get_mapped_attribute(
|
|
112
|
+
current_model,
|
|
113
|
+
segment,
|
|
114
|
+
)
|
|
115
|
+
is_last_segment = segment_index == len(segments_to_resolve) - 1
|
|
116
|
+
|
|
117
|
+
if mapped_attribute.kind is SQLAlchemyAttributeKind.RELATIONSHIP:
|
|
118
|
+
if is_last_segment:
|
|
119
|
+
raise SQLAlchemyPathResolutionError(
|
|
120
|
+
f"Field path {field_path!r} ends on relationship "
|
|
121
|
+
f"{segment!r}; a column-like attribute is required."
|
|
122
|
+
)
|
|
123
|
+
joins.append(
|
|
124
|
+
SQLAlchemyJoinPlan(
|
|
125
|
+
key=self._make_join_key(current_model, segment),
|
|
126
|
+
attribute=mapped_attribute.attribute,
|
|
127
|
+
default_hint=JoinHint.INNER,
|
|
128
|
+
is_collection=mapped_attribute.is_collection,
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
if mapped_attribute.mapper is None:
|
|
132
|
+
raise RuntimeError("mapper cannot be None")
|
|
133
|
+
current_model = mapped_attribute.mapper.class_
|
|
134
|
+
segment_index += 1
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
if not is_last_segment:
|
|
138
|
+
if mapped_attribute.is_json:
|
|
139
|
+
return SQLAlchemyResolvedPath(
|
|
140
|
+
root_model=model,
|
|
141
|
+
leaf_model=current_model,
|
|
142
|
+
field_path=".".join(segments_to_resolve),
|
|
143
|
+
joins=tuple(joins),
|
|
144
|
+
leaf_attribute=cast(
|
|
145
|
+
ColumnElement[Any],
|
|
146
|
+
mapped_attribute.attribute,
|
|
147
|
+
),
|
|
148
|
+
python_type=None,
|
|
149
|
+
json_path=JSONPath(
|
|
150
|
+
tuple(segments_to_resolve[segment_index + 1 :])
|
|
151
|
+
),
|
|
152
|
+
is_json=True,
|
|
153
|
+
)
|
|
154
|
+
raise SQLAlchemyPathResolutionError(
|
|
155
|
+
f"Field path {field_path!r} traverses through "
|
|
156
|
+
f"non-relationship segment {segment!r}."
|
|
157
|
+
)
|
|
158
|
+
leaf_attribute = mapped_attribute
|
|
159
|
+
if field_policy is not None:
|
|
160
|
+
self._validate_field_policy(
|
|
161
|
+
field_policy,
|
|
162
|
+
current_model,
|
|
163
|
+
leaf_attribute.name,
|
|
164
|
+
".".join(segments_to_resolve),
|
|
165
|
+
)
|
|
166
|
+
segment_index += 1
|
|
167
|
+
|
|
168
|
+
if leaf_attribute is None:
|
|
169
|
+
raise RuntimeError("leaf_attribute cannot be None")
|
|
170
|
+
return SQLAlchemyResolvedPath(
|
|
171
|
+
root_model=model,
|
|
172
|
+
leaf_model=current_model,
|
|
173
|
+
field_path=field_path,
|
|
174
|
+
joins=tuple(joins),
|
|
175
|
+
leaf_attribute=cast(
|
|
176
|
+
ColumnElement[Any],
|
|
177
|
+
leaf_attribute.attribute,
|
|
178
|
+
),
|
|
179
|
+
python_type=leaf_attribute.python_type,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def _make_join_key(
|
|
184
|
+
model: type[Any],
|
|
185
|
+
segment: str,
|
|
186
|
+
) -> str:
|
|
187
|
+
"""Builds the stable join-hint lookup key for one relationship step."""
|
|
188
|
+
return f"{model.__name__}.{segment}"
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _validate_field_policy(
|
|
192
|
+
field_policy: FieldPolicySet,
|
|
193
|
+
model: type[Any],
|
|
194
|
+
field_name: str,
|
|
195
|
+
field_path: str,
|
|
196
|
+
) -> None:
|
|
197
|
+
"""Validates field access using global and model-specific rules."""
|
|
198
|
+
try:
|
|
199
|
+
field_policy.validate_global_field_access(field_path)
|
|
200
|
+
field_policy.validate_model_field_access(model, field_name)
|
|
201
|
+
except ValueError as error:
|
|
202
|
+
raise SQLAlchemyPathResolutionError(str(error)) from error
|