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,293 @@
|
|
|
1
|
+
"""Scalar text and date functions (`LOWER`, `TRIM`, `DATE_TRUNC`, `EXTRACT`...).
|
|
2
|
+
|
|
3
|
+
A single node (`SnakeFunc`) with an agnostic enum instead of one class per function: a new node
|
|
4
|
+
would be one more place to forget something. The dialect translates the names. The TYPE is pinned
|
|
5
|
+
down by the constructors (`snake_length(User.name)` is `SnakeValue[int]`), typing the projection
|
|
6
|
+
with no `Any`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Any, TypeVar
|
|
15
|
+
|
|
16
|
+
from snakeorm.core.exceptions import SnakeUnsupportedFeature
|
|
17
|
+
from snakeorm.expressions.expression import CASTABLE, SnakeCast, SnakeValue
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
V = TypeVar("V")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SnakeFunc(Enum):
|
|
24
|
+
"""Supported scalar functions, with agnostic names that the dialect translates."""
|
|
25
|
+
|
|
26
|
+
LOWER = "lower"
|
|
27
|
+
UPPER = "upper"
|
|
28
|
+
TRIM = "trim"
|
|
29
|
+
LENGTH = "length"
|
|
30
|
+
CONCAT = "concat"
|
|
31
|
+
DATE_TRUNC = "date_trunc"
|
|
32
|
+
EXTRACT = "extract"
|
|
33
|
+
ABS = "abs"
|
|
34
|
+
ROUND = "round"
|
|
35
|
+
SUBSTRING = "substring"
|
|
36
|
+
REPLACE = "replace"
|
|
37
|
+
CEIL = "ceil"
|
|
38
|
+
FLOOR = "floor"
|
|
39
|
+
SQRT = "sqrt"
|
|
40
|
+
POWER = "power"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SnakeDatePart(Enum):
|
|
44
|
+
"""A part of a date, for `DATE_TRUNC` and `EXTRACT`. Standard SQL names."""
|
|
45
|
+
|
|
46
|
+
YEAR = "year"
|
|
47
|
+
QUARTER = "quarter"
|
|
48
|
+
MONTH = "month"
|
|
49
|
+
WEEK = "week"
|
|
50
|
+
DAY = "day"
|
|
51
|
+
HOUR = "hour"
|
|
52
|
+
MINUTE = "minute"
|
|
53
|
+
SECOND = "second"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
57
|
+
class SnakeFuncCall(SnakeValue[T]):
|
|
58
|
+
"""A call to a scalar function: `FUNC(arg, ...)`.
|
|
59
|
+
|
|
60
|
+
It is a VALUE (it gets compared, projected, aggregated). `part` is only used by the date
|
|
61
|
+
functions and travels separately (in SQL, `EXTRACT(year FROM col)` is not a normal argument).
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
func: SnakeFunc
|
|
65
|
+
arguments: tuple[SnakeValue[object] | object, ...]
|
|
66
|
+
part: SnakeDatePart | None = None
|
|
67
|
+
|
|
68
|
+
def paths(self) -> tuple[tuple[str, ...], ...]:
|
|
69
|
+
"""Paths of the arguments that are columns, so the planner sees their JOINs."""
|
|
70
|
+
return tuple(
|
|
71
|
+
path
|
|
72
|
+
for argument in self.arguments
|
|
73
|
+
if isinstance(argument, SnakeValue)
|
|
74
|
+
for path in argument.paths()
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def snake_lower(value: SnakeValue[str]) -> SnakeFuncCall[str]:
|
|
79
|
+
"""`LOWER(value)`."""
|
|
80
|
+
return SnakeFuncCall(SnakeFunc.LOWER, (value,))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def snake_upper(value: SnakeValue[str]) -> SnakeFuncCall[str]:
|
|
84
|
+
"""`UPPER(value)`."""
|
|
85
|
+
return SnakeFuncCall(SnakeFunc.UPPER, (value,))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def snake_trim(value: SnakeValue[str]) -> SnakeFuncCall[str]:
|
|
89
|
+
"""`TRIM(value)`: strips the whitespace off both ends."""
|
|
90
|
+
return SnakeFuncCall(SnakeFunc.TRIM, (value,))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def snake_length(value: SnakeValue[str]) -> SnakeFuncCall[int]:
|
|
94
|
+
"""`LENGTH(value)`: returns an INTEGER, not text. That is the whole point of the typing."""
|
|
95
|
+
return SnakeFuncCall(SnakeFunc.LENGTH, (value,))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def snake_concat(*values: SnakeValue[str] | str) -> SnakeFuncCall[str]:
|
|
99
|
+
"""`CONCAT(a, b, ...)`. Unlike `||`, it ignores NULLs instead of propagating them."""
|
|
100
|
+
return SnakeFuncCall(SnakeFunc.CONCAT, values)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
D = TypeVar("D", bound=datetime)
|
|
104
|
+
"""The datetime a date function was handed, kept instead of flattened.
|
|
105
|
+
|
|
106
|
+
`SnakeValue[T]` is INVARIANT, so a parameter annotated `SnakeValue[datetime]` rejects a
|
|
107
|
+
`SnakeValue[SnakeUtc]` even though `SnakeUtc` is a `datetime` subclass and the call runs fine. That
|
|
108
|
+
left the ORM's own recommended timestamp type unable to use the date functions, with no cast to
|
|
109
|
+
reach for: `CASTABLE` is `int`, `float`, `bool`.
|
|
110
|
+
|
|
111
|
+
Bounded rather than free, because `EXTRACT` over a `str` is a different mistake and one worth
|
|
112
|
+
keeping.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def snake_date_trunc(part: SnakeDatePart, value: SnakeValue[D]) -> SnakeFuncCall[D]:
|
|
117
|
+
"""`DATE_TRUNC('part', value)`: trims the date to the given precision (grouping by month).
|
|
118
|
+
|
|
119
|
+
It gives back the type it was handed. Trimming a timestamp to the month does not change what
|
|
120
|
+
KIND of thing it is, so a `SnakeUtc` column keeps coming out a `SnakeUtc` — the alternative
|
|
121
|
+
would hand back a plain `datetime` and lose the guarantee the column was declared for.
|
|
122
|
+
"""
|
|
123
|
+
return SnakeFuncCall(SnakeFunc.DATE_TRUNC, (value,), part=part)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def snake_extract(part: SnakeDatePart, value: SnakeValue[D]) -> SnakeFuncCall[int]:
|
|
127
|
+
"""`EXTRACT(part FROM value)`: pulls out one component as a NUMBER (the year, the month...)."""
|
|
128
|
+
return SnakeFuncCall(SnakeFunc.EXTRACT, (value,), part=part)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def snake_abs(value: SnakeValue[T]) -> SnakeFuncCall[T]:
|
|
132
|
+
"""`ABS(value)`: keeps the numeric type of its argument."""
|
|
133
|
+
return SnakeFuncCall(SnakeFunc.ABS, (value,))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def snake_round(value: SnakeValue[T], digits: int = 0) -> SnakeFuncCall[T]:
|
|
137
|
+
"""`ROUND(value)`, or `ROUND(value, digits)` when digits are asked for.
|
|
138
|
+
|
|
139
|
+
The zero is NOT passed through, and that is not tidiness. Postgres has `ROUND(double precision)`
|
|
140
|
+
and `ROUND(numeric, int)` but no `ROUND(double precision, int)`, so emitting the second argument
|
|
141
|
+
unasked made `snake_round()` on a float column fail there while working on the other two — for
|
|
142
|
+
the default call, the one nobody would think to test on three engines. `ROUND(x)` means the same
|
|
143
|
+
thing on all three.
|
|
144
|
+
|
|
145
|
+
Asking for digits DOES work on all three now. Postgres has no `ROUND(double precision, int)`,
|
|
146
|
+
so it declares the type its two-argument form wants —`syntax.round_casts_first_argument_to`— and
|
|
147
|
+
the emitter casts. A SHAPE difference, translated, never stopping the plan.
|
|
148
|
+
"""
|
|
149
|
+
if digits:
|
|
150
|
+
return SnakeFuncCall(SnakeFunc.ROUND, (value, digits))
|
|
151
|
+
return SnakeFuncCall(SnakeFunc.ROUND, (value,))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def snake_cast(value: SnakeValue[Any], as_type: type[V]) -> SnakeCast[V]:
|
|
155
|
+
"""`CAST(value AS <type>)`: an EXPLICIT change of type, named by whoever writes it.
|
|
156
|
+
|
|
157
|
+
THE ORM DOES NOT PROMOTE, and this is the door that makes that stance liveable. The arithmetic
|
|
158
|
+
operators carry one single `T` for both operands and the result, so `Stock.reserved /
|
|
159
|
+
Stock.on_hand` is an `int` — which is what SQL does, and sometimes what you want. When it is not,
|
|
160
|
+
you say so:
|
|
161
|
+
|
|
162
|
+
snake_cast(Stock.reserved, float) / Stock.on_hand -> SnakeArith[float]
|
|
163
|
+
|
|
164
|
+
`as_type` is what the RESULT is typed as, so the conversion travels through the type system
|
|
165
|
+
instead of around it. The SQL name of that type is the dialect's business: measured, SQLite needs
|
|
166
|
+
`REAL` where `NUMERIC` would answer 0.
|
|
167
|
+
|
|
168
|
+
The whitelist refuses by name at the CALL SITE rather than at emission, which is where a refusal
|
|
169
|
+
is worth something: the alternative is SQL the engine rejects and a driver explaining a decision
|
|
170
|
+
this ORM made.
|
|
171
|
+
"""
|
|
172
|
+
if as_type not in CASTABLE:
|
|
173
|
+
raise SnakeUnsupportedFeature(
|
|
174
|
+
f"There is no cast to {getattr(as_type, '__name__', as_type)!r}: an explicit cast "
|
|
175
|
+
f"targets one of {', '.join(t.__name__ for t in CASTABLE)}. This is a whitelist and not "
|
|
176
|
+
f"an oversight — a type the dialects cannot spell would be emitted as SQL the engine "
|
|
177
|
+
f"rejects, and the complaint would arrive from the driver rather than from here."
|
|
178
|
+
)
|
|
179
|
+
return SnakeCast(source=value, as_type=as_type)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# What a date can be shifted by. A WHITELIST built by removing from `SnakeDatePart`, not a second
|
|
183
|
+
# enum: a quarter is a real thing to TRUNCATE to and not a thing any of the three engines spells as
|
|
184
|
+
# an interval, so it is refused by name rather than silently translated into three months.
|
|
185
|
+
SHIFTABLE: tuple[SnakeDatePart, ...] = (
|
|
186
|
+
SnakeDatePart.YEAR,
|
|
187
|
+
SnakeDatePart.MONTH,
|
|
188
|
+
SnakeDatePart.WEEK,
|
|
189
|
+
SnakeDatePart.DAY,
|
|
190
|
+
SnakeDatePart.HOUR,
|
|
191
|
+
SnakeDatePart.MINUTE,
|
|
192
|
+
SnakeDatePart.SECOND,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
197
|
+
class SnakeDateShift(SnakeValue[T]):
|
|
198
|
+
"""Moving a date or a timestamp by a fixed amount: `placed_on + 30 days`.
|
|
199
|
+
|
|
200
|
+
ONE node for both directions: subtracting is adding a negative `amount`, which the three engines
|
|
201
|
+
accept and which keeps the sign in the VALUE instead of in a second node. `T` is the type of the
|
|
202
|
+
SOURCE — shifting a date gives a date.
|
|
203
|
+
|
|
204
|
+
The three spellings share nothing (`+ INTERVAL`, `DATE_ADD`, a modifier string), so the SQL is
|
|
205
|
+
the dialect's business. It is the clearest case in the whole ORM for that seam.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
value: SnakeValue[Any]
|
|
209
|
+
amount: int
|
|
210
|
+
unit: SnakeDatePart
|
|
211
|
+
|
|
212
|
+
def paths(self) -> tuple[tuple[str, ...], ...]:
|
|
213
|
+
"""What it shifts: the amount is a literal and contributes no navigation of its own."""
|
|
214
|
+
return self.value.paths()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _shift(value: SnakeValue[T], amount: int, unit: SnakeDatePart) -> SnakeDateShift[T]:
|
|
218
|
+
"""Shared guard for both directions: the unit has to be one a date can actually move by."""
|
|
219
|
+
if unit not in SHIFTABLE:
|
|
220
|
+
raise SnakeUnsupportedFeature(
|
|
221
|
+
f"A date cannot be shifted by {unit.name}: no engine spells it as an interval. "
|
|
222
|
+
f"`SnakeDatePart` is shared with DATE_TRUNC and EXTRACT, where {unit.name} is real. "
|
|
223
|
+
f"Shift by one of {', '.join(part.name for part in SHIFTABLE)} — a quarter is three "
|
|
224
|
+
f"MONTHs, and writing that out is the caller's decision rather than a silent rewrite."
|
|
225
|
+
)
|
|
226
|
+
return SnakeDateShift(value=value, amount=amount, unit=unit)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def snake_date_add(
|
|
230
|
+
value: SnakeValue[T], amount: int, unit: SnakeDatePart
|
|
231
|
+
) -> SnakeDateShift[T]:
|
|
232
|
+
"""Moves a date FORWARD: `snake_date_add(Order.placed_on, 30, SnakeDatePart.DAY)`.
|
|
233
|
+
|
|
234
|
+
CALENDAR UNITS ARE NOT PORTABLE and the ORM says so rather than hiding it. Measured, `2026-01-31`
|
|
235
|
+
plus one month is `2026-02-28` on PostgreSQL and MySQL —both clamp— and `2026-03-03` on SQLite,
|
|
236
|
+
which overflows. That divergence is declared as `Cap.CALENDAR_INTERVAL`, so the session warns
|
|
237
|
+
once instead of letting whichever engine the developer happens to run be the one that gets
|
|
238
|
+
tested. DAY, HOUR, MINUTE, SECOND and WEEK are a fixed span and identical on all three.
|
|
239
|
+
|
|
240
|
+
Emulating the clamp was the alternative, and it is the wrong one: the ORM would be computing
|
|
241
|
+
dates in Python behind an expression that claims to be SQL.
|
|
242
|
+
"""
|
|
243
|
+
return _shift(value, amount, unit)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def snake_date_sub(
|
|
247
|
+
value: SnakeValue[T], amount: int, unit: SnakeDatePart
|
|
248
|
+
) -> SnakeDateShift[T]:
|
|
249
|
+
"""Moves a date BACKWARD. Same node with the sign flipped, because that is all it is."""
|
|
250
|
+
return _shift(value, -amount, unit)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def snake_substring(
|
|
254
|
+
value: SnakeValue[str], start: int, length: int
|
|
255
|
+
) -> SnakeFuncCall[str]:
|
|
256
|
+
"""`SUBSTRING(value, start, length)`: a slice of text, counted from ONE like SQL does.
|
|
257
|
+
|
|
258
|
+
The bounds are VALUES and travel as parameters, which is not obvious enough to leave unsaid: a
|
|
259
|
+
slice computed from user input with the numbers written into the statement is the shape an
|
|
260
|
+
injection takes when nobody is looking at strings.
|
|
261
|
+
"""
|
|
262
|
+
return SnakeFuncCall(SnakeFunc.SUBSTRING, (value, start, length))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def snake_replace(value: SnakeValue[str], old: str, new: str) -> SnakeFuncCall[str]:
|
|
266
|
+
"""`REPLACE(value, old, new)`: every occurrence, not the first. Both strings are parameters."""
|
|
267
|
+
return SnakeFuncCall(SnakeFunc.REPLACE, (value, old, new))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def snake_ceil(value: SnakeValue[T]) -> SnakeFuncCall[T]:
|
|
271
|
+
"""`CEIL(value)`: rounds UP, keeping the type of its argument.
|
|
272
|
+
|
|
273
|
+
`T -> T` and not `-> int`, and that was measured rather than assumed: `CEIL(1.2)` answers `2` on
|
|
274
|
+
PostgreSQL and MySQL and `2.0` on SQLite. Declaring `int` would be false on one engine of three,
|
|
275
|
+
which is the exact family of bug the integer-division work removed. Keeping the argument's type
|
|
276
|
+
is true everywhere — a float in, a float back.
|
|
277
|
+
"""
|
|
278
|
+
return SnakeFuncCall(SnakeFunc.CEIL, (value,))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def snake_floor(value: SnakeValue[T]) -> SnakeFuncCall[T]:
|
|
282
|
+
"""`FLOOR(value)`: rounds DOWN, keeping the type of its argument. Same measurement as `CEIL`."""
|
|
283
|
+
return SnakeFuncCall(SnakeFunc.FLOOR, (value,))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def snake_sqrt(value: SnakeValue[Any]) -> SnakeFuncCall[float]:
|
|
287
|
+
"""`SQRT(value)`: always a float. Measured `double precision` on PostgreSQL and a real on SQLite."""
|
|
288
|
+
return SnakeFuncCall(SnakeFunc.SQRT, (value,))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def snake_power(value: SnakeValue[Any], exponent: float) -> SnakeFuncCall[float]:
|
|
292
|
+
"""`POWER(value, exponent)`: always a float, and the exponent travels as a parameter."""
|
|
293
|
+
return SnakeFuncCall(SnakeFunc.POWER, (value, exponent))
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Window functions: `<func> OVER (PARTITION BY ... ORDER BY ...)`.
|
|
2
|
+
|
|
3
|
+
They compute one value per row by looking at NEIGHBOURING rows without collapsing them (unlike a
|
|
4
|
+
GROUP BY). Without windows, that gets solved with a correlated subquery per row (the N+1 of SQL).
|
|
5
|
+
`SnakeWindow` is a plain `SnakeValue` (it gets projected and dispatched by type). It is not allowed
|
|
6
|
+
in WHERE/GROUP BY/HAVING (SQL evaluates them afterwards): the condition emitter watches for that.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, replace
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any, TypeVar
|
|
14
|
+
|
|
15
|
+
from snakeorm.core.exceptions import SnakeUnsupportedFeature, SnakeValueError
|
|
16
|
+
|
|
17
|
+
from snakeorm.expressions.expression import (
|
|
18
|
+
SnakeAggregate,
|
|
19
|
+
SnakeArith,
|
|
20
|
+
SnakeOrder,
|
|
21
|
+
SnakeValue,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
T = TypeVar("T")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# `eq=False` is NOT optional: `SnakeValue.__eq__` returns `SnakeCondition`, and a dataclass `__eq__`
|
|
28
|
+
# would return `bool`, breaking `window == value`. Same reason in `SnakeAggregate` and `SnakeOrder`.
|
|
29
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
30
|
+
class SnakeWindow(SnakeValue[T]):
|
|
31
|
+
"""`<func>(<arg>...) OVER (PARTITION BY ... ORDER BY ...)`.
|
|
32
|
+
|
|
33
|
+
`func` is the SQL NAME, kept as a string because two families live side by side (ranking, and
|
|
34
|
+
aggregates used as windows, which already have their own enum): a union enum would lie.
|
|
35
|
+
`extra_args` are literals of the function (the offset of `LAG`/`LEAD`), parametrised like every
|
|
36
|
+
other value.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
func: str
|
|
40
|
+
arg: SnakeValue[Any] | None = None
|
|
41
|
+
extra_args: tuple[object, ...] = ()
|
|
42
|
+
partition_by: tuple[SnakeValue[Any], ...] = ()
|
|
43
|
+
order_by: tuple[SnakeOrder, ...] = ()
|
|
44
|
+
frame: SnakeFrame | None = None
|
|
45
|
+
|
|
46
|
+
def over(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
partition_by: tuple[SnakeValue[Any], ...] | list[SnakeValue[Any]] = (),
|
|
50
|
+
order_by: tuple[SnakeOrder, ...] | list[SnakeOrder] = (),
|
|
51
|
+
frame: SnakeFrame | None = None,
|
|
52
|
+
) -> SnakeWindow[T]:
|
|
53
|
+
"""The same function with its window defined. Without `over(...)`, the window is ALL the rows.
|
|
54
|
+
|
|
55
|
+
`frame` is what turns a running total into a MOVING one. Without it the default frame runs
|
|
56
|
+
from the start of the partition to the current row, which is one useful answer out of many:
|
|
57
|
+
a trailing average, a centred window or a look-ahead all need the frame said out loud.
|
|
58
|
+
|
|
59
|
+
A FRAME WITHOUT AN ORDER IS REFUSED. `6 PRECEDING` has to be preceding IN something, and with
|
|
60
|
+
no `ORDER BY` the engine picks an order of its own — so the same query answers differently on
|
|
61
|
+
two runs with nothing to show for it. That is the shape of failure this ORM does not ship.
|
|
62
|
+
|
|
63
|
+
It returns a NEW node (immutable AST): reusing a stored window holds no surprises.
|
|
64
|
+
"""
|
|
65
|
+
if frame is not None and not order_by:
|
|
66
|
+
raise SnakeUnsupportedFeature(
|
|
67
|
+
"A window frame needs an order to be measured against: pass order_by= alongside "
|
|
68
|
+
"frame=. Without it, '6 PRECEDING' is six rows before whichever row the engine "
|
|
69
|
+
"happened to put first, which can change between two runs of the same query."
|
|
70
|
+
)
|
|
71
|
+
return replace(
|
|
72
|
+
self,
|
|
73
|
+
partition_by=tuple(partition_by),
|
|
74
|
+
order_by=tuple(order_by),
|
|
75
|
+
frame=frame,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def paths(self) -> tuple[tuple[str, ...], ...]:
|
|
79
|
+
"""Paths of everything the window touches (argument, partition, order): all three plan JOINs."""
|
|
80
|
+
paths: list[tuple[str, ...]] = []
|
|
81
|
+
if self.arg is not None:
|
|
82
|
+
paths.extend(self.arg.paths())
|
|
83
|
+
for value in self.partition_by:
|
|
84
|
+
paths.extend(value.paths())
|
|
85
|
+
for key in self.order_by:
|
|
86
|
+
paths.extend(key.expr.paths())
|
|
87
|
+
return tuple(paths)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _rank_window(name: str) -> SnakeWindow[int]:
|
|
91
|
+
"""Ranking function with no argument: it returns one integer per row."""
|
|
92
|
+
return SnakeWindow[int](func=name)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def row_number() -> SnakeWindow[int]:
|
|
96
|
+
"""`ROW_NUMBER()`: position within the partition, with no ties (1, 2, 3, 4...)."""
|
|
97
|
+
return _rank_window("ROW_NUMBER")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def rank() -> SnakeWindow[int]:
|
|
101
|
+
"""`RANK()`: position with ties, SKIPPING the gaps (1, 2, 2, 4...)."""
|
|
102
|
+
return _rank_window("RANK")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def dense_rank() -> SnakeWindow[int]:
|
|
106
|
+
"""`DENSE_RANK()`: position with ties and NO gaps (1, 2, 2, 3...)."""
|
|
107
|
+
return _rank_window("DENSE_RANK")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def lag(value: SnakeValue[T], offset: int = 1) -> SnakeWindow[T | None]:
|
|
111
|
+
"""`LAG(value, n)`: the value of the row n positions BEFORE in the window.
|
|
112
|
+
|
|
113
|
+
Optional: on the first n rows there is no previous one and the result is NULL.
|
|
114
|
+
"""
|
|
115
|
+
return SnakeWindow[T | None](func="LAG", arg=value, extra_args=(offset,))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def lead(value: SnakeValue[T], offset: int = 1) -> SnakeWindow[T | None]:
|
|
119
|
+
"""`LEAD(value, n)`: the value of the row n positions AFTER. Optional for the same reason."""
|
|
120
|
+
return SnakeWindow[T | None](func="LEAD", arg=value, extra_args=(offset,))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def as_window(aggregate: SnakeAggregate[T]) -> SnakeWindow[T]:
|
|
124
|
+
"""Turns an aggregate into a window: `SUM(x)` -> `SUM(x) OVER (...)`.
|
|
125
|
+
|
|
126
|
+
With GROUP BY it returns one row per group; as a window, every row with its running total. Used by `SnakeAggregate.over(...)`.
|
|
127
|
+
"""
|
|
128
|
+
return SnakeWindow[T](func=aggregate.func.value, arg=aggregate.arg)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def has_window(value: object) -> bool:
|
|
132
|
+
"""Does this value contain a window function, however nested?
|
|
133
|
+
|
|
134
|
+
It walks the nodes that WRAP another value (arithmetic, aggregate); the rest are leaves. It exists
|
|
135
|
+
to REJECT the window where SQL forbids it: `WHERE row_number() + 1 > 3` is just as impossible as it is without the `+ 1`.
|
|
136
|
+
"""
|
|
137
|
+
if isinstance(value, SnakeWindow):
|
|
138
|
+
return True
|
|
139
|
+
if isinstance(value, SnakeArith):
|
|
140
|
+
return has_window(value.left) or has_window(value.right)
|
|
141
|
+
if isinstance(value, SnakeAggregate):
|
|
142
|
+
return value.arg is not None and has_window(value.arg)
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class SnakeFrameMode(Enum):
|
|
147
|
+
"""`ROWS` counts ROWS, `RANGE` counts VALUES. With ties they answer differently.
|
|
148
|
+
|
|
149
|
+
Offering only `ROWS` would be the smaller API and the wrong one: somebody ordering by a day that
|
|
150
|
+
has several readings in it means RANGE, and handing them ROWS is a wrong answer with no error.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
ROWS = "ROWS"
|
|
154
|
+
RANGE = "RANGE"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True, slots=True)
|
|
158
|
+
class SnakeFrameBound:
|
|
159
|
+
"""One end of a frame. `offset=None` is UNBOUNDED; `offset=0` is the current row.
|
|
160
|
+
|
|
161
|
+
The offset reaches the STATEMENT rather than `params`, and that is measured rather than lazy:
|
|
162
|
+
PostgreSQL and SQLite take a placeholder in a bound and MariaDB rejects it outright, so the only
|
|
163
|
+
portable spelling is the literal. It is safe for the same reason the JSON key path is — the value
|
|
164
|
+
is an `int` from Python's own type system and it is checked when the bound is BUILT, before any
|
|
165
|
+
SQL exists. An integer carries no injection.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
offset: int | None
|
|
169
|
+
following: bool
|
|
170
|
+
|
|
171
|
+
def sql(self) -> str:
|
|
172
|
+
"""The bound as standard SQL. The three engines spell this identically (measured)."""
|
|
173
|
+
direction = "FOLLOWING" if self.following else "PRECEDING"
|
|
174
|
+
if self.offset is None:
|
|
175
|
+
return f"UNBOUNDED {direction}"
|
|
176
|
+
if self.offset == 0:
|
|
177
|
+
return "CURRENT ROW"
|
|
178
|
+
return f"{self.offset} {direction}"
|
|
179
|
+
|
|
180
|
+
def rank(self) -> tuple[int, int]:
|
|
181
|
+
"""Where this bound sits on the timeline, so a frame can check it does not run backwards."""
|
|
182
|
+
if self.offset is None:
|
|
183
|
+
return (4, 0) if self.following else (0, 0)
|
|
184
|
+
if self.offset == 0:
|
|
185
|
+
return (2, 0)
|
|
186
|
+
return (3, self.offset) if self.following else (1, -self.offset)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True, slots=True)
|
|
190
|
+
class SnakeFrame:
|
|
191
|
+
"""`<mode> BETWEEN <start> AND <end>`: which neighbouring rows the function may look at."""
|
|
192
|
+
|
|
193
|
+
mode: SnakeFrameMode
|
|
194
|
+
start: SnakeFrameBound
|
|
195
|
+
end: SnakeFrameBound
|
|
196
|
+
|
|
197
|
+
def sql(self) -> str:
|
|
198
|
+
"""The whole clause. No dialect involved: it is the same on the three engines."""
|
|
199
|
+
return f"{self.mode.value} BETWEEN {self.start.sql()} AND {self.end.sql()}"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# The current row, which is the commonest end of a trailing window. A constant rather than a
|
|
203
|
+
# function because it takes no argument and there is exactly one of it.
|
|
204
|
+
SNAKE_CURRENT_ROW = SnakeFrameBound(offset=0, following=False)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _bound(rows: int | None, following: bool) -> SnakeFrameBound:
|
|
208
|
+
"""Shared guard. Refusing here is what lets the number be interpolated later."""
|
|
209
|
+
if rows is not None and rows < 0:
|
|
210
|
+
raise SnakeValueError(
|
|
211
|
+
f"A frame bound cannot be negative (got {rows}): the DIRECTION is the function you "
|
|
212
|
+
f"call, so the distance is always counted forwards from here. For {abs(rows)} rows the "
|
|
213
|
+
f"other way, call the other one."
|
|
214
|
+
)
|
|
215
|
+
return SnakeFrameBound(offset=rows, following=following)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def snake_preceding(rows: int | None = None) -> SnakeFrameBound:
|
|
219
|
+
"""`n PRECEDING`, or `UNBOUNDED PRECEDING` with no argument."""
|
|
220
|
+
return _bound(rows, following=False)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def snake_following(rows: int | None = None) -> SnakeFrameBound:
|
|
224
|
+
"""`n FOLLOWING`, or `UNBOUNDED FOLLOWING` with no argument."""
|
|
225
|
+
return _bound(rows, following=True)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _frame(
|
|
229
|
+
mode: SnakeFrameMode, start: SnakeFrameBound, end: SnakeFrameBound
|
|
230
|
+
) -> SnakeFrame:
|
|
231
|
+
"""Shared guard: a frame that ends before it starts is empty by construction."""
|
|
232
|
+
if start.rank() > end.rank():
|
|
233
|
+
raise SnakeValueError(
|
|
234
|
+
f"This frame starts after it ends ({start.sql()} .. {end.sql()}), so it can never "
|
|
235
|
+
f"contain a row. The engines reject it too — it is caught here so the complaint names "
|
|
236
|
+
f"the frame instead of arriving from the driver."
|
|
237
|
+
)
|
|
238
|
+
return SnakeFrame(mode=mode, start=start, end=end)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def snake_rows(start: SnakeFrameBound, end: SnakeFrameBound) -> SnakeFrame:
|
|
242
|
+
"""`ROWS BETWEEN start AND end`: counts ROWS, so ties are separate rows."""
|
|
243
|
+
return _frame(SnakeFrameMode.ROWS, start, end)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def snake_range(start: SnakeFrameBound, end: SnakeFrameBound) -> SnakeFrame:
|
|
247
|
+
"""`RANGE BETWEEN start AND end`: counts VALUES, so tied rows come in together."""
|
|
248
|
+
return _frame(SnakeFrameMode.RANGE, start, end)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""SnakeORM field descriptors and their field specifiers."""
|
|
2
|
+
|
|
3
|
+
from snakeorm.fields.check import snake_check as snake_check
|
|
4
|
+
from snakeorm.fields.check import snake_checks as snake_checks
|
|
5
|
+
from snakeorm.fields.check import snake_indexes as snake_indexes
|
|
6
|
+
from snakeorm.fields.column import MISSING as MISSING
|
|
7
|
+
from snakeorm.fields.column import SnakeColumn as SnakeColumn
|
|
8
|
+
from snakeorm.fields.column import snake_auto as snake_auto
|
|
9
|
+
from snakeorm.fields.column import snake_column as snake_column
|
|
10
|
+
from snakeorm.fields.column import snake_discriminator as snake_discriminator
|
|
11
|
+
from snakeorm.fields.enum import snake_enum as snake_enum
|
|
12
|
+
from snakeorm.fields.index import SnakeIndex as SnakeIndex
|
|
13
|
+
from snakeorm.fields.relationship import SnakeCollection as SnakeCollection
|
|
14
|
+
from snakeorm.fields.relationship import SnakePrefetch as SnakePrefetch
|
|
15
|
+
from snakeorm.fields.relationship import SnakePrefetchHop as SnakePrefetchHop
|
|
16
|
+
from snakeorm.core.exceptions import (
|
|
17
|
+
SnakeRelationshipNotLoaded as SnakeRelationshipNotLoaded,
|
|
18
|
+
)
|
|
19
|
+
from snakeorm.fields.relationship import SnakePathProxy as SnakePathProxy
|
|
20
|
+
from snakeorm.fields.relationship import SnakeToMany as SnakeToMany
|
|
21
|
+
from snakeorm.fields.relationship import path_of as path_of
|
|
22
|
+
from snakeorm.fields.relationship import SnakeToOne as SnakeToOne
|
|
23
|
+
from snakeorm.fields.relationship import snake_to_many as snake_to_many
|
|
24
|
+
from snakeorm.fields.relationship import snake_to_many_through as snake_to_many_through
|
|
25
|
+
from snakeorm.fields.relationship import snake_to_one as snake_to_one
|
|
26
|
+
from snakeorm.fields.typed import snake_datetime as snake_datetime
|
|
27
|
+
from snakeorm.fields.typed import snake_datetimetz as snake_datetimetz
|
|
28
|
+
from snakeorm.fields.typed import snake_decimal as snake_decimal
|
|
29
|
+
from snakeorm.fields.typed import snake_int as snake_int
|
|
30
|
+
from snakeorm.fields.typed import snake_json as snake_json
|
|
31
|
+
from snakeorm.fields.typed import snake_str as snake_str
|
|
32
|
+
from snakeorm.fields.typed import snake_float as snake_float
|
|
33
|
+
from snakeorm.fields.typed import snake_time as snake_time
|
|
34
|
+
from snakeorm.fields.typed import snake_timetz as snake_timetz
|
|
35
|
+
|
|
36
|
+
# CANONICAL reference of the field specifiers. It CANNOT be passed to `@dataclass_transform`:
|
|
37
|
+
# PEP 681 demands a literal tuple at each site and mypy rejects it; the language imposes the
|
|
38
|
+
# duplication across the FIVE sites — `SnakeModel` and `SnakeView` in `model.py`, and the
|
|
39
|
+
# `@snake_model`, `@snake_view` and `@snake_db_first` decorators.
|
|
40
|
+
#
|
|
41
|
+
# `test/typing/test_field_specifiers.py` checks that they all match, and it DISCOVERS them through
|
|
42
|
+
# the `__dataclass_transform__` PEP 681 leaves at runtime instead of listing them: forgetting a
|
|
43
|
+
# site silently stops typing that path — with every test still green — so the count is exactly the
|
|
44
|
+
# thing that must not be remembered.
|
|
45
|
+
#
|
|
46
|
+
# The three relation specifiers ARE here, and NOT because relations are constructor arguments —
|
|
47
|
+
# they are not. `snake_to_one`, `snake_to_many` and `snake_to_many_through` each declare
|
|
48
|
+
# `init: Literal[False]`, and a checker only reads that signal off a call it recognises as a field
|
|
49
|
+
# specifier, i.e. one listed here. Leaving one out makes the checker read it as "a field with a
|
|
50
|
+
# default" and bless a line the runtime rejects with `TypeError`.
|
|
51
|
+
SNAKE_FIELD_SPECIFIERS = (
|
|
52
|
+
snake_column,
|
|
53
|
+
snake_auto,
|
|
54
|
+
snake_enum,
|
|
55
|
+
snake_int,
|
|
56
|
+
snake_str,
|
|
57
|
+
snake_decimal,
|
|
58
|
+
snake_datetime,
|
|
59
|
+
snake_datetimetz,
|
|
60
|
+
snake_float,
|
|
61
|
+
snake_time,
|
|
62
|
+
snake_timetz,
|
|
63
|
+
snake_json,
|
|
64
|
+
snake_to_one,
|
|
65
|
+
snake_to_many,
|
|
66
|
+
snake_to_many_through,
|
|
67
|
+
snake_discriminator,
|
|
68
|
+
)
|
snakeorm/fields/check.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Declaring CHECK constraints: `snake_check(...)` in the body of the model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
|
|
7
|
+
from snakeorm.core.exceptions import SnakeModelError
|
|
8
|
+
from snakeorm.expressions import SnakeCondition
|
|
9
|
+
from snakeorm.fields.index import SnakeIndex
|
|
10
|
+
from snakeorm.metadata import SnakeIndexInfo
|
|
11
|
+
from snakeorm.fields.relationship import _registry_of
|
|
12
|
+
from snakeorm.metadata.check import SnakeCheckInfo
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def snake_check(
|
|
16
|
+
condition: SnakeCondition, *, name: str | None = None
|
|
17
|
+
) -> SnakeCheckInfo:
|
|
18
|
+
"""Declare a typed CHECK constraint: the condition is the same `SnakeCondition` as
|
|
19
|
+
`.filter()`.
|
|
20
|
+
|
|
21
|
+
It gets validated here, at declaration time, and not when generating the migration: a
|
|
22
|
+
condition with a subquery or an EXISTS does not fit in a CHECK, and the useful place to find
|
|
23
|
+
out is where you wrote it.
|
|
24
|
+
"""
|
|
25
|
+
info = SnakeCheckInfo(condition=condition, name=name)
|
|
26
|
+
# A dry render so the rejection fires now; `render_condition` is the only one that knows which
|
|
27
|
+
# nodes are reconstructible (duplicating that criterion would mean having two truths).
|
|
28
|
+
from snakeorm.migration.render import render_condition
|
|
29
|
+
|
|
30
|
+
render_condition(condition)
|
|
31
|
+
return info
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def snake_checks(model: type, *checks: SnakeCheckInfo) -> None:
|
|
35
|
+
"""Add CHECK constraints to an ALREADY decorated model, referencing its TYPED columns.
|
|
36
|
+
|
|
37
|
+
snake_checks(Person, snake_check(Person.age >= 18, name="adult"))
|
|
38
|
+
|
|
39
|
+
Outside the class body (not inside it like `SnakeIndexes`) because a condition is built on the
|
|
40
|
+
spot and inside the body `age` is the raw descriptor, with `__set_name__` not yet run: it does
|
|
41
|
+
not know its name yet. Once decorated, `Person.age` is CLASS access → `SnakeExpr[int]`, which
|
|
42
|
+
preserves the typing.
|
|
43
|
+
"""
|
|
44
|
+
reg = _registry_of(model)
|
|
45
|
+
table = reg.table_of(model)
|
|
46
|
+
if table is None:
|
|
47
|
+
raise SnakeModelError(
|
|
48
|
+
f"{model.__name__} is not a @snake_model: declare the model before adding checks to it."
|
|
49
|
+
)
|
|
50
|
+
reg.register(model, replace(table, checks=(*table.checks, *checks)))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def snake_indexes(model: type, *indexes: SnakeIndex) -> None:
|
|
54
|
+
"""Add indexes to an ALREADY decorated model, referencing its TYPED columns.
|
|
55
|
+
|
|
56
|
+
snake_indexes(Customer, SnakeIndex(Customer.name, unique=True, where=Customer.closed_at.is_null()))
|
|
57
|
+
|
|
58
|
+
Same reason as `snake_checks`: a PARTIAL index needs a condition, which is built on the spot
|
|
59
|
+
and does not fit in the `SnakeIndexes` of the body. It re-registers the model instead of
|
|
60
|
+
mutating an attribute (assigning `SnakeIndexes` after the decorator never reaches the graph:
|
|
61
|
+
it has already compiled).
|
|
62
|
+
"""
|
|
63
|
+
reg = _registry_of(model)
|
|
64
|
+
table = reg.table_of(model)
|
|
65
|
+
if table is None:
|
|
66
|
+
raise SnakeModelError(
|
|
67
|
+
f"{model.__name__} is not a @snake_model: declare the model before adding indexes to it."
|
|
68
|
+
)
|
|
69
|
+
compiled = tuple(
|
|
70
|
+
SnakeIndexInfo(
|
|
71
|
+
columns=index.column_names(),
|
|
72
|
+
unique=index.unique,
|
|
73
|
+
name=index.name,
|
|
74
|
+
where=index.where,
|
|
75
|
+
method=index.method,
|
|
76
|
+
)
|
|
77
|
+
for index in indexes
|
|
78
|
+
)
|
|
79
|
+
reg.register(model, replace(table, indexes=(*table.indexes, *compiled)))
|