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,560 @@
|
|
|
1
|
+
"""Semantic expression translation for SQLAlchemy."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from types import MappingProxyType
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
import sqlalchemy as sa
|
|
8
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
9
|
+
|
|
10
|
+
from pyrsql.core.json.query import JSONPathComparison
|
|
11
|
+
from pyrsql.core.options import QueryOptions
|
|
12
|
+
from pyrsql.orms.sqlalchemy.coercion import SQLAlchemyValueCoercer
|
|
13
|
+
from pyrsql.orms.sqlalchemy.custom import (
|
|
14
|
+
SQLAlchemyCustomPredicate,
|
|
15
|
+
SQLAlchemyCustomPredicateInput,
|
|
16
|
+
)
|
|
17
|
+
from pyrsql.orms.sqlalchemy.errors import SQLAlchemyORMError
|
|
18
|
+
from pyrsql.orms.sqlalchemy.json_path import (
|
|
19
|
+
SQLAlchemyJSONPathExpressionBuilder,
|
|
20
|
+
)
|
|
21
|
+
from pyrsql.orms.sqlalchemy.resolver import SQLAlchemyPathResolver
|
|
22
|
+
from pyrsql.orms.sqlalchemy.type_inference import (
|
|
23
|
+
infer_sql_function_python_type,
|
|
24
|
+
is_string_python_type,
|
|
25
|
+
)
|
|
26
|
+
from pyrsql.orms.sqlalchemy.types import (
|
|
27
|
+
SQLAlchemyJoinPlan,
|
|
28
|
+
SQLAlchemyResolvedPath,
|
|
29
|
+
)
|
|
30
|
+
from pyrsql.parsing.operators import (
|
|
31
|
+
BETWEEN,
|
|
32
|
+
EQUAL,
|
|
33
|
+
GREATER_THAN,
|
|
34
|
+
GREATER_THAN_OR_EQUAL,
|
|
35
|
+
IGNORE_CASE,
|
|
36
|
+
IGNORE_CASE_LIKE,
|
|
37
|
+
IGNORE_CASE_NOT_LIKE,
|
|
38
|
+
IN,
|
|
39
|
+
IS_NULL,
|
|
40
|
+
LESS_THAN,
|
|
41
|
+
LESS_THAN_OR_EQUAL,
|
|
42
|
+
LIKE,
|
|
43
|
+
NOT_BETWEEN,
|
|
44
|
+
NOT_EQUAL,
|
|
45
|
+
NOT_IN,
|
|
46
|
+
NOT_LIKE,
|
|
47
|
+
NOT_NULL,
|
|
48
|
+
)
|
|
49
|
+
from pyrsql.selector.semantic import (
|
|
50
|
+
SemanticColumnSelector,
|
|
51
|
+
SemanticFunctionSelector,
|
|
52
|
+
SemanticLiteralSelector,
|
|
53
|
+
SemanticSelector,
|
|
54
|
+
)
|
|
55
|
+
from pyrsql.semantic.ast import (
|
|
56
|
+
SemanticComparison,
|
|
57
|
+
SemanticExpression,
|
|
58
|
+
SemanticLogical,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SQLAlchemyExpressionTranslator:
|
|
63
|
+
"""Translates semantic expressions to SQLAlchemy predicates.
|
|
64
|
+
|
|
65
|
+
The translator resolves ORM paths, coerces values, and produces SQLAlchemy
|
|
66
|
+
join plans and predicates from semantic expressions.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
path_resolver: SQLAlchemyPathResolver | None = None,
|
|
73
|
+
value_coercer: SQLAlchemyValueCoercer | None = None,
|
|
74
|
+
custom_predicates: (
|
|
75
|
+
Mapping[str, SQLAlchemyCustomPredicate] | None
|
|
76
|
+
) = None,
|
|
77
|
+
json_path_builder: SQLAlchemyJSONPathExpressionBuilder | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
self._path_resolver = path_resolver or SQLAlchemyPathResolver()
|
|
80
|
+
self._value_coercer = value_coercer or SQLAlchemyValueCoercer()
|
|
81
|
+
self._json_path_builder = (
|
|
82
|
+
json_path_builder or SQLAlchemyJSONPathExpressionBuilder()
|
|
83
|
+
)
|
|
84
|
+
self._custom_predicates = MappingProxyType(
|
|
85
|
+
dict(custom_predicates or {})
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def translate(
|
|
89
|
+
self,
|
|
90
|
+
model: type[Any],
|
|
91
|
+
expression: SemanticExpression,
|
|
92
|
+
*,
|
|
93
|
+
options: QueryOptions,
|
|
94
|
+
) -> tuple[tuple[SQLAlchemyJoinPlan, ...], ColumnElement[bool]]:
|
|
95
|
+
"""Translates a semantic expression for a mapped model.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
model: SQLAlchemy mapped class used to resolve fields.
|
|
99
|
+
expression: Semantic expression to translate.
|
|
100
|
+
options: Query configuration used during translation.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
A tuple containing join plans and the SQLAlchemy predicate.
|
|
104
|
+
"""
|
|
105
|
+
if isinstance(expression, SemanticComparison):
|
|
106
|
+
return self._translate_comparison(
|
|
107
|
+
model,
|
|
108
|
+
expression,
|
|
109
|
+
options=options,
|
|
110
|
+
)
|
|
111
|
+
return self._translate_logical(model, expression, options=options)
|
|
112
|
+
|
|
113
|
+
def _translate_logical(
|
|
114
|
+
self,
|
|
115
|
+
model: type[Any],
|
|
116
|
+
expression: SemanticLogical,
|
|
117
|
+
*,
|
|
118
|
+
options: QueryOptions,
|
|
119
|
+
) -> tuple[tuple[SQLAlchemyJoinPlan, ...], ColumnElement[bool]]:
|
|
120
|
+
"""Translates a logical semantic expression."""
|
|
121
|
+
joins: list[SQLAlchemyJoinPlan] = []
|
|
122
|
+
predicates: list[ColumnElement[bool]] = []
|
|
123
|
+
for child in expression.children:
|
|
124
|
+
child_joins, child_predicate = self.translate(
|
|
125
|
+
model,
|
|
126
|
+
child,
|
|
127
|
+
options=options,
|
|
128
|
+
)
|
|
129
|
+
joins.extend(child_joins)
|
|
130
|
+
predicates.append(child_predicate)
|
|
131
|
+
if expression.operator.name == "AND":
|
|
132
|
+
return tuple(joins), sa.and_(*predicates)
|
|
133
|
+
return tuple(joins), sa.or_(*predicates)
|
|
134
|
+
|
|
135
|
+
def _translate_comparison(
|
|
136
|
+
self,
|
|
137
|
+
model: type[Any],
|
|
138
|
+
expression: SemanticComparison,
|
|
139
|
+
*,
|
|
140
|
+
options: QueryOptions,
|
|
141
|
+
) -> tuple[tuple[SQLAlchemyJoinPlan, ...], ColumnElement[bool]]:
|
|
142
|
+
"""Translates a comparison semantic expression."""
|
|
143
|
+
field_model = None
|
|
144
|
+
field_name = None
|
|
145
|
+
field_path = None
|
|
146
|
+
if isinstance(expression.selector, SemanticColumnSelector):
|
|
147
|
+
resolved_path = self._path_resolver.resolve(
|
|
148
|
+
model,
|
|
149
|
+
expression.selector.field_path,
|
|
150
|
+
field_policy=options.field_policy,
|
|
151
|
+
)
|
|
152
|
+
selector_joins = resolved_path.joins
|
|
153
|
+
selector_expression = resolved_path.leaf_attribute
|
|
154
|
+
python_type = resolved_path.python_type
|
|
155
|
+
field_model = resolved_path.leaf_model
|
|
156
|
+
field_name = getattr(resolved_path.leaf_attribute, "key", None)
|
|
157
|
+
field_path = resolved_path.field_path
|
|
158
|
+
if resolved_path.is_json:
|
|
159
|
+
json_comparison = JSONPathComparison.from_raw_arguments(
|
|
160
|
+
path=resolved_path.json_path,
|
|
161
|
+
operator_name=expression.operator.name,
|
|
162
|
+
raw_arguments=tuple(
|
|
163
|
+
(argument.text, argument.quoted)
|
|
164
|
+
for argument in expression.arguments
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
predicate = self._json_path_builder.build_filter_expression(
|
|
168
|
+
selector_expression,
|
|
169
|
+
json_comparison,
|
|
170
|
+
options=options.json_options,
|
|
171
|
+
)
|
|
172
|
+
if self._should_use_exists_predicate(
|
|
173
|
+
selector_joins,
|
|
174
|
+
options=options,
|
|
175
|
+
):
|
|
176
|
+
return (), self._wrap_exists_predicate(
|
|
177
|
+
selector_joins,
|
|
178
|
+
predicate,
|
|
179
|
+
)
|
|
180
|
+
return selector_joins, predicate
|
|
181
|
+
else:
|
|
182
|
+
selector_joins, selector_expression, python_type = (
|
|
183
|
+
self._translate_selector(
|
|
184
|
+
model,
|
|
185
|
+
expression.selector,
|
|
186
|
+
options=options,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
custom_predicate = options.custom_predicates.get(
|
|
190
|
+
expression.operator.name
|
|
191
|
+
)
|
|
192
|
+
argument_type = (
|
|
193
|
+
custom_predicate.argument_type
|
|
194
|
+
if custom_predicate is not None
|
|
195
|
+
else python_type
|
|
196
|
+
)
|
|
197
|
+
coerced_values = tuple(
|
|
198
|
+
self._value_coercer.coerce(
|
|
199
|
+
argument.text,
|
|
200
|
+
argument_type,
|
|
201
|
+
field_converter_set=options.field_converter_set,
|
|
202
|
+
model=field_model,
|
|
203
|
+
field_name=field_name,
|
|
204
|
+
field_path=field_path,
|
|
205
|
+
registry=options.value_converter_registry,
|
|
206
|
+
)
|
|
207
|
+
for argument in expression.arguments
|
|
208
|
+
)
|
|
209
|
+
predicate = self._build_predicate(
|
|
210
|
+
selector_expression,
|
|
211
|
+
python_type,
|
|
212
|
+
expression.operator.name,
|
|
213
|
+
coerced_values,
|
|
214
|
+
options=options,
|
|
215
|
+
)
|
|
216
|
+
if self._should_use_exists_predicate(
|
|
217
|
+
selector_joins,
|
|
218
|
+
options=options,
|
|
219
|
+
):
|
|
220
|
+
return (), self._wrap_exists_predicate(
|
|
221
|
+
selector_joins,
|
|
222
|
+
predicate,
|
|
223
|
+
)
|
|
224
|
+
return selector_joins, predicate
|
|
225
|
+
|
|
226
|
+
def _translate_selector(
|
|
227
|
+
self,
|
|
228
|
+
model: type[Any],
|
|
229
|
+
selector: SemanticSelector,
|
|
230
|
+
*,
|
|
231
|
+
options: QueryOptions,
|
|
232
|
+
) -> tuple[
|
|
233
|
+
tuple[SQLAlchemyJoinPlan, ...],
|
|
234
|
+
ColumnElement[Any],
|
|
235
|
+
type[Any] | None,
|
|
236
|
+
]:
|
|
237
|
+
"""Translates a semantic selector recursively."""
|
|
238
|
+
if isinstance(selector, SemanticColumnSelector):
|
|
239
|
+
resolved_path = self._path_resolver.resolve(
|
|
240
|
+
model,
|
|
241
|
+
selector.field_path,
|
|
242
|
+
field_policy=options.field_policy,
|
|
243
|
+
)
|
|
244
|
+
return (
|
|
245
|
+
resolved_path.joins,
|
|
246
|
+
self._resolve_column_expression(resolved_path),
|
|
247
|
+
str if resolved_path.is_json else resolved_path.python_type,
|
|
248
|
+
)
|
|
249
|
+
if isinstance(selector, SemanticLiteralSelector):
|
|
250
|
+
python_type = (
|
|
251
|
+
type(selector.value) if selector.value is not None else None
|
|
252
|
+
)
|
|
253
|
+
return (), sa.literal(selector.value), python_type
|
|
254
|
+
|
|
255
|
+
if not isinstance(selector, SemanticFunctionSelector):
|
|
256
|
+
raise TypeError("Expected SemanticFunctionSelector")
|
|
257
|
+
joins: list[SQLAlchemyJoinPlan] = []
|
|
258
|
+
argument_expressions: list[ColumnElement[Any]] = []
|
|
259
|
+
argument_types: list[type[Any] | None] = []
|
|
260
|
+
for argument in selector.arguments:
|
|
261
|
+
argument_joins, argument_expression, argument_type = (
|
|
262
|
+
self._translate_selector(
|
|
263
|
+
model,
|
|
264
|
+
argument,
|
|
265
|
+
options=options,
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
joins.extend(argument_joins)
|
|
269
|
+
argument_expressions.append(argument_expression)
|
|
270
|
+
argument_types.append(argument_type)
|
|
271
|
+
function_expression = getattr(sa.func, selector.function_name)(
|
|
272
|
+
*argument_expressions
|
|
273
|
+
)
|
|
274
|
+
return (
|
|
275
|
+
tuple(joins),
|
|
276
|
+
cast(ColumnElement[Any], function_expression),
|
|
277
|
+
infer_sql_function_python_type(
|
|
278
|
+
selector.function_name,
|
|
279
|
+
tuple(argument_types),
|
|
280
|
+
),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
def _resolve_column_expression(
|
|
284
|
+
self,
|
|
285
|
+
resolved_path: SQLAlchemyResolvedPath,
|
|
286
|
+
) -> ColumnElement[Any]:
|
|
287
|
+
"""Builds the effective SQL expression for a resolved path."""
|
|
288
|
+
base_expression = resolved_path.leaf_attribute
|
|
289
|
+
if not resolved_path.is_json:
|
|
290
|
+
return base_expression
|
|
291
|
+
return self._json_path_builder.build_sort_expression(
|
|
292
|
+
base_expression,
|
|
293
|
+
resolved_path.json_path,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def _build_predicate(
|
|
297
|
+
self,
|
|
298
|
+
expression: ColumnElement[Any],
|
|
299
|
+
python_type: type[Any] | None,
|
|
300
|
+
operator_name: str,
|
|
301
|
+
values: tuple[Any, ...],
|
|
302
|
+
*,
|
|
303
|
+
options: QueryOptions,
|
|
304
|
+
) -> ColumnElement[bool]:
|
|
305
|
+
"""Builds a SQLAlchemy predicate for a resolved path."""
|
|
306
|
+
custom_predicate = self._custom_predicates.get(operator_name)
|
|
307
|
+
if custom_predicate is not None:
|
|
308
|
+
return custom_predicate(
|
|
309
|
+
SQLAlchemyCustomPredicateInput(
|
|
310
|
+
expression=expression,
|
|
311
|
+
python_type=python_type,
|
|
312
|
+
values=values,
|
|
313
|
+
options=options,
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
if operator_name == EQUAL.name:
|
|
317
|
+
return self._build_equality_predicate(
|
|
318
|
+
expression,
|
|
319
|
+
python_type,
|
|
320
|
+
values[0],
|
|
321
|
+
negated=False,
|
|
322
|
+
options=options,
|
|
323
|
+
)
|
|
324
|
+
if operator_name == NOT_EQUAL.name:
|
|
325
|
+
return self._build_equality_predicate(
|
|
326
|
+
expression,
|
|
327
|
+
python_type,
|
|
328
|
+
values[0],
|
|
329
|
+
negated=True,
|
|
330
|
+
options=options,
|
|
331
|
+
)
|
|
332
|
+
if operator_name == GREATER_THAN.name:
|
|
333
|
+
return cast(ColumnElement[bool], expression > values[0])
|
|
334
|
+
if operator_name == GREATER_THAN_OR_EQUAL.name:
|
|
335
|
+
return cast(ColumnElement[bool], expression >= values[0])
|
|
336
|
+
if operator_name == LESS_THAN.name:
|
|
337
|
+
return cast(ColumnElement[bool], expression < values[0])
|
|
338
|
+
if operator_name == LESS_THAN_OR_EQUAL.name:
|
|
339
|
+
return cast(ColumnElement[bool], expression <= values[0])
|
|
340
|
+
if operator_name == IN.name:
|
|
341
|
+
return cast(ColumnElement[bool], expression.in_(values))
|
|
342
|
+
if operator_name == NOT_IN.name:
|
|
343
|
+
return cast(ColumnElement[bool], expression.not_in(values))
|
|
344
|
+
if operator_name == IS_NULL.name:
|
|
345
|
+
return cast(ColumnElement[bool], expression.is_(None))
|
|
346
|
+
if operator_name == NOT_NULL.name:
|
|
347
|
+
return cast(ColumnElement[bool], expression.is_not(None))
|
|
348
|
+
if operator_name == LIKE.name:
|
|
349
|
+
return self._build_contains_predicate(
|
|
350
|
+
expression,
|
|
351
|
+
str(values[0]),
|
|
352
|
+
ignore_case=False,
|
|
353
|
+
negated=False,
|
|
354
|
+
options=options,
|
|
355
|
+
)
|
|
356
|
+
if operator_name == NOT_LIKE.name:
|
|
357
|
+
return self._build_contains_predicate(
|
|
358
|
+
expression,
|
|
359
|
+
str(values[0]),
|
|
360
|
+
ignore_case=False,
|
|
361
|
+
negated=True,
|
|
362
|
+
options=options,
|
|
363
|
+
)
|
|
364
|
+
if operator_name == IGNORE_CASE.name:
|
|
365
|
+
return self._build_case_insensitive_equality_predicate(
|
|
366
|
+
expression,
|
|
367
|
+
str(values[0]),
|
|
368
|
+
negated=False,
|
|
369
|
+
)
|
|
370
|
+
if operator_name == IGNORE_CASE_LIKE.name:
|
|
371
|
+
return self._build_contains_predicate(
|
|
372
|
+
expression,
|
|
373
|
+
str(values[0]),
|
|
374
|
+
ignore_case=True,
|
|
375
|
+
negated=False,
|
|
376
|
+
options=options,
|
|
377
|
+
)
|
|
378
|
+
if operator_name == IGNORE_CASE_NOT_LIKE.name:
|
|
379
|
+
return self._build_contains_predicate(
|
|
380
|
+
expression,
|
|
381
|
+
str(values[0]),
|
|
382
|
+
ignore_case=True,
|
|
383
|
+
negated=True,
|
|
384
|
+
options=options,
|
|
385
|
+
)
|
|
386
|
+
if operator_name == BETWEEN.name:
|
|
387
|
+
return cast(
|
|
388
|
+
ColumnElement[bool],
|
|
389
|
+
expression.between(values[0], values[1]),
|
|
390
|
+
)
|
|
391
|
+
if operator_name == NOT_BETWEEN.name:
|
|
392
|
+
return cast(
|
|
393
|
+
ColumnElement[bool],
|
|
394
|
+
sa.not_(expression.between(values[0], values[1])),
|
|
395
|
+
)
|
|
396
|
+
raise SQLAlchemyORMError(
|
|
397
|
+
f"Unsupported SQLAlchemy operator {operator_name!r}."
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
def _build_equality_predicate(
|
|
401
|
+
self,
|
|
402
|
+
expression: ColumnElement[Any],
|
|
403
|
+
python_type: type[Any] | None,
|
|
404
|
+
value: Any,
|
|
405
|
+
*,
|
|
406
|
+
negated: bool,
|
|
407
|
+
options: QueryOptions,
|
|
408
|
+
) -> ColumnElement[bool]:
|
|
409
|
+
"""Builds equality semantics, including RSQL wildcard handling."""
|
|
410
|
+
predicate: ColumnElement[bool]
|
|
411
|
+
if is_string_python_type(python_type):
|
|
412
|
+
predicate = self._build_string_equality_predicate(
|
|
413
|
+
expression,
|
|
414
|
+
str(value),
|
|
415
|
+
options=options,
|
|
416
|
+
)
|
|
417
|
+
else:
|
|
418
|
+
predicate = cast(ColumnElement[bool], expression == value)
|
|
419
|
+
if not negated:
|
|
420
|
+
return predicate
|
|
421
|
+
return cast(ColumnElement[bool], sa.not_(predicate))
|
|
422
|
+
|
|
423
|
+
def _build_string_equality_predicate(
|
|
424
|
+
self,
|
|
425
|
+
expression: ColumnElement[Any],
|
|
426
|
+
value: str,
|
|
427
|
+
*,
|
|
428
|
+
options: QueryOptions,
|
|
429
|
+
) -> ColumnElement[bool]:
|
|
430
|
+
"""Builds equality for string values with wildcard semantics."""
|
|
431
|
+
if options.strict_equality:
|
|
432
|
+
return expression == value
|
|
433
|
+
|
|
434
|
+
ignore_case = "^" in value
|
|
435
|
+
normalized_value = value.replace("^", "")
|
|
436
|
+
if "*" in normalized_value:
|
|
437
|
+
pattern = normalized_value.replace("*", "%")
|
|
438
|
+
return self._build_pattern_predicate(
|
|
439
|
+
expression,
|
|
440
|
+
pattern,
|
|
441
|
+
ignore_case=ignore_case,
|
|
442
|
+
negated=False,
|
|
443
|
+
options=options,
|
|
444
|
+
)
|
|
445
|
+
if ignore_case:
|
|
446
|
+
return self._build_case_insensitive_equality_predicate(
|
|
447
|
+
expression,
|
|
448
|
+
normalized_value,
|
|
449
|
+
negated=False,
|
|
450
|
+
)
|
|
451
|
+
return expression == normalized_value
|
|
452
|
+
|
|
453
|
+
def _build_case_insensitive_equality_predicate(
|
|
454
|
+
self,
|
|
455
|
+
expression: ColumnElement[Any],
|
|
456
|
+
value: str,
|
|
457
|
+
*,
|
|
458
|
+
negated: bool,
|
|
459
|
+
) -> ColumnElement[bool]:
|
|
460
|
+
"""Builds a case-insensitive equality predicate."""
|
|
461
|
+
predicate = sa.func.lower(expression) == value.lower()
|
|
462
|
+
if not negated:
|
|
463
|
+
return predicate
|
|
464
|
+
return cast(ColumnElement[bool], sa.not_(predicate))
|
|
465
|
+
|
|
466
|
+
def _build_contains_predicate(
|
|
467
|
+
self,
|
|
468
|
+
expression: ColumnElement[Any],
|
|
469
|
+
value: str,
|
|
470
|
+
*,
|
|
471
|
+
ignore_case: bool,
|
|
472
|
+
negated: bool,
|
|
473
|
+
options: QueryOptions,
|
|
474
|
+
) -> ColumnElement[bool]:
|
|
475
|
+
"""Builds contains-style LIKE semantics with optional escaping."""
|
|
476
|
+
pattern = f"%{value}%"
|
|
477
|
+
return self._build_pattern_predicate(
|
|
478
|
+
expression,
|
|
479
|
+
pattern,
|
|
480
|
+
ignore_case=ignore_case,
|
|
481
|
+
negated=negated,
|
|
482
|
+
options=options,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
def _build_pattern_predicate(
|
|
486
|
+
self,
|
|
487
|
+
expression: ColumnElement[Any],
|
|
488
|
+
pattern: str,
|
|
489
|
+
*,
|
|
490
|
+
ignore_case: bool,
|
|
491
|
+
negated: bool,
|
|
492
|
+
options: QueryOptions,
|
|
493
|
+
) -> ColumnElement[bool]:
|
|
494
|
+
"""Builds a LIKE or ILIKE predicate with optional escaping."""
|
|
495
|
+
escape_character = options.like_escape_character
|
|
496
|
+
if ignore_case:
|
|
497
|
+
if negated:
|
|
498
|
+
return cast(
|
|
499
|
+
ColumnElement[bool],
|
|
500
|
+
expression.not_ilike(pattern, escape=escape_character),
|
|
501
|
+
)
|
|
502
|
+
return cast(
|
|
503
|
+
ColumnElement[bool],
|
|
504
|
+
expression.ilike(pattern, escape=escape_character),
|
|
505
|
+
)
|
|
506
|
+
if negated:
|
|
507
|
+
return cast(
|
|
508
|
+
ColumnElement[bool],
|
|
509
|
+
expression.not_like(pattern, escape=escape_character),
|
|
510
|
+
)
|
|
511
|
+
return cast(
|
|
512
|
+
ColumnElement[bool],
|
|
513
|
+
expression.like(pattern, escape=escape_character),
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
def _is_string_type(self, python_type: type[Any] | None) -> bool:
|
|
517
|
+
"""Returns whether the resolved Python type is string-compatible."""
|
|
518
|
+
if python_type is None:
|
|
519
|
+
return False
|
|
520
|
+
return issubclass(python_type, str)
|
|
521
|
+
|
|
522
|
+
def _should_use_exists_predicate(
|
|
523
|
+
self,
|
|
524
|
+
joins: tuple[SQLAlchemyJoinPlan, ...],
|
|
525
|
+
*,
|
|
526
|
+
options: QueryOptions,
|
|
527
|
+
) -> bool:
|
|
528
|
+
"""Returns whether a filter should use relationship EXISTS forms."""
|
|
529
|
+
if options.join_hints:
|
|
530
|
+
return False
|
|
531
|
+
return any(join_plan.is_collection for join_plan in joins)
|
|
532
|
+
|
|
533
|
+
def _wrap_exists_predicate(
|
|
534
|
+
self,
|
|
535
|
+
joins: tuple[SQLAlchemyJoinPlan, ...],
|
|
536
|
+
predicate: ColumnElement[bool],
|
|
537
|
+
) -> ColumnElement[bool]:
|
|
538
|
+
"""Wraps a leaf predicate using relationship any()/has()."""
|
|
539
|
+
wrapped_predicate = predicate
|
|
540
|
+
for join_plan in reversed(joins):
|
|
541
|
+
if join_plan.is_collection:
|
|
542
|
+
wrapped_predicate = join_plan.attribute.any(wrapped_predicate)
|
|
543
|
+
continue
|
|
544
|
+
wrapped_predicate = join_plan.attribute.has(wrapped_predicate)
|
|
545
|
+
return wrapped_predicate
|
|
546
|
+
|
|
547
|
+
def _infer_function_python_type(
|
|
548
|
+
self,
|
|
549
|
+
function_name: str,
|
|
550
|
+
argument_types: tuple[type[Any] | None, ...],
|
|
551
|
+
) -> type[Any] | None:
|
|
552
|
+
"""Infers the Python type for common SQL functions."""
|
|
553
|
+
normalized_name = function_name.lower()
|
|
554
|
+
if normalized_name in {"lower", "upper", "concat"}:
|
|
555
|
+
return str
|
|
556
|
+
if normalized_name == "coalesce":
|
|
557
|
+
for argument_type in argument_types:
|
|
558
|
+
if argument_type is not None:
|
|
559
|
+
return argument_type
|
|
560
|
+
return None
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Shared Python-type inference helpers for SQLAlchemy ORM translation."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
_STRING_SQL_FUNCTIONS = frozenset({"lower", "upper", "concat"})
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def infer_sql_function_python_type(
|
|
9
|
+
function_name: str,
|
|
10
|
+
argument_types: tuple[type[Any] | None, ...],
|
|
11
|
+
) -> type[Any] | None:
|
|
12
|
+
"""Infers the Python type for common SQL functions."""
|
|
13
|
+
normalized_name = function_name.lower()
|
|
14
|
+
if normalized_name in _STRING_SQL_FUNCTIONS:
|
|
15
|
+
return str
|
|
16
|
+
if normalized_name == "coalesce":
|
|
17
|
+
for argument_type in argument_types:
|
|
18
|
+
if argument_type is not None:
|
|
19
|
+
return argument_type
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_string_python_type(python_type: type[Any] | None) -> bool:
|
|
24
|
+
"""Returns whether the resolved Python type is string-compatible."""
|
|
25
|
+
if python_type is None:
|
|
26
|
+
return False
|
|
27
|
+
return issubclass(python_type, str)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Shared SQLAlchemy ORM value objects and type aliases."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum, auto
|
|
5
|
+
from typing import Any, TypeAlias
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.orm import Mapper
|
|
8
|
+
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
|
9
|
+
from sqlalchemy.sql import Select
|
|
10
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
11
|
+
|
|
12
|
+
from pyrsql.core.joins import JoinHint
|
|
13
|
+
from pyrsql.core.json.path import JSONPath
|
|
14
|
+
|
|
15
|
+
SQLAlchemyModel: TypeAlias = type[Any]
|
|
16
|
+
SQLAlchemySelect: TypeAlias = Select[Any]
|
|
17
|
+
SQLAlchemyExpression: TypeAlias = ColumnElement[Any]
|
|
18
|
+
SQLAlchemyAttribute: TypeAlias = InstrumentedAttribute[Any]
|
|
19
|
+
SQLAlchemyMapper: TypeAlias = Mapper[Any]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SQLAlchemyAttributeKind(Enum):
|
|
23
|
+
"""Kinds of ORM attributes relevant to path resolution."""
|
|
24
|
+
|
|
25
|
+
COLUMN = auto()
|
|
26
|
+
RELATIONSHIP = auto()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class SQLAlchemyJoinPlan:
|
|
31
|
+
"""Represents one relationship join to be applied to a statement."""
|
|
32
|
+
|
|
33
|
+
key: str
|
|
34
|
+
attribute: SQLAlchemyAttribute
|
|
35
|
+
default_hint: JoinHint
|
|
36
|
+
is_collection: bool
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class SQLAlchemyResolvedPath:
|
|
41
|
+
"""Represents a resolved ORM path."""
|
|
42
|
+
|
|
43
|
+
root_model: SQLAlchemyModel
|
|
44
|
+
leaf_model: SQLAlchemyModel
|
|
45
|
+
field_path: str
|
|
46
|
+
joins: tuple[SQLAlchemyJoinPlan, ...]
|
|
47
|
+
leaf_attribute: SQLAlchemyExpression
|
|
48
|
+
python_type: type[Any] | None
|
|
49
|
+
json_path: JSONPath = JSONPath()
|
|
50
|
+
is_json: bool = False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class SQLAlchemyMappedAttribute:
|
|
55
|
+
"""Represents a single mapped attribute discovered by introspection."""
|
|
56
|
+
|
|
57
|
+
name: str
|
|
58
|
+
kind: SQLAlchemyAttributeKind
|
|
59
|
+
owner_model: SQLAlchemyModel
|
|
60
|
+
attribute: SQLAlchemyAttribute
|
|
61
|
+
mapper: SQLAlchemyMapper | None
|
|
62
|
+
python_type: type[Any] | None
|
|
63
|
+
is_collection: bool = False
|
|
64
|
+
is_json: bool = False
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Parsing primitives for pyrsql."""
|
|
2
|
+
|
|
3
|
+
from pyrsql.parsing.ast import (
|
|
4
|
+
Argument,
|
|
5
|
+
ComparisonNode,
|
|
6
|
+
LogicalNode,
|
|
7
|
+
LogicalOperator,
|
|
8
|
+
Node,
|
|
9
|
+
)
|
|
10
|
+
from pyrsql.parsing.errors import LexError, ParseError
|
|
11
|
+
from pyrsql.parsing.lexer import Lexer
|
|
12
|
+
from pyrsql.parsing.limits import ParseLimits
|
|
13
|
+
from pyrsql.parsing.parser import Parser
|
|
14
|
+
from pyrsql.parsing.source import SourceSpan, SourceText
|
|
15
|
+
from pyrsql.parsing.tokens import Token, TokenKind
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Argument",
|
|
19
|
+
"ComparisonNode",
|
|
20
|
+
"LexError",
|
|
21
|
+
"Lexer",
|
|
22
|
+
"LogicalNode",
|
|
23
|
+
"LogicalOperator",
|
|
24
|
+
"Node",
|
|
25
|
+
"ParseError",
|
|
26
|
+
"ParseLimits",
|
|
27
|
+
"Parser",
|
|
28
|
+
"SourceSpan",
|
|
29
|
+
"SourceText",
|
|
30
|
+
"Token",
|
|
31
|
+
"TokenKind",
|
|
32
|
+
]
|