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,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