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
pyrsql/parsing/parser.py
ADDED
|
@@ -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
|
pyrsql/parsing/source.py
ADDED
|
@@ -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]
|
pyrsql/parsing/tokens.py
ADDED
|
@@ -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
|
+
]
|