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,292 @@
1
+ """Recursive-descent parser for pyrsql token streams."""
2
+
3
+ from pyrsql.parsing.ast import (
4
+ Argument,
5
+ ComparisonNode,
6
+ Expression,
7
+ LogicalNode,
8
+ LogicalOperator,
9
+ )
10
+ from pyrsql.parsing.errors import ParseError
11
+ from pyrsql.parsing.lexer import Lexer
12
+ from pyrsql.parsing.limits import ParseLimits
13
+ from pyrsql.parsing.operators import (
14
+ DEFAULT_OPERATOR_REGISTRY,
15
+ ComparisonOperator,
16
+ OperatorRegistry,
17
+ )
18
+ from pyrsql.parsing.source import SourceSpan
19
+ from pyrsql.parsing.tokens import Token, TokenKind
20
+ from pyrsql.selector.ast import Selector
21
+ from pyrsql.selector.parser import DEFAULT_SELECTOR_PARSER, SelectorParseError
22
+
23
+
24
+ class Parser:
25
+ """Parses pyrsql query strings into AST nodes.
26
+
27
+ The parser consumes a token stream and builds a syntax tree that later
28
+ semantic analysis and ORM compilation stages can consume.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ source: str,
34
+ *,
35
+ limits: ParseLimits | None = None,
36
+ operator_registry: OperatorRegistry = DEFAULT_OPERATOR_REGISTRY,
37
+ ) -> None:
38
+ self._limits = limits or ParseLimits()
39
+ self._operator_registry = operator_registry
40
+ self._tokens = Lexer(
41
+ source,
42
+ limits=self._limits,
43
+ operator_spellings=operator_registry.operator_spellings,
44
+ ).tokenize()
45
+ self._selector_parser = DEFAULT_SELECTOR_PARSER
46
+ self._index = 0
47
+ self._node_count = 0
48
+
49
+ def parse(self) -> Expression:
50
+ """Parses the configured query into an AST.
51
+
52
+ Returns:
53
+ The parsed expression tree.
54
+ """
55
+ node = self._parse_or_expression(depth=1)
56
+ self._expect(TokenKind.EOF, message="Unexpected trailing tokens")
57
+ return node
58
+
59
+ def _parse_or_expression(self, depth: int) -> Expression:
60
+ """Parses OR-precedence logical expressions."""
61
+ self._enforce_depth(depth)
62
+ nodes = [self._parse_and_expression(depth + 1)]
63
+ while self._match(TokenKind.OR, TokenKind.COMMA):
64
+ nodes.append(self._parse_and_expression(depth + 1))
65
+ if len(nodes) == 1:
66
+ return nodes[0]
67
+ return self._make_logical_node(LogicalOperator.OR, nodes)
68
+
69
+ def _parse_and_expression(self, depth: int) -> Expression:
70
+ """Parses AND-precedence logical expressions."""
71
+ self._enforce_depth(depth)
72
+ nodes = [self._parse_primary_expression(depth + 1)]
73
+ while self._match(TokenKind.AND, TokenKind.SEMICOLON):
74
+ nodes.append(self._parse_primary_expression(depth + 1))
75
+ if len(nodes) == 1:
76
+ return nodes[0]
77
+ return self._make_logical_node(LogicalOperator.AND, nodes)
78
+
79
+ def _parse_primary_expression(self, depth: int) -> Expression:
80
+ """Parses grouped or comparison expressions."""
81
+ self._enforce_depth(depth)
82
+ if self._current().kind is TokenKind.LPAREN:
83
+ opening = self._expect(
84
+ TokenKind.LPAREN,
85
+ message="Expected '(' to start grouped expression",
86
+ )
87
+ expression = self._parse_or_expression(depth + 1)
88
+ closing = self._expect(
89
+ TokenKind.RPAREN,
90
+ message="Expected ')' to close grouped expression",
91
+ )
92
+ return expression.with_span(
93
+ SourceSpan.cover(opening.span, closing.span),
94
+ )
95
+ return self._parse_comparison()
96
+
97
+ def _parse_comparison(self) -> ComparisonNode:
98
+ """Parses a comparison expression."""
99
+ selector = self._expect(
100
+ TokenKind.UNQUOTED_TEXT,
101
+ message="Expected a selector before the comparison operator",
102
+ )
103
+ try:
104
+ parsed_selector = self._selector_parser.parse(
105
+ selector.lexeme,
106
+ max_length=self._limits.max_selector_length,
107
+ context="Comparison selector",
108
+ )
109
+ except SelectorParseError as error:
110
+ raise ParseError(message=str(error), span=selector.span) from error
111
+ operator_token = self._expect(
112
+ TokenKind.COMPARISON_OPERATOR,
113
+ message="Expected a comparison operator after the selector",
114
+ )
115
+ operator = self._operator_registry.get(operator_token.lexeme)
116
+ arguments = self._parse_arguments()
117
+ self._validate_argument_count(operator_token, operator, arguments)
118
+ return self._make_comparison_node(
119
+ selector,
120
+ parsed_selector,
121
+ operator_token,
122
+ operator,
123
+ arguments,
124
+ )
125
+
126
+ def _parse_arguments(self) -> tuple[Argument, ...]:
127
+ """Parses comparison arguments."""
128
+ if self._current().kind is TokenKind.LPAREN:
129
+ return self._parse_argument_list()
130
+ if self._current().kind in (
131
+ TokenKind.EOF,
132
+ TokenKind.RPAREN,
133
+ TokenKind.AND,
134
+ TokenKind.OR,
135
+ TokenKind.SEMICOLON,
136
+ TokenKind.COMMA,
137
+ ):
138
+ return ()
139
+ argument_token = self._expect_value("Expected a comparison argument")
140
+ return (self._make_argument(argument_token),)
141
+
142
+ def _parse_argument_list(self) -> tuple[Argument, ...]:
143
+ """Parses a parenthesized argument list."""
144
+ self._expect(
145
+ TokenKind.LPAREN,
146
+ message="Expected '(' to start argument list",
147
+ )
148
+ arguments: list[Argument] = []
149
+ if self._current().kind is TokenKind.RPAREN:
150
+ self._advance()
151
+ return ()
152
+
153
+ while True:
154
+ if len(arguments) >= self._limits.max_arguments_per_list:
155
+ raise ParseError(
156
+ message=(
157
+ "Argument list exceeds the maximum supported size of "
158
+ f"{self._limits.max_arguments_per_list}"
159
+ ),
160
+ span=self._current().span,
161
+ )
162
+ argument_token = self._expect_value(
163
+ "Expected an argument inside list"
164
+ )
165
+ arguments.append(self._make_argument(argument_token))
166
+ if self._match(TokenKind.COMMA):
167
+ continue
168
+ self._expect(
169
+ TokenKind.RPAREN,
170
+ message="Expected ')' to close argument list",
171
+ )
172
+ return tuple(arguments)
173
+
174
+ def _validate_argument_count(
175
+ self,
176
+ operator_token: Token,
177
+ operator: ComparisonOperator,
178
+ arguments: tuple[Argument, ...],
179
+ ) -> None:
180
+ """Validates operator arity."""
181
+ minimum_arguments = operator.minimum_arguments
182
+ maximum_arguments = operator.maximum_arguments
183
+ if len(arguments) < minimum_arguments:
184
+ raise ParseError(
185
+ message=(
186
+ f"Operator {operator_token.lexeme!r} expects at least "
187
+ f"{minimum_arguments} argument(s)"
188
+ ),
189
+ span=operator_token.span,
190
+ )
191
+ if maximum_arguments is not None and len(arguments) > maximum_arguments:
192
+ raise ParseError(
193
+ message=(
194
+ f"Operator {operator_token.lexeme!r} expects at most "
195
+ f"{maximum_arguments} argument(s)"
196
+ ),
197
+ span=operator_token.span,
198
+ )
199
+
200
+ def _make_logical_node(
201
+ self,
202
+ operator: LogicalOperator,
203
+ children: list[Expression],
204
+ ) -> LogicalNode:
205
+ """Builds a logical node while tracking parser limits."""
206
+ self._register_node()
207
+ return LogicalNode(
208
+ span=SourceSpan.cover(children[0].span, children[-1].span),
209
+ operator=operator,
210
+ children=tuple(children),
211
+ )
212
+
213
+ def _make_comparison_node(
214
+ self,
215
+ selector_token: Token,
216
+ selector: Selector,
217
+ operator_token: Token,
218
+ operator: ComparisonOperator,
219
+ arguments: tuple[Argument, ...],
220
+ ) -> ComparisonNode:
221
+ """Builds a comparison node while tracking parser limits."""
222
+ self._register_node()
223
+ end_span = arguments[-1].span if arguments else operator_token.span
224
+ return ComparisonNode(
225
+ span=SourceSpan.cover(selector_token.span, end_span),
226
+ selector=selector,
227
+ operator=operator,
228
+ arguments=arguments,
229
+ )
230
+
231
+ def _make_argument(self, token: Token) -> Argument:
232
+ """Builds an argument node from a value token."""
233
+ return Argument(
234
+ text=token.lexeme,
235
+ quoted=token.kind is TokenKind.QUOTED_TEXT,
236
+ span=token.span,
237
+ )
238
+
239
+ def _expect_value(self, message: str) -> Token:
240
+ """Consumes the next value token."""
241
+ current = self._current()
242
+ if current.kind not in (TokenKind.UNQUOTED_TEXT, TokenKind.QUOTED_TEXT):
243
+ raise ParseError(message=message, span=current.span)
244
+ self._advance()
245
+ return current
246
+
247
+ def _expect(self, kind: TokenKind, *, message: str) -> Token:
248
+ """Consumes the next token if it matches the expected kind."""
249
+ current = self._current()
250
+ if current.kind is not kind:
251
+ raise ParseError(message=message, span=current.span)
252
+ self._advance()
253
+ return current
254
+
255
+ def _match(self, *kinds: TokenKind) -> bool:
256
+ """Consumes the current token if it matches one of the kinds."""
257
+ if self._current().kind in kinds:
258
+ self._advance()
259
+ return True
260
+ return False
261
+
262
+ def _register_node(self) -> None:
263
+ """Tracks AST node creation against parser limits."""
264
+ self._node_count += 1
265
+ if self._node_count > self._limits.max_node_count:
266
+ raise ParseError(
267
+ message=(
268
+ "Query exceeds the maximum supported node count of "
269
+ f"{self._limits.max_node_count}"
270
+ ),
271
+ span=self._current().span,
272
+ )
273
+
274
+ def _enforce_depth(self, depth: int) -> None:
275
+ """Enforces the maximum expression depth."""
276
+ if depth > self._limits.max_expression_depth:
277
+ raise ParseError(
278
+ message=(
279
+ "Query exceeds the maximum supported expression depth of "
280
+ f"{self._limits.max_expression_depth}"
281
+ ),
282
+ span=self._current().span,
283
+ )
284
+
285
+ def _current(self) -> Token:
286
+ """The current token."""
287
+ return self._tokens[self._index]
288
+
289
+ def _advance(self) -> None:
290
+ """Advances to the next token when possible."""
291
+ if self._index < len(self._tokens) - 1:
292
+ self._index += 1
@@ -0,0 +1,48 @@
1
+ """Source location models for parsing."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class SourcePosition:
8
+ """Represents a single position in the input source."""
9
+
10
+ index: int
11
+ line: int
12
+ column: int
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class SourceSpan:
17
+ """Represents a half-open span in the input source."""
18
+
19
+ start: SourcePosition
20
+ end: SourcePosition
21
+
22
+ @classmethod
23
+ def cover(cls, start: "SourceSpan", end: "SourceSpan") -> "SourceSpan":
24
+ """Builds a span that covers two existing spans."""
25
+ return cls(start=start.start, end=end.end)
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class SourceText:
30
+ """Wraps immutable source text and exposes helper methods."""
31
+
32
+ text: str
33
+ _length: int = field(init=False, repr=False, compare=False)
34
+
35
+ def __post_init__(self) -> None:
36
+ """Validates the source text."""
37
+ if not isinstance(self.text, str):
38
+ raise TypeError("Source text must be a string.")
39
+ object.__setattr__(self, "_length", len(self.text))
40
+
41
+ @property
42
+ def length(self) -> int:
43
+ """Returns the source length."""
44
+ return self._length
45
+
46
+ def slice(self, start: int, end: int) -> str:
47
+ """Returns a substring by raw indices."""
48
+ return self.text[start:end]
@@ -0,0 +1,30 @@
1
+ """Token models for the lexer."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum, auto
5
+
6
+ from pyrsql.parsing.source import SourceSpan
7
+
8
+
9
+ class TokenKind(Enum):
10
+ """Token kinds emitted by the lexer."""
11
+
12
+ EOF = auto()
13
+ LPAREN = auto()
14
+ RPAREN = auto()
15
+ COMMA = auto()
16
+ SEMICOLON = auto()
17
+ AND = auto()
18
+ OR = auto()
19
+ COMPARISON_OPERATOR = auto()
20
+ UNQUOTED_TEXT = auto()
21
+ QUOTED_TEXT = auto()
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class Token:
26
+ """Represents a lexical token."""
27
+
28
+ kind: TokenKind
29
+ lexeme: str
30
+ span: SourceSpan
pyrsql/py.typed ADDED
File without changes
@@ -0,0 +1,30 @@
1
+ """Shared selector primitives for pyrsql."""
2
+
3
+ from pyrsql.selector.ast import (
4
+ ColumnSelector,
5
+ FunctionSelector,
6
+ LiteralSelector,
7
+ Selector,
8
+ SelectorLiteral,
9
+ )
10
+ from pyrsql.selector.parser import SelectorParseError, SelectorParser
11
+ from pyrsql.selector.semantic import (
12
+ SemanticColumnSelector,
13
+ SemanticFunctionSelector,
14
+ SemanticLiteralSelector,
15
+ SemanticSelector,
16
+ )
17
+
18
+ __all__ = [
19
+ "ColumnSelector",
20
+ "FunctionSelector",
21
+ "LiteralSelector",
22
+ "Selector",
23
+ "SelectorLiteral",
24
+ "SelectorParseError",
25
+ "SelectorParser",
26
+ "SemanticColumnSelector",
27
+ "SemanticFunctionSelector",
28
+ "SemanticLiteralSelector",
29
+ "SemanticSelector",
30
+ ]
@@ -0,0 +1,58 @@
1
+ """Shared semantic normalization for pyrsql selectors."""
2
+
3
+ from collections.abc import Callable, Mapping
4
+
5
+ from pyrsql.selector.ast import (
6
+ ColumnSelector,
7
+ FunctionSelector,
8
+ LiteralSelector,
9
+ Selector,
10
+ )
11
+ from pyrsql.selector.semantic import (
12
+ SemanticColumnSelector,
13
+ SemanticFunctionSelector,
14
+ SemanticLiteralSelector,
15
+ SemanticSelector,
16
+ )
17
+
18
+
19
+ class SelectorSemanticAnalyzer:
20
+ """Normalizes parsed selectors into semantic selector nodes."""
21
+
22
+ def __init__(self, field_mapping: Mapping[str, str]) -> None:
23
+ self._field_mapping = field_mapping
24
+
25
+ def analyze(
26
+ self,
27
+ selector: Selector,
28
+ *,
29
+ validate_field: Callable[[str], None],
30
+ validate_function: Callable[[str], None],
31
+ ) -> SemanticSelector:
32
+ """Analyzes one parsed selector recursively."""
33
+ if isinstance(selector, ColumnSelector):
34
+ field_path = self._field_mapping.get(
35
+ selector.selector,
36
+ selector.selector,
37
+ )
38
+ validate_field(field_path)
39
+ return SemanticColumnSelector(
40
+ selector=selector.selector,
41
+ field_path=field_path,
42
+ )
43
+ if isinstance(selector, LiteralSelector):
44
+ return SemanticLiteralSelector(value=selector.value)
45
+ if not isinstance(selector, FunctionSelector):
46
+ raise TypeError("Expected FunctionSelector")
47
+ validate_function(selector.function_name)
48
+ return SemanticFunctionSelector(
49
+ function_name=selector.function_name,
50
+ arguments=tuple(
51
+ self.analyze(
52
+ argument,
53
+ validate_field=validate_field,
54
+ validate_function=validate_function,
55
+ )
56
+ for argument in selector.arguments
57
+ ),
58
+ )
pyrsql/selector/ast.py ADDED
@@ -0,0 +1,31 @@
1
+ """AST nodes for shared pyrsql selectors."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TypeAlias
5
+
6
+ SelectorLiteral: TypeAlias = str | int | float | bool | None
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class ColumnSelector:
11
+ """Column-path selector."""
12
+
13
+ selector: str
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class LiteralSelector:
18
+ """Static literal selector."""
19
+
20
+ value: SelectorLiteral
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class FunctionSelector:
25
+ """Function selector."""
26
+
27
+ function_name: str
28
+ arguments: tuple["Selector", ...]
29
+
30
+
31
+ Selector: TypeAlias = ColumnSelector | LiteralSelector | FunctionSelector
@@ -0,0 +1,130 @@
1
+ """Shared recursive parser for pyrsql selectors."""
2
+
3
+ import re
4
+
5
+ from pyrsql.selector import ast
6
+
7
+
8
+ class SelectorParseError(ValueError):
9
+ """Raised when a selector expression is malformed."""
10
+
11
+
12
+ class SelectorParser:
13
+ """Parses column, literal, and function selectors."""
14
+
15
+ _INTEGER_PATTERN = re.compile(r"-?\d+")
16
+ _FLOAT_PATTERN = re.compile(r"-?\d+(?:\.\d+)?")
17
+
18
+ def parse(
19
+ self,
20
+ raw_selector: str,
21
+ *,
22
+ max_length: int,
23
+ context: str,
24
+ ) -> ast.Selector:
25
+ """Parses a selector recursively."""
26
+ if len(raw_selector) > max_length:
27
+ raise SelectorParseError(
28
+ f"{context} exceeds the maximum supported length of "
29
+ f"{max_length}."
30
+ )
31
+ if raw_selector.startswith("@"):
32
+ return self._parse_function_selector(
33
+ raw_selector,
34
+ max_length=max_length,
35
+ context=context,
36
+ )
37
+ if raw_selector.startswith("#"):
38
+ return ast.LiteralSelector(
39
+ value=self._parse_literal_value(raw_selector[1:])
40
+ )
41
+ return ast.ColumnSelector(selector=raw_selector)
42
+
43
+ def split_top_level(self, text: str, *, delimiter: str) -> tuple[str, ...]:
44
+ """Splits text on a delimiter while respecting nested brackets."""
45
+ parts: list[str] = []
46
+ start_index = 0
47
+ depth = 0
48
+ for index, character in enumerate(text):
49
+ if character == "[":
50
+ depth += 1
51
+ elif character == "]":
52
+ depth -= 1
53
+ if depth < 0:
54
+ raise SelectorParseError(
55
+ f"Selector fragment {text!r} has unbalanced brackets."
56
+ )
57
+ elif character == delimiter and depth == 0:
58
+ part = text[start_index:index].strip()
59
+ if part:
60
+ parts.append(part)
61
+ start_index = index + 1
62
+ if depth != 0:
63
+ raise SelectorParseError(
64
+ f"Selector fragment {text!r} has unbalanced brackets."
65
+ )
66
+ final_part = text[start_index:].strip()
67
+ if final_part:
68
+ parts.append(final_part)
69
+ return tuple(parts)
70
+
71
+ def _parse_function_selector(
72
+ self,
73
+ raw_selector: str,
74
+ *,
75
+ max_length: int,
76
+ context: str,
77
+ ) -> ast.FunctionSelector:
78
+ """Parses a function selector recursively."""
79
+ argument_start = raw_selector.find("[")
80
+ argument_end = raw_selector.rfind("]")
81
+ if (
82
+ argument_start <= 1
83
+ or argument_end <= argument_start
84
+ or argument_end != len(raw_selector) - 1
85
+ ):
86
+ raise SelectorParseError(
87
+ f"{context} has invalid function selector {raw_selector!r}."
88
+ )
89
+ function_name = raw_selector[1:argument_start].strip()
90
+ if not function_name:
91
+ raise SelectorParseError(f"{context} has empty function name.")
92
+ arguments_text = raw_selector[argument_start + 1 : argument_end]
93
+ argument_fragments = self.split_top_level(arguments_text, delimiter="|")
94
+ if not argument_fragments:
95
+ raise SelectorParseError(
96
+ f"{context} function {function_name!r} must have at least one "
97
+ "argument."
98
+ )
99
+ return ast.FunctionSelector(
100
+ function_name=function_name,
101
+ arguments=tuple(
102
+ self.parse(
103
+ fragment,
104
+ max_length=max_length,
105
+ context=context,
106
+ )
107
+ for fragment in argument_fragments
108
+ ),
109
+ )
110
+
111
+ def _parse_literal_value(self, raw_literal: str) -> ast.SelectorLiteral:
112
+ """Parses a static literal selector value."""
113
+ normalized_literal = raw_literal.replace("\t", " ")
114
+ match normalized_literal.lower():
115
+ case "null":
116
+ return None
117
+ case "true":
118
+ return True
119
+ case "false":
120
+ return False
121
+ case _:
122
+ pass
123
+ if self._INTEGER_PATTERN.fullmatch(normalized_literal):
124
+ return int(normalized_literal)
125
+ if self._FLOAT_PATTERN.fullmatch(normalized_literal):
126
+ return float(normalized_literal)
127
+ return normalized_literal
128
+
129
+
130
+ DEFAULT_SELECTOR_PARSER = SelectorParser()
@@ -0,0 +1,34 @@
1
+ """Semantic nodes for shared pyrsql selectors."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TypeAlias
5
+
6
+ from pyrsql.selector.ast import SelectorLiteral
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class SemanticColumnSelector:
11
+ """Column-path selector after semantic normalization."""
12
+
13
+ selector: str
14
+ field_path: str
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class SemanticLiteralSelector:
19
+ """Static literal selector after semantic normalization."""
20
+
21
+ value: SelectorLiteral
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class SemanticFunctionSelector:
26
+ """Function selector after semantic normalization."""
27
+
28
+ function_name: str
29
+ arguments: tuple["SemanticSelector", ...]
30
+
31
+
32
+ SemanticSelector: TypeAlias = (
33
+ SemanticColumnSelector | SemanticLiteralSelector | SemanticFunctionSelector
34
+ )
@@ -0,0 +1,23 @@
1
+ """ORM-neutral semantic analysis primitives."""
2
+
3
+ from pyrsql.semantic.analyzer import SemanticAnalyzer
4
+ from pyrsql.semantic.ast import (
5
+ SemanticComparison,
6
+ SemanticExpression,
7
+ SemanticLogical,
8
+ )
9
+ from pyrsql.semantic.errors import (
10
+ FieldBlacklistedError,
11
+ FieldNotWhitelistedError,
12
+ SemanticError,
13
+ )
14
+
15
+ __all__ = [
16
+ "FieldBlacklistedError",
17
+ "FieldNotWhitelistedError",
18
+ "SemanticAnalyzer",
19
+ "SemanticComparison",
20
+ "SemanticError",
21
+ "SemanticExpression",
22
+ "SemanticLogical",
23
+ ]