snake-orm 0.1.0b1__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.
- snake_orm-0.1.0b1.dist-info/METADATA +366 -0
- snake_orm-0.1.0b1.dist-info/RECORD +198 -0
- snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
- snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
- snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
- snakeorm/__init__.py +214 -0
- snakeorm/advisor.py +109 -0
- snakeorm/cli/__init__.py +3 -0
- snakeorm/cli/app.py +992 -0
- snakeorm/cli/discovery.py +179 -0
- snakeorm/cli/hooks.py +86 -0
- snakeorm/compiler/__init__.py +3 -0
- snakeorm/compiler/compiler.py +330 -0
- snakeorm/connection.py +228 -0
- snakeorm/contrib/__init__.py +17 -0
- snakeorm/contrib/asgi.py +192 -0
- snakeorm/contrib/config.py +264 -0
- snakeorm/contrib/deliver.py +256 -0
- snakeorm/contrib/django.py +245 -0
- snakeorm/contrib/sidecar.py +44 -0
- snakeorm/contrib/wsgi.py +176 -0
- snakeorm/core/__init__.py +1 -0
- snakeorm/core/config.py +173 -0
- snakeorm/core/converters.py +172 -0
- snakeorm/core/exceptions.py +175 -0
- snakeorm/core/placement.py +15 -0
- snakeorm/core/sentinels.py +37 -0
- snakeorm/core/signals.py +114 -0
- snakeorm/debug/__init__.py +42 -0
- snakeorm/debug/assets/css/panel.css +616 -0
- snakeorm/debug/assets/css/snake_orm_app.css +293 -0
- snakeorm/debug/assets/img/icon.svg +5 -0
- snakeorm/debug/assets/js/history.js +425 -0
- snakeorm/debug/assets/js/language.js +283 -0
- snakeorm/debug/assets/js/panel.js +241 -0
- snakeorm/debug/assets/js/snake_orm_app.js +82 -0
- snakeorm/debug/assets/pages/cli.html +28 -0
- snakeorm/debug/assets/pages/config.html +34 -0
- snakeorm/debug/assets/pages/database_first.html +29 -0
- snakeorm/debug/assets/pages/help.html +21 -0
- snakeorm/debug/assets/pages/history.html +27 -0
- snakeorm/debug/capture.py +234 -0
- snakeorm/debug/channel.py +131 -0
- snakeorm/debug/collector.py +144 -0
- snakeorm/debug/config.py +149 -0
- snakeorm/debug/html.py +578 -0
- snakeorm/debug/matrix.py +60 -0
- snakeorm/debug/origin.py +42 -0
- snakeorm/debug/otel/__init__.py +68 -0
- snakeorm/debug/otel/context.py +43 -0
- snakeorm/debug/otel/exporter.py +277 -0
- snakeorm/debug/otel/payload.py +97 -0
- snakeorm/debug/otel/report.py +29 -0
- snakeorm/debug/otel/spans.py +393 -0
- snakeorm/debug/otel/summary.py +59 -0
- snakeorm/debug/record.py +54 -0
- snakeorm/debug/report.py +311 -0
- snakeorm/debug/testing.py +23 -0
- snakeorm/decorators/__init__.py +13 -0
- snakeorm/decorators/abstract.py +26 -0
- snakeorm/decorators/db_first.py +104 -0
- snakeorm/decorators/function.py +28 -0
- snakeorm/decorators/model.py +268 -0
- snakeorm/decorators/polymorphic.py +140 -0
- snakeorm/decorators/result.py +167 -0
- snakeorm/decorators/row.py +90 -0
- snakeorm/decorators/trigger.py +47 -0
- snakeorm/decorators/view.py +149 -0
- snakeorm/dialects/__init__.py +6 -0
- snakeorm/dialects/base.py +373 -0
- snakeorm/dialects/capabilities.py +571 -0
- snakeorm/dialects/literals.py +46 -0
- snakeorm/dialects/matrix.py +384 -0
- snakeorm/dialects/mysql.py +519 -0
- snakeorm/dialects/postgres.py +504 -0
- snakeorm/dialects/sqlite.py +417 -0
- snakeorm/drivers/__init__.py +18 -0
- snakeorm/drivers/asyncbase.py +73 -0
- snakeorm/drivers/asyncdecorators.py +244 -0
- snakeorm/drivers/asyncpool.py +228 -0
- snakeorm/drivers/asyncpsycopg.py +124 -0
- snakeorm/drivers/asyncpymysql.py +50 -0
- snakeorm/drivers/asyncsqlite.py +30 -0
- snakeorm/drivers/base.py +76 -0
- snakeorm/drivers/failures.py +181 -0
- snakeorm/drivers/logging.py +167 -0
- snakeorm/drivers/pool.py +352 -0
- snakeorm/drivers/psycopg.py +188 -0
- snakeorm/drivers/pymysql.py +176 -0
- snakeorm/drivers/savepoints.py +46 -0
- snakeorm/drivers/sqlite.py +170 -0
- snakeorm/drivers/threaded.py +143 -0
- snakeorm/drivers/timeout.py +87 -0
- snakeorm/dto/__init__.py +55 -0
- snakeorm/dto/read.py +231 -0
- snakeorm/dto/region.py +326 -0
- snakeorm/dto/resolve.py +354 -0
- snakeorm/dto/spec.py +201 -0
- snakeorm/expressions/__init__.py +88 -0
- snakeorm/expressions/conditional.py +178 -0
- snakeorm/expressions/expression.py +810 -0
- snakeorm/expressions/functions.py +71 -0
- snakeorm/expressions/keys.py +299 -0
- snakeorm/expressions/paths.py +43 -0
- snakeorm/expressions/scalar.py +293 -0
- snakeorm/expressions/window.py +248 -0
- snakeorm/fields/__init__.py +68 -0
- snakeorm/fields/check.py +79 -0
- snakeorm/fields/column.py +316 -0
- snakeorm/fields/enum.py +64 -0
- snakeorm/fields/index.py +46 -0
- snakeorm/fields/relationship.py +738 -0
- snakeorm/fields/typed.py +736 -0
- snakeorm/helpers/__init__.py +1 -0
- snakeorm/helpers/annotations.py +21 -0
- snakeorm/helpers/inheritance.py +41 -0
- snakeorm/helpers/pyliteral.py +52 -0
- snakeorm/helpers/pytype.py +50 -0
- snakeorm/introspection/__init__.py +13 -0
- snakeorm/introspection/base.py +30 -0
- snakeorm/introspection/drift.py +78 -0
- snakeorm/introspection/models.py +633 -0
- snakeorm/introspection/mysql.py +367 -0
- snakeorm/introspection/postgres.py +397 -0
- snakeorm/introspection/sqlite.py +217 -0
- snakeorm/introspection/unsupported.py +94 -0
- snakeorm/linker/__init__.py +3 -0
- snakeorm/linker/linker.py +521 -0
- snakeorm/metadata/__init__.py +41 -0
- snakeorm/metadata/check.py +37 -0
- snakeorm/metadata/column.py +201 -0
- snakeorm/metadata/enum_storage.py +33 -0
- snakeorm/metadata/fk_action.py +19 -0
- snakeorm/metadata/foreign_key.py +26 -0
- snakeorm/metadata/index.py +43 -0
- snakeorm/metadata/index_method.py +19 -0
- snakeorm/metadata/int_size.py +20 -0
- snakeorm/metadata/json_storage.py +21 -0
- snakeorm/metadata/polymorphic.py +30 -0
- snakeorm/metadata/primary_key.py +23 -0
- snakeorm/metadata/relationship.py +60 -0
- snakeorm/metadata/relationship_kind.py +43 -0
- snakeorm/metadata/routine.py +26 -0
- snakeorm/metadata/server_default.py +20 -0
- snakeorm/metadata/table.py +97 -0
- snakeorm/metadata/table_kind.py +23 -0
- snakeorm/metadata/trigger.py +62 -0
- snakeorm/metadata/type_params.py +348 -0
- snakeorm/migration/__init__.py +88 -0
- snakeorm/migration/asyncrunner.py +168 -0
- snakeorm/migration/autodetect.py +96 -0
- snakeorm/migration/ddl.py +1172 -0
- snakeorm/migration/diff.py +701 -0
- snakeorm/migration/loader.py +77 -0
- snakeorm/migration/operations.py +952 -0
- snakeorm/migration/planner.py +78 -0
- snakeorm/migration/realize.py +420 -0
- snakeorm/migration/renames.py +153 -0
- snakeorm/migration/render.py +1095 -0
- snakeorm/migration/runner.py +314 -0
- snakeorm/migration/squash.py +60 -0
- snakeorm/migration/state.py +107 -0
- snakeorm/model.py +162 -0
- snakeorm/py.typed +0 -0
- snakeorm/query/__init__.py +9 -0
- snakeorm/query/compound.py +288 -0
- snakeorm/query/join_kind.py +19 -0
- snakeorm/query/joined.py +143 -0
- snakeorm/query/query.py +1001 -0
- snakeorm/query/recursive.py +244 -0
- snakeorm/registry/__init__.py +5 -0
- snakeorm/registry/by_module.py +163 -0
- snakeorm/registry/registry.py +166 -0
- snakeorm/session/__init__.py +8 -0
- snakeorm/session/asyncsession.py +638 -0
- snakeorm/session/coercion.py +306 -0
- snakeorm/session/factory.py +29 -0
- snakeorm/session/guards.py +269 -0
- snakeorm/session/isolation.py +23 -0
- snakeorm/session/mapper.py +213 -0
- snakeorm/session/planning.py +815 -0
- snakeorm/session/retry.py +96 -0
- snakeorm/session/session.py +871 -0
- snakeorm/session/shared.py +355 -0
- snakeorm/sql/__init__.py +18 -0
- snakeorm/sql/adapt.py +104 -0
- snakeorm/sql/aggregate.py +232 -0
- snakeorm/sql/condition.py +359 -0
- snakeorm/sql/delete.py +46 -0
- snakeorm/sql/insert.py +149 -0
- snakeorm/sql/joins.py +158 -0
- snakeorm/sql/pk_subquery.py +54 -0
- snakeorm/sql/refs.py +19 -0
- snakeorm/sql/resolver.py +28 -0
- snakeorm/sql/select.py +175 -0
- snakeorm/sql/update.py +82 -0
- snakeorm/sql/value.py +552 -0
- snakeorm/times.py +276 -0
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
"""Typed expression AST: values (SnakeValue) and conditions (SnakeCondition).
|
|
2
|
+
|
|
3
|
+
The nodes do NOT execute: they describe SQL; `sql/` translates them. Hierarchy of values:
|
|
4
|
+
|
|
5
|
+
SnakeValue[T] base: it compares and operates; it has NO `.path`.
|
|
6
|
+
├── SnakeExpr[T] a column (the only one with `.path`).
|
|
7
|
+
└── SnakeArith[T] an arithmetic operation.
|
|
8
|
+
|
|
9
|
+
`SnakeValue.__eq__` returns a comparison (not `bool`), so the nodes use identity equality
|
|
10
|
+
(`eq=False`) and are not hashable by value.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from decimal import Decimal
|
|
17
|
+
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar, overload
|
|
22
|
+
|
|
23
|
+
from snakeorm.core.exceptions import SnakeUnsupportedFeature, SnakeValueError
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
# For the checker only: `window` imports this module, so at runtime it would be a cycle.
|
|
27
|
+
# With `from __future__ import annotations` the annotation is a string and is never evaluated.
|
|
28
|
+
from snakeorm.expressions.window import SnakeFrame, SnakeWindow
|
|
29
|
+
|
|
30
|
+
T = TypeVar("T")
|
|
31
|
+
V = TypeVar("V")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SnakeOp(Enum):
|
|
35
|
+
"""Comparison operators supported inside a condition."""
|
|
36
|
+
|
|
37
|
+
EQ = "="
|
|
38
|
+
NE = "<>"
|
|
39
|
+
LT = "<"
|
|
40
|
+
LE = "<="
|
|
41
|
+
GT = ">"
|
|
42
|
+
GE = ">="
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SnakeArithOp(Enum):
|
|
46
|
+
"""Arithmetic operators supported inside a value expression."""
|
|
47
|
+
|
|
48
|
+
ADD = "+"
|
|
49
|
+
SUB = "-"
|
|
50
|
+
MUL = "*"
|
|
51
|
+
DIV = "/"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SnakeNulls(Enum):
|
|
55
|
+
"""Where the NULLs go when ordering. Agnostic: `NULLS FIRST`/`NULLS LAST` is standard SQL."""
|
|
56
|
+
|
|
57
|
+
FIRST = "FIRST"
|
|
58
|
+
LAST = "LAST"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SnakeLock(Enum):
|
|
62
|
+
"""What to do if the row to be locked is already locked.
|
|
63
|
+
|
|
64
|
+
`WAIT` waits (the default of `FOR UPDATE`), `NOWAIT` fails, `SKIP_LOCKED` ignores it. The last
|
|
65
|
+
two are opposites: asking for both is an error.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
WAIT = "wait"
|
|
69
|
+
NOWAIT = "nowait"
|
|
70
|
+
SKIP_LOCKED = "skip_locked"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SnakeAggFunc(Enum):
|
|
74
|
+
"""Supported aggregation functions (standard SQL, engine-agnostic)."""
|
|
75
|
+
|
|
76
|
+
COUNT = "COUNT"
|
|
77
|
+
SUM = "SUM"
|
|
78
|
+
AVG = "AVG"
|
|
79
|
+
MIN = "MIN"
|
|
80
|
+
MAX = "MAX"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SnakeCondition:
|
|
84
|
+
"""Boolean node of the query AST (WHERE, JOIN ON...).
|
|
85
|
+
|
|
86
|
+
It combines with `&` (AND), `|` (OR) and is negated with `~` (NOT).
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
__slots__ = ()
|
|
90
|
+
|
|
91
|
+
def __and__(self, other: SnakeCondition) -> SnakeAnd:
|
|
92
|
+
return SnakeAnd(parts=(self, other))
|
|
93
|
+
|
|
94
|
+
def __or__(self, other: SnakeCondition) -> SnakeOr:
|
|
95
|
+
return SnakeOr(parts=(self, other))
|
|
96
|
+
|
|
97
|
+
def __invert__(self) -> SnakeNot:
|
|
98
|
+
return SnakeNot(operand=self)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
102
|
+
class SnakeComparison(SnakeCondition):
|
|
103
|
+
"""Comparison `value OP value` (e.g. `username = 'Ana'`)."""
|
|
104
|
+
|
|
105
|
+
left: SnakeValue[Any]
|
|
106
|
+
op: SnakeOp
|
|
107
|
+
right: object
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
111
|
+
class SnakeAnd(SnakeCondition):
|
|
112
|
+
"""Conjunction of conditions (all of them must hold)."""
|
|
113
|
+
|
|
114
|
+
parts: tuple[SnakeCondition, ...]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
118
|
+
class SnakeOr(SnakeCondition):
|
|
119
|
+
"""Disjunction of conditions (at least one of them must hold)."""
|
|
120
|
+
|
|
121
|
+
parts: tuple[SnakeCondition, ...]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
125
|
+
class SnakeInList(SnakeCondition):
|
|
126
|
+
"""Membership in a set: `value IN (v1, v2, ...)`."""
|
|
127
|
+
|
|
128
|
+
left: SnakeValue[Any]
|
|
129
|
+
values: tuple[object, ...]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
133
|
+
class SnakeTupleIn(SnakeCondition):
|
|
134
|
+
"""Row constructor: `(c1, c2) IN ((v1a, v2a), ...)` — the N-dimensional version of `SnakeInList`.
|
|
135
|
+
|
|
136
|
+
It is produced by the select-in of a to-many with a composite FK. Postgres emits it as is; a
|
|
137
|
+
dialect without a row constructor translates it into the disjunction `(a=.. AND b=..) OR (...)`. Parametrised values.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
columns: tuple[SnakeValue[Any], ...]
|
|
141
|
+
rows: tuple[tuple[object, ...], ...]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
145
|
+
class SnakeIsNull(SnakeCondition):
|
|
146
|
+
"""Nullity check: `value IS NULL`."""
|
|
147
|
+
|
|
148
|
+
left: SnakeValue[Any]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
152
|
+
class SnakeIsNotNull(SnakeCondition):
|
|
153
|
+
"""Non-nullity check: `value IS NOT NULL`."""
|
|
154
|
+
|
|
155
|
+
left: SnakeValue[Any]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _escape_like(value: str) -> str:
|
|
159
|
+
"""Escapes the LIKE wildcards inside a VALUE, so they are data and not pattern.
|
|
160
|
+
|
|
161
|
+
`startswith("100%")` must search for a literal "100%": without this, the filter would silently
|
|
162
|
+
bring back TOO MUCH. The backslash goes first, or it would escape the escapes just put in.
|
|
163
|
+
"""
|
|
164
|
+
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
168
|
+
class SnakeLike(SnakeCondition):
|
|
169
|
+
"""Pattern match: `value LIKE pattern` (or `ILIKE` if it ignores case).
|
|
170
|
+
|
|
171
|
+
The pattern ALWAYS travels parametrised. `case_insensitive` is a flag (it does not compose well
|
|
172
|
+
with the existing nodes).
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
left: SnakeValue[Any]
|
|
176
|
+
pattern: str
|
|
177
|
+
case_insensitive: bool = False
|
|
178
|
+
# True when the wildcards of the VALUE were escaped (startswith/contains/endswith): the emission
|
|
179
|
+
# adds `ESCAPE '\'` or SQLite treats the `\` as a character and filters wrong. A raw `.like()` goes with False.
|
|
180
|
+
escaped: bool = False
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
184
|
+
class SnakeNot(SnakeCondition):
|
|
185
|
+
"""Negation of a condition: `NOT (...)`."""
|
|
186
|
+
|
|
187
|
+
operand: SnakeCondition
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True, slots=True)
|
|
191
|
+
class SnakeExistsJoin:
|
|
192
|
+
"""A navigation JOIN INSIDE the subquery of an EXISTS (a to-one relationship of the child).
|
|
193
|
+
|
|
194
|
+
When the condition of `.any()` navigates (`Maker.nation.name`), it joins the target table inside
|
|
195
|
+
the EXISTS. Already resolved against the graph (the emission only assigns aliases). `prefix` is
|
|
196
|
+
the relationships accumulated from the child (`prefix[:-1]` reaches the parent, `()` = the
|
|
197
|
+
child); `pairs` are the to-one FK pairs (`parent.local = alias.remote`, a composite FK ANDs
|
|
198
|
+
them). Primitives only: no `SnakeTableInfo`.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
prefix: tuple[str, ...]
|
|
202
|
+
schema: str
|
|
203
|
+
name: str
|
|
204
|
+
pairs: tuple[tuple[str, str], ...]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
208
|
+
class SnakeExists(SnakeCondition):
|
|
209
|
+
"""Correlated EXISTS: does the parent have at least one child [satisfying `condition`]?
|
|
210
|
+
|
|
211
|
+
It is produced by class access to a to-many (`Nation.makers.any(...)`). It changes the
|
|
212
|
+
cardinality, which is why it is explicit; it inherits `& | ~`. It stores the child table and the
|
|
213
|
+
correlation pairs (`(child_column, parent_column)`) so it can emit `child.fk = parent.pk`. The
|
|
214
|
+
`condition` (optional) filters the child (paths relative to the child, re-anchored at emission).
|
|
215
|
+
`joins` are the to-one navigation JOINs of the child. Primitives only, no `SnakeTableInfo`.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
child_schema: str
|
|
219
|
+
child_name: str
|
|
220
|
+
pairs: tuple[tuple[str, str], ...]
|
|
221
|
+
condition: SnakeCondition | None = None
|
|
222
|
+
joins: tuple[SnakeExistsJoin, ...] = ()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
226
|
+
class SnakeOrder:
|
|
227
|
+
"""Ordering key: a value, its direction and where the NULLs go.
|
|
228
|
+
|
|
229
|
+
`nulls=None` leaves the engine default, which has a trap in it: Postgres puts the NULLs last in
|
|
230
|
+
ASC and first in DESC, so changing the direction moves the gaps around.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
expr: SnakeValue[Any]
|
|
234
|
+
descending: bool
|
|
235
|
+
nulls: SnakeNulls | None = None
|
|
236
|
+
|
|
237
|
+
def nulls_first(self) -> SnakeOrder:
|
|
238
|
+
"""The same key, with the NULLs first."""
|
|
239
|
+
return SnakeOrder(self.expr, self.descending, SnakeNulls.FIRST)
|
|
240
|
+
|
|
241
|
+
def nulls_last(self) -> SnakeOrder:
|
|
242
|
+
"""The same key, with the NULLs last."""
|
|
243
|
+
return SnakeOrder(self.expr, self.descending, SnakeNulls.LAST)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class SnakeValue(Generic[T]):
|
|
247
|
+
"""Base of every VALUE expression: it knows how to compare itself and operate arithmetically.
|
|
248
|
+
|
|
249
|
+
With no SQL identity of its own (the subclasses provide that). The comparators produce a
|
|
250
|
+
`SnakeCondition`; the arithmetic ones another `SnakeValue` (`SnakeArith`), chainable.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
__slots__ = ()
|
|
254
|
+
|
|
255
|
+
def __eq__(self, other: object) -> SnakeCondition: # type: ignore[override]
|
|
256
|
+
if (
|
|
257
|
+
other is None
|
|
258
|
+
): # `col == None` -> IS NULL (not `= NULL`, which in SQL is always false)
|
|
259
|
+
return SnakeIsNull(self)
|
|
260
|
+
return SnakeComparison(self, SnakeOp.EQ, other)
|
|
261
|
+
|
|
262
|
+
def __ne__(self, other: object) -> SnakeCondition: # type: ignore[override]
|
|
263
|
+
if other is None: # `col != None` -> IS NOT NULL
|
|
264
|
+
return SnakeIsNotNull(self)
|
|
265
|
+
return SnakeComparison(self, SnakeOp.NE, other)
|
|
266
|
+
|
|
267
|
+
# A COLUMN is accepted on the right, and it costs no parameter: `quantity > reserved` emits
|
|
268
|
+
# `"quantity" > "reserved"`. It is the same rule arithmetic always used: an operand that is a
|
|
269
|
+
# `SnakeValue` is a reference, anything else is a literal.
|
|
270
|
+
#
|
|
271
|
+
# The pair stays TYPED: `SnakeValue` is invariant in `T`, so `SnakeExpr[str] > SnakeExpr[int]`
|
|
272
|
+
# is still rejected. Comparing a column against a column of another type is the mistake this
|
|
273
|
+
# opening must not let through.
|
|
274
|
+
#
|
|
275
|
+
# An `int` is accepted on a `Decimal` column, and ONLY there. `Decimal("1") >= 0` is valid
|
|
276
|
+
# Python, `NUMERIC >= 0` is valid SQL, and this ORM emits the SAME SQL either way — measured
|
|
277
|
+
# byte for byte on the three dialects. The promotion is EXACT: an integer has no fractional
|
|
278
|
+
# part, so `0` and `Decimal(0)` are the same number and nothing rounds. `float` is NOT opened
|
|
279
|
+
# here and does not need to be: PEP 484's numeric tower already says an `int` goes wherever a
|
|
280
|
+
# `float` is expected. `str` stays shut, as it is in Python.
|
|
281
|
+
@overload
|
|
282
|
+
def __lt__(
|
|
283
|
+
self: SnakeValue[Decimal],
|
|
284
|
+
other: Decimal | int | SnakeValue[Decimal] | SnakeValue[int],
|
|
285
|
+
) -> SnakeComparison: ...
|
|
286
|
+
|
|
287
|
+
@overload
|
|
288
|
+
def __lt__(self, other: SnakeValue[T] | T) -> SnakeComparison: ...
|
|
289
|
+
|
|
290
|
+
def __lt__(self, other: object) -> SnakeComparison:
|
|
291
|
+
return SnakeComparison(self, SnakeOp.LT, other)
|
|
292
|
+
|
|
293
|
+
@overload
|
|
294
|
+
def __le__(
|
|
295
|
+
self: SnakeValue[Decimal],
|
|
296
|
+
other: Decimal | int | SnakeValue[Decimal] | SnakeValue[int],
|
|
297
|
+
) -> SnakeComparison: ...
|
|
298
|
+
|
|
299
|
+
@overload
|
|
300
|
+
def __le__(self, other: SnakeValue[T] | T) -> SnakeComparison: ...
|
|
301
|
+
|
|
302
|
+
def __le__(self, other: object) -> SnakeComparison:
|
|
303
|
+
return SnakeComparison(self, SnakeOp.LE, other)
|
|
304
|
+
|
|
305
|
+
@overload
|
|
306
|
+
def __gt__(
|
|
307
|
+
self: SnakeValue[Decimal],
|
|
308
|
+
other: Decimal | int | SnakeValue[Decimal] | SnakeValue[int],
|
|
309
|
+
) -> SnakeComparison: ...
|
|
310
|
+
|
|
311
|
+
@overload
|
|
312
|
+
def __gt__(self, other: SnakeValue[T] | T) -> SnakeComparison: ...
|
|
313
|
+
|
|
314
|
+
def __gt__(self, other: object) -> SnakeComparison:
|
|
315
|
+
return SnakeComparison(self, SnakeOp.GT, other)
|
|
316
|
+
|
|
317
|
+
@overload
|
|
318
|
+
def __ge__(
|
|
319
|
+
self: SnakeValue[Decimal],
|
|
320
|
+
other: Decimal | int | SnakeValue[Decimal] | SnakeValue[int],
|
|
321
|
+
) -> SnakeComparison: ...
|
|
322
|
+
|
|
323
|
+
@overload
|
|
324
|
+
def __ge__(self, other: SnakeValue[T] | T) -> SnakeComparison: ...
|
|
325
|
+
|
|
326
|
+
def __ge__(self, other: object) -> SnakeComparison:
|
|
327
|
+
return SnakeComparison(self, SnakeOp.GE, other)
|
|
328
|
+
|
|
329
|
+
# Not hashable: `__eq__` returns a condition, not a bool. A RUNTIME guard (`hash()` raises
|
|
330
|
+
# TypeError); statically it cannot be closed off, hence the `# type: ignore[assignment]`.
|
|
331
|
+
__hash__ = None # type: ignore[assignment]
|
|
332
|
+
|
|
333
|
+
# THE SECOND OVERLOAD OF EACH OPERATOR PROPAGATES NULLABILITY, and the line it draws is one
|
|
334
|
+
# question: does the type CHANGE what the engine computes, or finally DESCRIBE it?
|
|
335
|
+
#
|
|
336
|
+
# int * 1.0 changes it — integer division or decimal is a decision, and it stays the
|
|
337
|
+
# caller's, made explicit with `snake_cast`.
|
|
338
|
+
# a + NULL changes nothing — it is NULL on the three engines, always, no choice in it.
|
|
339
|
+
#
|
|
340
|
+
# Without it, `snake_nullif` declaring the `None` it introduces would make
|
|
341
|
+
# `x / snake_nullif(y, 0)` impossible to write — the one expression it exists for.
|
|
342
|
+
#
|
|
343
|
+
# The non-nullable overload goes FIRST: with a nullable operand it cannot match (`SnakeValue[T]`
|
|
344
|
+
# is invariant, so `SnakeValue[int | None]` is not a `SnakeValue[int]`) and resolution falls
|
|
345
|
+
# through to the second. Reversing them would make every arithmetic nullable.
|
|
346
|
+
|
|
347
|
+
@overload
|
|
348
|
+
def __add__(self, other: SnakeValue[T] | T) -> SnakeArith[T]: ...
|
|
349
|
+
@overload
|
|
350
|
+
def __add__(self, other: SnakeValue[T | None]) -> SnakeArith[T | None]: ...
|
|
351
|
+
def __add__(self, other: object) -> SnakeArith[Any]:
|
|
352
|
+
return SnakeArith(self, SnakeArithOp.ADD, other)
|
|
353
|
+
|
|
354
|
+
@overload
|
|
355
|
+
def __sub__(self, other: SnakeValue[T] | T) -> SnakeArith[T]: ...
|
|
356
|
+
@overload
|
|
357
|
+
def __sub__(self, other: SnakeValue[T | None]) -> SnakeArith[T | None]: ...
|
|
358
|
+
def __sub__(self, other: object) -> SnakeArith[Any]:
|
|
359
|
+
return SnakeArith(self, SnakeArithOp.SUB, other)
|
|
360
|
+
|
|
361
|
+
@overload
|
|
362
|
+
def __mul__(self, other: SnakeValue[T] | T) -> SnakeArith[T]: ...
|
|
363
|
+
@overload
|
|
364
|
+
def __mul__(self, other: SnakeValue[T | None]) -> SnakeArith[T | None]: ...
|
|
365
|
+
def __mul__(self, other: object) -> SnakeArith[Any]:
|
|
366
|
+
return SnakeArith(self, SnakeArithOp.MUL, other)
|
|
367
|
+
|
|
368
|
+
@overload
|
|
369
|
+
def __truediv__(self, other: SnakeValue[T] | T) -> SnakeArith[T]: ...
|
|
370
|
+
@overload
|
|
371
|
+
def __truediv__(self, other: SnakeValue[T | None]) -> SnakeArith[T | None]: ...
|
|
372
|
+
def __truediv__(self, other: object) -> SnakeArith[Any]:
|
|
373
|
+
return SnakeArith(self, SnakeArithOp.DIV, other)
|
|
374
|
+
|
|
375
|
+
# Reflected: the foreign operand ends up on the LEFT (the order matters for `-` and `/`).
|
|
376
|
+
|
|
377
|
+
@overload
|
|
378
|
+
def __radd__(self, other: SnakeValue[T] | T) -> SnakeArith[T]: ...
|
|
379
|
+
@overload
|
|
380
|
+
def __radd__(self, other: SnakeValue[T | None]) -> SnakeArith[T | None]: ...
|
|
381
|
+
def __radd__(self, other: object) -> SnakeArith[Any]:
|
|
382
|
+
return SnakeArith(other, SnakeArithOp.ADD, self)
|
|
383
|
+
|
|
384
|
+
@overload
|
|
385
|
+
def __rsub__(self, other: SnakeValue[T] | T) -> SnakeArith[T]: ...
|
|
386
|
+
@overload
|
|
387
|
+
def __rsub__(self, other: SnakeValue[T | None]) -> SnakeArith[T | None]: ...
|
|
388
|
+
def __rsub__(self, other: object) -> SnakeArith[Any]:
|
|
389
|
+
return SnakeArith(other, SnakeArithOp.SUB, self)
|
|
390
|
+
|
|
391
|
+
@overload
|
|
392
|
+
def __rmul__(self, other: SnakeValue[T] | T) -> SnakeArith[T]: ...
|
|
393
|
+
@overload
|
|
394
|
+
def __rmul__(self, other: SnakeValue[T | None]) -> SnakeArith[T | None]: ...
|
|
395
|
+
def __rmul__(self, other: object) -> SnakeArith[Any]:
|
|
396
|
+
return SnakeArith(other, SnakeArithOp.MUL, self)
|
|
397
|
+
|
|
398
|
+
@overload
|
|
399
|
+
def __rtruediv__(self, other: SnakeValue[T] | T) -> SnakeArith[T]: ...
|
|
400
|
+
@overload
|
|
401
|
+
def __rtruediv__(self, other: SnakeValue[T | None]) -> SnakeArith[T | None]: ...
|
|
402
|
+
def __rtruediv__(self, other: object) -> SnakeArith[Any]:
|
|
403
|
+
return SnakeArith(other, SnakeArithOp.DIV, self)
|
|
404
|
+
|
|
405
|
+
def json_get(self, *key_path: str, as_type: type[V]) -> SnakeJsonGet[V]:
|
|
406
|
+
"""Reads a key INSIDE a JSON column, as the declared type: `meta.json_get("size", as_type=int)`.
|
|
407
|
+
|
|
408
|
+
`as_type` is required and it is not ceremony. What the engines give back from a document is
|
|
409
|
+
text, so without a declared type the comparison below would be a TEXT comparison and
|
|
410
|
+
`'9' > '100'` would be true — the same trap the capability catalogue documents for a
|
|
411
|
+
`Decimal` ordered as text. The type is what makes the ORM emit the cast.
|
|
412
|
+
|
|
413
|
+
Several keys walk a nested path in ONE access (`json_get("owner", "name")`), because every
|
|
414
|
+
engine takes a path and two accesses would be two trips through the document.
|
|
415
|
+
|
|
416
|
+
The key is validated rather than parametrised: it is emitted inside a literal, where no
|
|
417
|
+
engine accepts a placeholder, so a key that is not a plain identifier is refused here.
|
|
418
|
+
"""
|
|
419
|
+
if as_type not in JSON_CASTABLE:
|
|
420
|
+
raise SnakeUnsupportedFeature(
|
|
421
|
+
f"json_get(as_type={getattr(as_type, '__name__', as_type)!r}) is not supported: a "
|
|
422
|
+
f"document is read back as one of {', '.join(t.__name__ for t in JSON_CASTABLE)}. "
|
|
423
|
+
f"Read it as str and convert it yourself if the engine has no cast for that type."
|
|
424
|
+
)
|
|
425
|
+
if not key_path:
|
|
426
|
+
raise SnakeValueError("json_get() needs at least one key to read")
|
|
427
|
+
for key in key_path:
|
|
428
|
+
if not _JSON_KEY.match(key):
|
|
429
|
+
raise SnakeValueError(
|
|
430
|
+
f"json_get() key {key!r} is not a plain identifier. The key is emitted INSIDE "
|
|
431
|
+
f"the statement (no engine takes a placeholder in a JSON path), so anything "
|
|
432
|
+
f"else would be an injection rather than a lookup."
|
|
433
|
+
)
|
|
434
|
+
return SnakeJsonGet(source=self, key_path=key_path, as_type=as_type)
|
|
435
|
+
|
|
436
|
+
@overload
|
|
437
|
+
def in_(self, values: SnakeSubquery[T]) -> SnakeInSubquery: ...
|
|
438
|
+
@overload
|
|
439
|
+
def in_(self, values: Iterable[T]) -> SnakeInList: ...
|
|
440
|
+
def in_(
|
|
441
|
+
self, values: Iterable[T] | SnakeSubquery[T]
|
|
442
|
+
) -> SnakeInList | SnakeInSubquery:
|
|
443
|
+
"""`value IN (...)`: a set of values or a scalar SUBQUERY.
|
|
444
|
+
|
|
445
|
+
An iterable -> values typed to `T`, `SnakeInList`. A `SnakeSubquery[T]` (same `T`) -> `value
|
|
446
|
+
IN (SELECT ...)`, `SnakeInSubquery` (the checker rejects a subquery of another type).
|
|
447
|
+
"""
|
|
448
|
+
if isinstance(values, SnakeSubquery):
|
|
449
|
+
return SnakeInSubquery(left=self, subquery=values)
|
|
450
|
+
return SnakeInList(self, tuple(values))
|
|
451
|
+
|
|
452
|
+
def is_null(self) -> SnakeIsNull:
|
|
453
|
+
"""`value IS NULL`. Valid for any expression."""
|
|
454
|
+
return SnakeIsNull(self)
|
|
455
|
+
|
|
456
|
+
def is_not_null(self) -> SnakeIsNotNull:
|
|
457
|
+
"""`value IS NOT NULL`. Valid for any expression."""
|
|
458
|
+
return SnakeIsNotNull(self)
|
|
459
|
+
|
|
460
|
+
def like(self: SnakeValue[str], pattern: str) -> SnakeLike:
|
|
461
|
+
"""`value LIKE pattern`. Only over text expressions (self: SnakeValue[str])."""
|
|
462
|
+
return SnakeLike(self, pattern)
|
|
463
|
+
|
|
464
|
+
def ilike(self: SnakeValue[str], pattern: str) -> SnakeLike:
|
|
465
|
+
"""`value ILIKE pattern`: like `like`, but ignoring upper and lower case."""
|
|
466
|
+
return SnakeLike(self, pattern, case_insensitive=True)
|
|
467
|
+
|
|
468
|
+
def not_in(self, values: Iterable[T]) -> SnakeNot:
|
|
469
|
+
"""`NOT (value IN (...))`: the NEGATION of the IN, not a new node (one more node would be one more place to forget something)."""
|
|
470
|
+
return SnakeNot(operand=SnakeInList(self, tuple(values)))
|
|
471
|
+
|
|
472
|
+
def between(self, low: T, high: T) -> SnakeAnd:
|
|
473
|
+
"""`value BETWEEN low AND high` (inclusive), as an AND of two comparisons (no node of its own: equivalent SQL)."""
|
|
474
|
+
try:
|
|
475
|
+
inverted = low > high # type: ignore[operator]
|
|
476
|
+
except TypeError:
|
|
477
|
+
# `T` carries no bound and cannot: bounding it would break `SnakeColumn[dict]`, which is
|
|
478
|
+
# a real column type. So the comparison can fail, and Python's own message —"'>' not
|
|
479
|
+
# supported between instances of 'dict' and 'dict'"— is about Python's operators in the
|
|
480
|
+
# middle of building a query, leaving the reader to work out which column it meant.
|
|
481
|
+
# `json_get()` twelve lines above already translates its own refusal; this is that move.
|
|
482
|
+
raise SnakeUnsupportedFeature(
|
|
483
|
+
f"between() needs values that can be ORDERED, and {type(low).__name__} cannot be. "
|
|
484
|
+
f"A column of that type has no range to sit inside: filter it with an equality, or "
|
|
485
|
+
f"with json_get() if what you want is a value inside the document."
|
|
486
|
+
) from None
|
|
487
|
+
if inverted:
|
|
488
|
+
raise SnakeValueError(
|
|
489
|
+
f"The between range is inverted ({low!r} > {high!r}): there is no value between "
|
|
490
|
+
f"those two, so the query would return nothing. Did you swap them round?"
|
|
491
|
+
)
|
|
492
|
+
return SnakeAnd(
|
|
493
|
+
parts=(
|
|
494
|
+
SnakeComparison(self, SnakeOp.GE, low),
|
|
495
|
+
SnakeComparison(self, SnakeOp.LE, high),
|
|
496
|
+
)
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
def startswith(self: SnakeValue[str], value: str) -> SnakeLike:
|
|
500
|
+
"""`LIKE 'value%'`, with the wildcards of the VALUE escaped."""
|
|
501
|
+
return SnakeLike(self, f"{_escape_like(value)}%", escaped=True)
|
|
502
|
+
|
|
503
|
+
def istartswith(self: SnakeValue[str], value: str) -> SnakeLike:
|
|
504
|
+
"""Like `startswith`, but ignoring case."""
|
|
505
|
+
return SnakeLike(
|
|
506
|
+
self, f"{_escape_like(value)}%", case_insensitive=True, escaped=True
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
def endswith(self: SnakeValue[str], value: str) -> SnakeLike:
|
|
510
|
+
"""`LIKE '%value'`, with the wildcards of the VALUE escaped."""
|
|
511
|
+
return SnakeLike(self, f"%{_escape_like(value)}", escaped=True)
|
|
512
|
+
|
|
513
|
+
def iendswith(self: SnakeValue[str], value: str) -> SnakeLike:
|
|
514
|
+
"""Like `endswith`, but ignoring case."""
|
|
515
|
+
return SnakeLike(
|
|
516
|
+
self, f"%{_escape_like(value)}", case_insensitive=True, escaped=True
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
def contains(self: SnakeValue[str], value: str) -> SnakeLike:
|
|
520
|
+
"""`LIKE '%value%'`, with the wildcards of the VALUE escaped."""
|
|
521
|
+
return SnakeLike(self, f"%{_escape_like(value)}%", escaped=True)
|
|
522
|
+
|
|
523
|
+
def icontains(self: SnakeValue[str], value: str) -> SnakeLike:
|
|
524
|
+
"""Like `contains`, but ignoring case."""
|
|
525
|
+
return SnakeLike(
|
|
526
|
+
self, f"%{_escape_like(value)}%", case_insensitive=True, escaped=True
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
def asc(self) -> SnakeOrder:
|
|
530
|
+
"""Ascending order key by this expression."""
|
|
531
|
+
return SnakeOrder(self, descending=False)
|
|
532
|
+
|
|
533
|
+
def desc(self) -> SnakeOrder:
|
|
534
|
+
"""Descending order key by this expression."""
|
|
535
|
+
return SnakeOrder(self, descending=True)
|
|
536
|
+
|
|
537
|
+
def paths(self) -> tuple[tuple[str, ...], ...]:
|
|
538
|
+
"""Column paths contained in this node. Each concrete subclass defines it."""
|
|
539
|
+
raise NotImplementedError
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
class SnakeExpr(SnakeValue[T]):
|
|
543
|
+
"""Typed expression over ONE column.
|
|
544
|
+
|
|
545
|
+
`path` is the navigation down to the column (the last element is the column, the earlier ones
|
|
546
|
+
relationships: `("car", "brand", "name")`). The only column leaf node.
|
|
547
|
+
|
|
548
|
+
`python_type` is the COMPILED type of the column, carried here so the emitter can reason about
|
|
549
|
+
it: the generic `T` is erased at runtime, and without this the SQL layer cannot tell an integer
|
|
550
|
+
division from a decimal one. It is `None` when nobody stamped it — a hand-built node in a test,
|
|
551
|
+
or a path the compiler never saw — and everything that reads it treats `None` as "no proof" and
|
|
552
|
+
changes nothing.
|
|
553
|
+
"""
|
|
554
|
+
|
|
555
|
+
__slots__ = ("path", "python_type")
|
|
556
|
+
|
|
557
|
+
def __init__(self, path: tuple[str, ...], python_type: type | None = None) -> None:
|
|
558
|
+
self.path = path
|
|
559
|
+
self.python_type = python_type
|
|
560
|
+
|
|
561
|
+
def paths(self) -> tuple[tuple[str, ...], ...]:
|
|
562
|
+
"""A column contributes exactly its own path."""
|
|
563
|
+
return (self.path,)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
567
|
+
class SnakeArith(SnakeValue[T]):
|
|
568
|
+
"""Arithmetic operation between two values: `left OP right`.
|
|
569
|
+
|
|
570
|
+
Each operand is another `SnakeValue` or a literal `T`. Chainable: `(a + 1) > 3`.
|
|
571
|
+
"""
|
|
572
|
+
|
|
573
|
+
left: SnakeValue[T] | T
|
|
574
|
+
op: SnakeArithOp
|
|
575
|
+
right: SnakeValue[T] | T
|
|
576
|
+
|
|
577
|
+
def paths(self) -> tuple[tuple[str, ...], ...]:
|
|
578
|
+
"""Recursively collects the paths of the operands that are expressions."""
|
|
579
|
+
collected: tuple[tuple[str, ...], ...] = ()
|
|
580
|
+
if isinstance(self.left, SnakeValue):
|
|
581
|
+
collected += self.left.paths()
|
|
582
|
+
if isinstance(self.right, SnakeValue):
|
|
583
|
+
collected += self.right.paths()
|
|
584
|
+
return collected
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
# What a declared type becomes inside the document access. It is a WHITELIST and not a mapping with a
|
|
588
|
+
# fallback: a type nobody has written a cast for must be refused by name, because the alternative is
|
|
589
|
+
# emitting SQL the engine rejects and letting the driver explain a decision this ORM made.
|
|
590
|
+
JSON_CASTABLE: tuple[type, ...] = (str, int, float, bool)
|
|
591
|
+
|
|
592
|
+
# What a JSON key may be. Emitted INSIDE a literal (`'$.a'`, `'{a,b}'`) because no engine takes a
|
|
593
|
+
# placeholder there — which is exactly the shape where an unchecked string becomes an injection.
|
|
594
|
+
_JSON_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
598
|
+
class SnakeJsonGet(SnakeValue[T]):
|
|
599
|
+
"""A read INSIDE a JSON document: `meta ->> 'size'`, cast to the type that was declared.
|
|
600
|
+
|
|
601
|
+
The type is DECLARED rather than inferred, and that is the decision the whole feature rests on.
|
|
602
|
+
A key access could have been `str` always —which is what `->>` returns— and it would have let
|
|
603
|
+
`meta.json_get("size") > 100` compare TEXT, where `'9' > '100'` is true. The cast is what makes
|
|
604
|
+
the comparison mean what it looks like.
|
|
605
|
+
|
|
606
|
+
What is inside the document is not something the ORM can know: if it does not hold what was
|
|
607
|
+
declared, the ENGINE complains, and that is the right place for it.
|
|
608
|
+
"""
|
|
609
|
+
|
|
610
|
+
source: SnakeValue[Any]
|
|
611
|
+
key_path: tuple[str, ...]
|
|
612
|
+
as_type: type
|
|
613
|
+
|
|
614
|
+
def paths(self) -> tuple[tuple[str, ...], ...]:
|
|
615
|
+
"""The column being read into: the access adds no navigation of its own."""
|
|
616
|
+
return self.source.paths()
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
# What an explicit cast may target. A WHITELIST for the same reason `JSON_CASTABLE` is one, and NOT
|
|
620
|
+
# the same list: `str` belongs there because `->>` already returns text, and does NOT belong here,
|
|
621
|
+
# where accepting it would mean emitting the source unchanged and handing an integer to somebody who
|
|
622
|
+
# asked for text. These three are exactly what the dialects' cast tables can spell.
|
|
623
|
+
CASTABLE: tuple[type, ...] = (int, float, bool)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
627
|
+
class SnakeCast(SnakeValue[T]):
|
|
628
|
+
"""An EXPLICIT conversion: `CAST(<source> AS <type>)`, with the type named at the call site.
|
|
629
|
+
|
|
630
|
+
It exists because the arithmetic operators are `SnakeValue[T] | T -> SnakeArith[T]` — one single
|
|
631
|
+
`T` — and that is right: promoting `int` to `float` behind the user's back would be the ORM
|
|
632
|
+
deciding. But strictness only holds up with an explicit door, and there was none: `column * 1.0`
|
|
633
|
+
does not type-check, so a real division between two integer columns could not be written at all.
|
|
634
|
+
|
|
635
|
+
The TYPE NAME is the dialect's, not this node's. Measured: `CAST(x AS NUMERIC)` answers `0.9` on
|
|
636
|
+
PostgreSQL and `0` on SQLite, whose NUMERIC affinity collapses an integral value back to an
|
|
637
|
+
integer. One spelling would be right on two engines and silently wrong on the third.
|
|
638
|
+
"""
|
|
639
|
+
|
|
640
|
+
source: SnakeValue[Any]
|
|
641
|
+
as_type: type
|
|
642
|
+
|
|
643
|
+
def paths(self) -> tuple[tuple[str, ...], ...]:
|
|
644
|
+
"""What it wraps: a cast adds no navigation, and swallowing these would drop the JOIN."""
|
|
645
|
+
return self.source.paths()
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
649
|
+
class SnakeStringAgg(SnakeValue[T]):
|
|
650
|
+
"""`STRING_AGG(arg, sep ORDER BY ...)`: a group's values joined into ONE string.
|
|
651
|
+
|
|
652
|
+
It gets its own node instead of a member of `SnakeAggFunc` because it is not shaped like the
|
|
653
|
+
others. `SnakeAggregate` emits `FUNC(arg)` uniformly, and this one has a second argument whose
|
|
654
|
+
POSITION differs per engine — and on MySQL is not an argument at all but the `SEPARATOR` keyword.
|
|
655
|
+
Squeezing it into the uniform node would mean the node knowing about engines, which is the one
|
|
656
|
+
thing the graph must never do.
|
|
657
|
+
|
|
658
|
+
`order_by` is not decoration: without it the concatenation comes back in whatever order the
|
|
659
|
+
engine chose, so a value somebody READS changes between runs.
|
|
660
|
+
"""
|
|
661
|
+
|
|
662
|
+
arg: SnakeValue[Any]
|
|
663
|
+
separator: str
|
|
664
|
+
order_by: tuple[SnakeOrder, ...] = ()
|
|
665
|
+
|
|
666
|
+
def paths(self) -> tuple[tuple[str, ...], ...]:
|
|
667
|
+
"""The argument's and the order's: both of them plan JOINs."""
|
|
668
|
+
paths = list(self.arg.paths())
|
|
669
|
+
for key in self.order_by:
|
|
670
|
+
paths.extend(key.expr.paths())
|
|
671
|
+
return tuple(paths)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def static_type(value: object) -> type | None:
|
|
675
|
+
"""The type of an operand when it can be PROVEN, and `None` when it cannot.
|
|
676
|
+
|
|
677
|
+
The generic `T` is erased at runtime, so emission cannot read it. What survives is what somebody
|
|
678
|
+
wrote down: a column's compiled type, a cast's declared target, the class of a literal. This
|
|
679
|
+
walks those, and it is deliberately a PROVER rather than an inferrer — the answer is either a
|
|
680
|
+
type somebody stated or nothing at all.
|
|
681
|
+
|
|
682
|
+
`None` is not a failure to be papered over. Its only caller uses it to decide whether an engine
|
|
683
|
+
needs a different operator, and reading `None` as a guess would silently turn a decimal division
|
|
684
|
+
into an integer one. Absence of proof leaves the SQL exactly as it was.
|
|
685
|
+
"""
|
|
686
|
+
if isinstance(value, SnakeExpr):
|
|
687
|
+
return value.python_type
|
|
688
|
+
if isinstance(value, (SnakeCast, SnakeJsonGet)):
|
|
689
|
+
return value.as_type
|
|
690
|
+
if isinstance(value, SnakeArith):
|
|
691
|
+
# An arithmetic node keeps its operands' type only when BOTH agree. Mixed or unprovable
|
|
692
|
+
# operands make the whole node unprovable, which is what makes nesting safe.
|
|
693
|
+
left = static_type(value.left)
|
|
694
|
+
return left if left is not None and left is static_type(value.right) else None
|
|
695
|
+
if isinstance(value, SnakeValue):
|
|
696
|
+
# Every other node (aggregate, window, function call...) pins its type in the CONSTRUCTOR
|
|
697
|
+
# and does not carry it. Claiming otherwise here would be inventing proof.
|
|
698
|
+
return None
|
|
699
|
+
return type(value)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
703
|
+
class SnakeAggregate(SnakeValue[T]):
|
|
704
|
+
"""Aggregate over a value: `COUNT(*)`, `SUM(col)`, `MIN/MAX(col)`...
|
|
705
|
+
|
|
706
|
+
It is a `SnakeValue` (comparable -> HAVING, projectable). `arg` is the aggregated value (`None` =
|
|
707
|
+
`COUNT(*)`); `distinct` marks `COUNT(DISTINCT ...)`. The TYPE is pinned down by the constructors in `functions.py`.
|
|
708
|
+
"""
|
|
709
|
+
|
|
710
|
+
func: SnakeAggFunc
|
|
711
|
+
arg: SnakeValue[Any] | None = None
|
|
712
|
+
distinct: bool = False
|
|
713
|
+
|
|
714
|
+
def paths(self) -> tuple[tuple[str, ...], ...]:
|
|
715
|
+
"""Propagates the argument's paths (empty in `COUNT(*)`): that is how the aggregate plans its JOINs."""
|
|
716
|
+
return self.arg.paths() if self.arg is not None else ()
|
|
717
|
+
|
|
718
|
+
def over(
|
|
719
|
+
self,
|
|
720
|
+
*,
|
|
721
|
+
partition_by: tuple[SnakeValue[Any], ...] | list[SnakeValue[Any]] = (),
|
|
722
|
+
order_by: tuple[SnakeOrder, ...] | list[SnakeOrder] = (),
|
|
723
|
+
frame: SnakeFrame | None = None,
|
|
724
|
+
) -> SnakeWindow[T]:
|
|
725
|
+
"""The same aggregate as a WINDOW: `SUM(x)` -> `SUM(x) OVER (...)`.
|
|
726
|
+
|
|
727
|
+
With `GROUP BY` it returns one row per group; as a window, every row with its running total.
|
|
728
|
+
`frame` is what makes that total MOVING instead of cumulative, and it is forwarded whole so
|
|
729
|
+
this door and `SnakeWindow.over` cannot drift apart — including the guard that refuses a
|
|
730
|
+
frame with no order to measure it against.
|
|
731
|
+
|
|
732
|
+
The import of `as_window` is local because `window` imports this module and at module level
|
|
733
|
+
that would be a cycle. The ANNOTATIONS are a different matter and used to be lumped in with
|
|
734
|
+
it: `frame` was `Any` and the docstring blamed the same cycle. It never applied — the
|
|
735
|
+
`TYPE_CHECKING` block above already imports `SnakeWindow`, this method's own return type,
|
|
736
|
+
from that very module, and `from __future__ import annotations` means neither name is
|
|
737
|
+
evaluated at runtime. `Any` there switched off the door's type for the checker and crossed
|
|
738
|
+
the strict gate without a word, in a project whose rule is zero `Any`.
|
|
739
|
+
"""
|
|
740
|
+
from snakeorm.expressions.window import as_window
|
|
741
|
+
|
|
742
|
+
return as_window(self).over(
|
|
743
|
+
partition_by=partition_by, order_by=order_by, frame=frame
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
class SnakeSubqueryAggregate(SnakeValue[T]):
|
|
748
|
+
"""Scalar aggregation subquery `(SELECT FUNC(arg) FROM child AS alias WHERE correlation)`.
|
|
749
|
+
|
|
750
|
+
It is produced by `collection.count()/.sum_()/...`. It does NOT change the cardinality: it is a
|
|
751
|
+
comparable scalar VALUE. The TYPE is pinned down by the constructors of `SnakeCollection`. `arg`
|
|
752
|
+
is the column OF THE CHILD (`None` = `COUNT(*)`), re-anchored to the alias at emission like the
|
|
753
|
+
condition of `.any()`. It contributes no paths to the outer query (empty `paths()`): the
|
|
754
|
+
correlation is resolved at emission. It stores the child table and the correlation pairs.
|
|
755
|
+
"""
|
|
756
|
+
|
|
757
|
+
__slots__ = ("func", "arg", "child_schema", "child_name", "pairs")
|
|
758
|
+
|
|
759
|
+
def __init__(
|
|
760
|
+
self,
|
|
761
|
+
func: SnakeAggFunc,
|
|
762
|
+
arg: SnakeExpr[Any] | None,
|
|
763
|
+
child_schema: str,
|
|
764
|
+
child_name: str,
|
|
765
|
+
pairs: tuple[tuple[str, str], ...],
|
|
766
|
+
) -> None:
|
|
767
|
+
self.func = func
|
|
768
|
+
self.arg = arg
|
|
769
|
+
self.child_schema = child_schema
|
|
770
|
+
self.child_name = child_name
|
|
771
|
+
self.pairs = pairs
|
|
772
|
+
|
|
773
|
+
def paths(self) -> tuple[tuple[str, ...], ...]:
|
|
774
|
+
"""A correlated aggregate references no columns of the outer query: no paths."""
|
|
775
|
+
return ()
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
class SnakeSubquery(SnakeValue[T]):
|
|
779
|
+
"""Scalar subquery used as a VALUE: `(SELECT <column> FROM <table> [WHERE ...])`.
|
|
780
|
+
|
|
781
|
+
It is produced by `SnakeQuery.as_scalar(column)`. Primitives only (agnostic; it does not import
|
|
782
|
+
`SnakeQuery`, which avoids a cycle). It has its own FROM: it contributes no paths and does not
|
|
783
|
+
correlate. It is used in `.in_(...)`; its params are threaded into the outer numbering at emission.
|
|
784
|
+
"""
|
|
785
|
+
|
|
786
|
+
__slots__ = ("schema", "name", "column", "where")
|
|
787
|
+
|
|
788
|
+
def __init__(
|
|
789
|
+
self, schema: str, name: str, column: str, where: SnakeCondition | None = None
|
|
790
|
+
) -> None:
|
|
791
|
+
self.schema = schema
|
|
792
|
+
self.name = name
|
|
793
|
+
self.column = column
|
|
794
|
+
self.where = where
|
|
795
|
+
|
|
796
|
+
def paths(self) -> tuple[tuple[str, ...], ...]:
|
|
797
|
+
"""A subquery has its own FROM: it references no columns of the outer query."""
|
|
798
|
+
return ()
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
802
|
+
class SnakeInSubquery(SnakeCondition):
|
|
803
|
+
"""Membership in the result of a scalar subquery: `<value> IN (SELECT ...)`.
|
|
804
|
+
|
|
805
|
+
It is produced by `SnakeValue.in_(subquery)`. `left` is the value from the outer query;
|
|
806
|
+
`subquery` is the subquery. It inherits `& | ~`.
|
|
807
|
+
"""
|
|
808
|
+
|
|
809
|
+
left: SnakeValue[Any]
|
|
810
|
+
subquery: SnakeSubquery[Any]
|