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.
Files changed (74) hide show
  1. pyrsql/__init__.py +63 -0
  2. pyrsql/adapters/__init__.py +3 -0
  3. pyrsql/adapters/fastapi/__init__.py +15 -0
  4. pyrsql/adapters/fastapi/config.py +76 -0
  5. pyrsql/adapters/fastapi/criteria.py +56 -0
  6. pyrsql/adapters/fastapi/dependency.py +217 -0
  7. pyrsql/adapters/fastapi/errors.py +107 -0
  8. pyrsql/api.py +76 -0
  9. pyrsql/core/__init__.py +45 -0
  10. pyrsql/core/compiler.py +81 -0
  11. pyrsql/core/conversion.py +206 -0
  12. pyrsql/core/custom.py +14 -0
  13. pyrsql/core/field_policy.py +97 -0
  14. pyrsql/core/joins.py +11 -0
  15. pyrsql/core/json/__init__.py +19 -0
  16. pyrsql/core/json/options.py +31 -0
  17. pyrsql/core/json/path.py +35 -0
  18. pyrsql/core/json/query.py +39 -0
  19. pyrsql/core/json/values.py +72 -0
  20. pyrsql/core/options.py +416 -0
  21. pyrsql/core/page.py +115 -0
  22. pyrsql/core/procedure_policy.py +61 -0
  23. pyrsql/core/query.py +160 -0
  24. pyrsql/core/sort.py +153 -0
  25. pyrsql/integrations/__init__.py +8 -0
  26. pyrsql/integrations/fastapi/__init__.py +8 -0
  27. pyrsql/integrations/fastapi/sqlalchemy.py +251 -0
  28. pyrsql/orms/__init__.py +15 -0
  29. pyrsql/orms/base.py +99 -0
  30. pyrsql/orms/sqlalchemy/__init__.py +39 -0
  31. pyrsql/orms/sqlalchemy/coercion.py +57 -0
  32. pyrsql/orms/sqlalchemy/compiled.py +56 -0
  33. pyrsql/orms/sqlalchemy/compiled_page.py +40 -0
  34. pyrsql/orms/sqlalchemy/compiled_sort.py +56 -0
  35. pyrsql/orms/sqlalchemy/custom.py +25 -0
  36. pyrsql/orms/sqlalchemy/errors.py +17 -0
  37. pyrsql/orms/sqlalchemy/introspection.py +107 -0
  38. pyrsql/orms/sqlalchemy/json_path.py +351 -0
  39. pyrsql/orms/sqlalchemy/orm.py +65 -0
  40. pyrsql/orms/sqlalchemy/resolver.py +202 -0
  41. pyrsql/orms/sqlalchemy/sorter.py +173 -0
  42. pyrsql/orms/sqlalchemy/statement.py +40 -0
  43. pyrsql/orms/sqlalchemy/translator.py +560 -0
  44. pyrsql/orms/sqlalchemy/type_inference.py +27 -0
  45. pyrsql/orms/sqlalchemy/types.py +64 -0
  46. pyrsql/parsing/__init__.py +32 -0
  47. pyrsql/parsing/ast.py +61 -0
  48. pyrsql/parsing/errors.py +25 -0
  49. pyrsql/parsing/lexer.py +264 -0
  50. pyrsql/parsing/limits.py +15 -0
  51. pyrsql/parsing/operators.py +201 -0
  52. pyrsql/parsing/parser.py +292 -0
  53. pyrsql/parsing/source.py +48 -0
  54. pyrsql/parsing/tokens.py +30 -0
  55. pyrsql/py.typed +0 -0
  56. pyrsql/selector/__init__.py +30 -0
  57. pyrsql/selector/analyzer.py +58 -0
  58. pyrsql/selector/ast.py +31 -0
  59. pyrsql/selector/parser.py +130 -0
  60. pyrsql/selector/semantic.py +34 -0
  61. pyrsql/semantic/__init__.py +23 -0
  62. pyrsql/semantic/analyzer.py +116 -0
  63. pyrsql/semantic/ast.py +35 -0
  64. pyrsql/semantic/errors.py +40 -0
  65. pyrsql/sorting/__init__.py +26 -0
  66. pyrsql/sorting/analyzer.py +74 -0
  67. pyrsql/sorting/ast.py +46 -0
  68. pyrsql/sorting/errors.py +21 -0
  69. pyrsql/sorting/limits.py +15 -0
  70. pyrsql/sorting/parser.py +150 -0
  71. pyrsql/sorting/semantic.py +15 -0
  72. pyrsql-0.0.1.dist-info/METADATA +72 -0
  73. pyrsql-0.0.1.dist-info/RECORD +74 -0
  74. 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
+ ]