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/ast.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""AST node definitions for pyrsql parsing."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from dataclasses import dataclass, replace
|
|
5
|
+
from enum import Enum, auto
|
|
6
|
+
|
|
7
|
+
from pyrsql.parsing.operators import ComparisonOperator
|
|
8
|
+
from pyrsql.parsing.source import SourceSpan
|
|
9
|
+
from pyrsql.selector.ast import Selector
|
|
10
|
+
|
|
11
|
+
if sys.version_info >= (3, 11):
|
|
12
|
+
from typing import Self
|
|
13
|
+
else:
|
|
14
|
+
from typing_extensions import Self
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class Argument:
|
|
19
|
+
"""Represents a comparison argument."""
|
|
20
|
+
|
|
21
|
+
text: str
|
|
22
|
+
quoted: bool
|
|
23
|
+
span: SourceSpan
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class Node:
|
|
28
|
+
"""Base AST node."""
|
|
29
|
+
|
|
30
|
+
span: SourceSpan
|
|
31
|
+
|
|
32
|
+
def with_span(self, span: SourceSpan) -> Self:
|
|
33
|
+
"""Returns a copy of the node with an updated source span."""
|
|
34
|
+
return replace(self, span=span)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class ComparisonNode(Node):
|
|
39
|
+
"""Leaf node for selector/operator/arguments expressions."""
|
|
40
|
+
|
|
41
|
+
selector: Selector
|
|
42
|
+
operator: ComparisonOperator
|
|
43
|
+
arguments: tuple[Argument, ...]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LogicalOperator(Enum):
|
|
47
|
+
"""Logical operators supported in the AST."""
|
|
48
|
+
|
|
49
|
+
AND = auto()
|
|
50
|
+
OR = auto()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class LogicalNode(Node):
|
|
55
|
+
"""Logical expression with two or more child nodes."""
|
|
56
|
+
|
|
57
|
+
operator: LogicalOperator
|
|
58
|
+
children: tuple["Expression", ...]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
Expression = ComparisonNode | LogicalNode
|
pyrsql/parsing/errors.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Parsing error types."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from pyrsql.parsing.source import SourceSpan
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class ParseError(ValueError):
|
|
10
|
+
"""Base exception for parsing failures."""
|
|
11
|
+
|
|
12
|
+
message: str
|
|
13
|
+
span: SourceSpan
|
|
14
|
+
|
|
15
|
+
def __str__(self) -> str:
|
|
16
|
+
"""Formats the parse error with source location data."""
|
|
17
|
+
return (
|
|
18
|
+
f"{self.message} at index {self.span.start.index} "
|
|
19
|
+
f"(line {self.span.start.line}, column {self.span.start.column})"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class LexError(ParseError):
|
|
25
|
+
"""Raised when lexing fails."""
|
pyrsql/parsing/lexer.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Single-pass lexer for pyrsql query strings."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
|
|
5
|
+
from pyrsql.parsing.errors import LexError
|
|
6
|
+
from pyrsql.parsing.limits import ParseLimits
|
|
7
|
+
from pyrsql.parsing.operators import OPERATOR_SPELLINGS
|
|
8
|
+
from pyrsql.parsing.source import SourcePosition, SourceSpan, SourceText
|
|
9
|
+
from pyrsql.parsing.tokens import Token, TokenKind
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Lexer:
|
|
13
|
+
"""Converts raw query text into a token stream."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
source: str | SourceText,
|
|
18
|
+
*,
|
|
19
|
+
limits: ParseLimits | None = None,
|
|
20
|
+
operator_spellings: tuple[str, ...] = OPERATOR_SPELLINGS,
|
|
21
|
+
) -> None:
|
|
22
|
+
self._source = (
|
|
23
|
+
source if isinstance(source, SourceText) else SourceText(source)
|
|
24
|
+
)
|
|
25
|
+
self._limits = limits or ParseLimits()
|
|
26
|
+
self._operator_spellings = operator_spellings
|
|
27
|
+
self._operator_spellings_by_prefix = self._index_operator_spellings(
|
|
28
|
+
operator_spellings
|
|
29
|
+
)
|
|
30
|
+
self._index = 0
|
|
31
|
+
self._line = 1
|
|
32
|
+
self._column = 1
|
|
33
|
+
self._validate_source_length()
|
|
34
|
+
|
|
35
|
+
def tokenize(self) -> tuple[Token, ...]:
|
|
36
|
+
"""Tokenizes the configured source."""
|
|
37
|
+
tokens: list[Token] = []
|
|
38
|
+
while not self._is_at_end():
|
|
39
|
+
self._skip_whitespace()
|
|
40
|
+
if self._is_at_end():
|
|
41
|
+
break
|
|
42
|
+
tokens.append(self._next_token())
|
|
43
|
+
tokens.append(
|
|
44
|
+
self._make_token(
|
|
45
|
+
TokenKind.EOF,
|
|
46
|
+
"",
|
|
47
|
+
self._current_position(),
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
return tuple(tokens)
|
|
51
|
+
|
|
52
|
+
def _next_token(self) -> Token:
|
|
53
|
+
"""Returns the next token from the stream."""
|
|
54
|
+
if operator := self._match_operator():
|
|
55
|
+
return operator
|
|
56
|
+
|
|
57
|
+
current_char = self._peek()
|
|
58
|
+
match current_char:
|
|
59
|
+
case "(":
|
|
60
|
+
return self._single_char_token(TokenKind.LPAREN)
|
|
61
|
+
case ")":
|
|
62
|
+
return self._single_char_token(TokenKind.RPAREN)
|
|
63
|
+
case ",":
|
|
64
|
+
return self._single_char_token(TokenKind.COMMA)
|
|
65
|
+
case ";":
|
|
66
|
+
return self._single_char_token(TokenKind.SEMICOLON)
|
|
67
|
+
case "'":
|
|
68
|
+
return self._consume_quoted_text()
|
|
69
|
+
case _:
|
|
70
|
+
return self._consume_unquoted_text()
|
|
71
|
+
|
|
72
|
+
def _consume_quoted_text(self) -> Token:
|
|
73
|
+
"""Consumes a single-quoted text token."""
|
|
74
|
+
start = self._current_position()
|
|
75
|
+
self._advance()
|
|
76
|
+
characters: list[str] = []
|
|
77
|
+
while not self._is_at_end():
|
|
78
|
+
current_char = self._advance()
|
|
79
|
+
if current_char == "'":
|
|
80
|
+
lexeme = "".join(characters)
|
|
81
|
+
self._enforce_argument_length(lexeme, start)
|
|
82
|
+
return self._make_token(TokenKind.QUOTED_TEXT, lexeme, start)
|
|
83
|
+
if current_char == "\\" and not self._is_at_end():
|
|
84
|
+
escaped_char = self._advance()
|
|
85
|
+
characters.append(escaped_char)
|
|
86
|
+
continue
|
|
87
|
+
characters.append(current_char)
|
|
88
|
+
raise LexError(
|
|
89
|
+
message="Unterminated quoted string",
|
|
90
|
+
span=SourceSpan(start=start, end=self._current_position()),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def _consume_unquoted_text(self) -> Token:
|
|
94
|
+
"""Consumes an unquoted text token."""
|
|
95
|
+
start = self._current_position()
|
|
96
|
+
start_index = self._index
|
|
97
|
+
while not self._is_at_end():
|
|
98
|
+
current_char = self._peek()
|
|
99
|
+
if current_char.isspace():
|
|
100
|
+
break
|
|
101
|
+
if current_char in "(),;":
|
|
102
|
+
break
|
|
103
|
+
if self._starts_with_operator():
|
|
104
|
+
break
|
|
105
|
+
self._advance()
|
|
106
|
+
|
|
107
|
+
lexeme = self._source.slice(start_index, self._index)
|
|
108
|
+
if not lexeme:
|
|
109
|
+
raise LexError(
|
|
110
|
+
message=f"Unexpected character {self._peek()!r}",
|
|
111
|
+
span=SourceSpan(start=start, end=self._current_position()),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
match lexeme:
|
|
115
|
+
case "and":
|
|
116
|
+
return self._make_token(TokenKind.AND, lexeme, start)
|
|
117
|
+
case "or":
|
|
118
|
+
return self._make_token(TokenKind.OR, lexeme, start)
|
|
119
|
+
case _:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
self._enforce_selector_length(lexeme, start)
|
|
123
|
+
return self._make_token(TokenKind.UNQUOTED_TEXT, lexeme, start)
|
|
124
|
+
|
|
125
|
+
def _single_char_token(self, kind: TokenKind) -> Token:
|
|
126
|
+
"""Consumes a fixed-width token."""
|
|
127
|
+
start = self._current_position()
|
|
128
|
+
lexeme = self._advance()
|
|
129
|
+
return self._make_token(kind, lexeme, start)
|
|
130
|
+
|
|
131
|
+
def _match_operator(self) -> Token | None:
|
|
132
|
+
"""Consumes a comparison operator when present."""
|
|
133
|
+
start = self._current_position()
|
|
134
|
+
for spelling in self._candidate_operator_spellings():
|
|
135
|
+
if self._source.text.startswith(spelling, self._index):
|
|
136
|
+
self._advance_operator(spelling)
|
|
137
|
+
return self._make_token(
|
|
138
|
+
TokenKind.COMPARISON_OPERATOR,
|
|
139
|
+
spelling,
|
|
140
|
+
start,
|
|
141
|
+
)
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
def _starts_with_operator(self) -> bool:
|
|
145
|
+
"""Checks whether the current position begins an operator."""
|
|
146
|
+
for spelling in self._candidate_operator_spellings():
|
|
147
|
+
if self._source.text.startswith(spelling, self._index):
|
|
148
|
+
return True
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
def _candidate_operator_spellings(self) -> tuple[str, ...]:
|
|
152
|
+
"""Returns spellings matching the current prefix character."""
|
|
153
|
+
current_char = self._peek()
|
|
154
|
+
return self._operator_spellings_by_prefix.get(
|
|
155
|
+
current_char,
|
|
156
|
+
self._operator_spellings,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def _index_operator_spellings(
|
|
160
|
+
self,
|
|
161
|
+
operator_spellings: tuple[str, ...],
|
|
162
|
+
) -> Mapping[str, tuple[str, ...]]:
|
|
163
|
+
"""Indexes operator spellings by their first character."""
|
|
164
|
+
spellings_by_prefix: dict[str, list[str]] = {}
|
|
165
|
+
for spelling in operator_spellings:
|
|
166
|
+
spellings_by_prefix.setdefault(spelling[0], []).append(spelling)
|
|
167
|
+
return {
|
|
168
|
+
prefix: tuple(spellings)
|
|
169
|
+
for prefix, spellings in spellings_by_prefix.items()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
def _skip_whitespace(self) -> None:
|
|
173
|
+
"""Skips ASCII whitespace characters."""
|
|
174
|
+
while not self._is_at_end():
|
|
175
|
+
if not self._peek().isspace():
|
|
176
|
+
break
|
|
177
|
+
self._advance()
|
|
178
|
+
|
|
179
|
+
def _validate_source_length(self) -> None:
|
|
180
|
+
"""Enforces the global query size limit."""
|
|
181
|
+
if self._source.length > self._limits.max_query_length:
|
|
182
|
+
position = SourcePosition(index=0, line=1, column=1)
|
|
183
|
+
raise LexError(
|
|
184
|
+
message=(
|
|
185
|
+
"Query exceeds the maximum supported length of "
|
|
186
|
+
f"{self._limits.max_query_length} characters"
|
|
187
|
+
),
|
|
188
|
+
span=SourceSpan(start=position, end=position),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def _enforce_selector_length(
|
|
192
|
+
self,
|
|
193
|
+
lexeme: str,
|
|
194
|
+
position: SourcePosition,
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Enforces the unquoted text length limit."""
|
|
197
|
+
if len(lexeme) > self._limits.max_selector_length:
|
|
198
|
+
raise LexError(
|
|
199
|
+
message=(
|
|
200
|
+
"Unquoted token exceeds the maximum supported length of "
|
|
201
|
+
f"{self._limits.max_selector_length} characters"
|
|
202
|
+
),
|
|
203
|
+
span=SourceSpan(start=position, end=self._current_position()),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def _enforce_argument_length(
|
|
207
|
+
self,
|
|
208
|
+
lexeme: str,
|
|
209
|
+
position: SourcePosition,
|
|
210
|
+
) -> None:
|
|
211
|
+
"""Enforces the quoted argument length limit."""
|
|
212
|
+
if len(lexeme) > self._limits.max_argument_length:
|
|
213
|
+
raise LexError(
|
|
214
|
+
message=(
|
|
215
|
+
"Quoted token exceeds the maximum supported length of "
|
|
216
|
+
f"{self._limits.max_argument_length} characters"
|
|
217
|
+
),
|
|
218
|
+
span=SourceSpan(start=position, end=self._current_position()),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def _advance(self) -> str:
|
|
222
|
+
"""Consumes the current character and updates source position."""
|
|
223
|
+
character = self._source.text[self._index]
|
|
224
|
+
self._index += 1
|
|
225
|
+
if character == "\n":
|
|
226
|
+
self._line += 1
|
|
227
|
+
self._column = 1
|
|
228
|
+
else:
|
|
229
|
+
self._column += 1
|
|
230
|
+
return character
|
|
231
|
+
|
|
232
|
+
def _advance_operator(self, operator: str) -> None:
|
|
233
|
+
"""Consumes a comparison operator in one step."""
|
|
234
|
+
self._index += len(operator)
|
|
235
|
+
self._column += len(operator)
|
|
236
|
+
|
|
237
|
+
def _peek(self) -> str:
|
|
238
|
+
"""Returns the current character without consuming it."""
|
|
239
|
+
return self._source.text[self._index]
|
|
240
|
+
|
|
241
|
+
def _current_position(self) -> SourcePosition:
|
|
242
|
+
"""Returns the current source position."""
|
|
243
|
+
return SourcePosition(
|
|
244
|
+
index=self._index,
|
|
245
|
+
line=self._line,
|
|
246
|
+
column=self._column,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def _make_token(
|
|
250
|
+
self,
|
|
251
|
+
kind: TokenKind,
|
|
252
|
+
lexeme: str,
|
|
253
|
+
start: SourcePosition,
|
|
254
|
+
) -> Token:
|
|
255
|
+
"""Builds a token from a start position and current cursor."""
|
|
256
|
+
return Token(
|
|
257
|
+
kind=kind,
|
|
258
|
+
lexeme=lexeme,
|
|
259
|
+
span=SourceSpan(start=start, end=self._current_position()),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def _is_at_end(self) -> bool:
|
|
263
|
+
"""Checks whether the lexer reached the end of the source."""
|
|
264
|
+
return self._index >= self._source.length
|
pyrsql/parsing/limits.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Safety limits for lexing and parsing."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True, slots=True)
|
|
7
|
+
class ParseLimits:
|
|
8
|
+
"""Configurable safety limits for query processing."""
|
|
9
|
+
|
|
10
|
+
max_query_length: int = 4096
|
|
11
|
+
max_selector_length: int = 256
|
|
12
|
+
max_argument_length: int = 1024
|
|
13
|
+
max_arguments_per_list: int = 100
|
|
14
|
+
max_expression_depth: int = 16
|
|
15
|
+
max_node_count: int = 1024
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Supported comparison operators."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from types import MappingProxyType
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class ComparisonOperator:
|
|
10
|
+
"""Represents a supported comparison operator."""
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
spellings: tuple[str, ...]
|
|
14
|
+
minimum_arguments: int
|
|
15
|
+
maximum_arguments: int | None
|
|
16
|
+
|
|
17
|
+
def __post_init__(self) -> None:
|
|
18
|
+
"""Validates operator invariants."""
|
|
19
|
+
if not self.name:
|
|
20
|
+
raise ValueError("Operator name cannot be empty.")
|
|
21
|
+
if not self.spellings:
|
|
22
|
+
raise ValueError("Operator must define at least one spelling.")
|
|
23
|
+
if self.minimum_arguments < 0:
|
|
24
|
+
raise ValueError("minimum_arguments cannot be negative.")
|
|
25
|
+
if (
|
|
26
|
+
self.maximum_arguments is not None
|
|
27
|
+
and self.maximum_arguments < self.minimum_arguments
|
|
28
|
+
):
|
|
29
|
+
raise ValueError(
|
|
30
|
+
"maximum_arguments cannot be less than minimum_arguments."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class OperatorRegistry:
|
|
36
|
+
"""Immutable registry of supported comparison operators."""
|
|
37
|
+
|
|
38
|
+
operators: tuple[ComparisonOperator, ...] = field(default_factory=tuple)
|
|
39
|
+
operators_by_spelling: Mapping[str, ComparisonOperator] = field(init=False)
|
|
40
|
+
operator_spellings: tuple[str, ...] = field(init=False)
|
|
41
|
+
|
|
42
|
+
def __post_init__(self) -> None:
|
|
43
|
+
"""Builds lookup structures and validates uniqueness."""
|
|
44
|
+
operators_by_spelling: dict[str, ComparisonOperator] = {}
|
|
45
|
+
for operator in self.operators:
|
|
46
|
+
for spelling in operator.spellings:
|
|
47
|
+
if spelling in operators_by_spelling:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"Duplicate operator spelling registered: {spelling!r}."
|
|
50
|
+
)
|
|
51
|
+
operators_by_spelling[spelling] = operator
|
|
52
|
+
object.__setattr__(
|
|
53
|
+
self,
|
|
54
|
+
"operators_by_spelling",
|
|
55
|
+
MappingProxyType(operators_by_spelling),
|
|
56
|
+
)
|
|
57
|
+
object.__setattr__(
|
|
58
|
+
self,
|
|
59
|
+
"operator_spellings",
|
|
60
|
+
tuple(
|
|
61
|
+
sorted(
|
|
62
|
+
operators_by_spelling,
|
|
63
|
+
key=len,
|
|
64
|
+
reverse=True,
|
|
65
|
+
)
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def get(self, spelling: str) -> ComparisonOperator:
|
|
70
|
+
"""Returns the operator registered for the provided spelling."""
|
|
71
|
+
return self.operators_by_spelling[spelling]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
EQUAL = ComparisonOperator(
|
|
75
|
+
name="equal",
|
|
76
|
+
spellings=("==",),
|
|
77
|
+
minimum_arguments=1,
|
|
78
|
+
maximum_arguments=1,
|
|
79
|
+
)
|
|
80
|
+
NOT_EQUAL = ComparisonOperator(
|
|
81
|
+
name="not_equal",
|
|
82
|
+
spellings=("!=",),
|
|
83
|
+
minimum_arguments=1,
|
|
84
|
+
maximum_arguments=1,
|
|
85
|
+
)
|
|
86
|
+
GREATER_THAN = ComparisonOperator(
|
|
87
|
+
name="greater_than",
|
|
88
|
+
spellings=("=gt=", ">"),
|
|
89
|
+
minimum_arguments=1,
|
|
90
|
+
maximum_arguments=1,
|
|
91
|
+
)
|
|
92
|
+
GREATER_THAN_OR_EQUAL = ComparisonOperator(
|
|
93
|
+
name="greater_than_or_equal",
|
|
94
|
+
spellings=("=ge=", ">="),
|
|
95
|
+
minimum_arguments=1,
|
|
96
|
+
maximum_arguments=1,
|
|
97
|
+
)
|
|
98
|
+
LESS_THAN = ComparisonOperator(
|
|
99
|
+
name="less_than",
|
|
100
|
+
spellings=("=lt=", "<"),
|
|
101
|
+
minimum_arguments=1,
|
|
102
|
+
maximum_arguments=1,
|
|
103
|
+
)
|
|
104
|
+
LESS_THAN_OR_EQUAL = ComparisonOperator(
|
|
105
|
+
name="less_than_or_equal",
|
|
106
|
+
spellings=("=le=", "<="),
|
|
107
|
+
minimum_arguments=1,
|
|
108
|
+
maximum_arguments=1,
|
|
109
|
+
)
|
|
110
|
+
IN = ComparisonOperator(
|
|
111
|
+
name="in",
|
|
112
|
+
spellings=("=in=",),
|
|
113
|
+
minimum_arguments=1,
|
|
114
|
+
maximum_arguments=None,
|
|
115
|
+
)
|
|
116
|
+
NOT_IN = ComparisonOperator(
|
|
117
|
+
name="not_in",
|
|
118
|
+
spellings=("=out=",),
|
|
119
|
+
minimum_arguments=1,
|
|
120
|
+
maximum_arguments=None,
|
|
121
|
+
)
|
|
122
|
+
IS_NULL = ComparisonOperator(
|
|
123
|
+
name="is_null",
|
|
124
|
+
spellings=("=na=", "=isnull=", "=null="),
|
|
125
|
+
minimum_arguments=0,
|
|
126
|
+
maximum_arguments=1,
|
|
127
|
+
)
|
|
128
|
+
NOT_NULL = ComparisonOperator(
|
|
129
|
+
name="not_null",
|
|
130
|
+
spellings=("=nn=", "=notnull=", "=isnotnull="),
|
|
131
|
+
minimum_arguments=0,
|
|
132
|
+
maximum_arguments=1,
|
|
133
|
+
)
|
|
134
|
+
LIKE = ComparisonOperator(
|
|
135
|
+
name="like",
|
|
136
|
+
spellings=("=like=", "=ke="),
|
|
137
|
+
minimum_arguments=1,
|
|
138
|
+
maximum_arguments=1,
|
|
139
|
+
)
|
|
140
|
+
NOT_LIKE = ComparisonOperator(
|
|
141
|
+
name="not_like",
|
|
142
|
+
spellings=("=notlike=", "=nk="),
|
|
143
|
+
minimum_arguments=1,
|
|
144
|
+
maximum_arguments=1,
|
|
145
|
+
)
|
|
146
|
+
IGNORE_CASE = ComparisonOperator(
|
|
147
|
+
name="ignore_case",
|
|
148
|
+
spellings=("=ic=", "=icase="),
|
|
149
|
+
minimum_arguments=1,
|
|
150
|
+
maximum_arguments=1,
|
|
151
|
+
)
|
|
152
|
+
IGNORE_CASE_LIKE = ComparisonOperator(
|
|
153
|
+
name="ignore_case_like",
|
|
154
|
+
spellings=("=ilike=", "=ik="),
|
|
155
|
+
minimum_arguments=1,
|
|
156
|
+
maximum_arguments=1,
|
|
157
|
+
)
|
|
158
|
+
IGNORE_CASE_NOT_LIKE = ComparisonOperator(
|
|
159
|
+
name="ignore_case_not_like",
|
|
160
|
+
spellings=("=inotlike=", "=ni="),
|
|
161
|
+
minimum_arguments=1,
|
|
162
|
+
maximum_arguments=1,
|
|
163
|
+
)
|
|
164
|
+
BETWEEN = ComparisonOperator(
|
|
165
|
+
name="between",
|
|
166
|
+
spellings=("=bt=", "=between="),
|
|
167
|
+
minimum_arguments=2,
|
|
168
|
+
maximum_arguments=2,
|
|
169
|
+
)
|
|
170
|
+
NOT_BETWEEN = ComparisonOperator(
|
|
171
|
+
name="not_between",
|
|
172
|
+
spellings=("=nb=", "=notbetween="),
|
|
173
|
+
minimum_arguments=2,
|
|
174
|
+
maximum_arguments=2,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
SUPPORTED_COMPARISON_OPERATORS = (
|
|
178
|
+
EQUAL,
|
|
179
|
+
NOT_EQUAL,
|
|
180
|
+
GREATER_THAN,
|
|
181
|
+
GREATER_THAN_OR_EQUAL,
|
|
182
|
+
LESS_THAN,
|
|
183
|
+
LESS_THAN_OR_EQUAL,
|
|
184
|
+
IN,
|
|
185
|
+
NOT_IN,
|
|
186
|
+
IS_NULL,
|
|
187
|
+
NOT_NULL,
|
|
188
|
+
LIKE,
|
|
189
|
+
NOT_LIKE,
|
|
190
|
+
IGNORE_CASE,
|
|
191
|
+
IGNORE_CASE_LIKE,
|
|
192
|
+
IGNORE_CASE_NOT_LIKE,
|
|
193
|
+
BETWEEN,
|
|
194
|
+
NOT_BETWEEN,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
DEFAULT_OPERATOR_REGISTRY = OperatorRegistry(
|
|
198
|
+
operators=SUPPORTED_COMPARISON_OPERATORS
|
|
199
|
+
)
|
|
200
|
+
OPERATOR_SPELLINGS = DEFAULT_OPERATOR_REGISTRY.operator_spellings
|
|
201
|
+
OPERATORS_BY_SPELLING = DEFAULT_OPERATOR_REGISTRY.operators_by_spelling
|