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,71 @@
|
|
|
1
|
+
"""Public aggregate constructors (`count`, `sum_`, `avg`, `min_`, `max_`): a typed API over `SnakeAggregate`.
|
|
2
|
+
|
|
3
|
+
The TYPE is what adds the value: `select(q, col, count())` types the tuple with no `Any`. Nullability
|
|
4
|
+
(straight from SQL): `COUNT` over zero rows is 0 (never NULL) -> `int`; `SUM/AVG/MIN/MAX` over zero
|
|
5
|
+
rows is NULL -> `| None` (a type that promises `int` and hands back `None` is worse than no type at
|
|
6
|
+
all). The ones that clash with a builtin carry a trailing underscore (`sum_`, `min_`, `max_`).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, TypeVar
|
|
12
|
+
|
|
13
|
+
from snakeorm.expressions.expression import (
|
|
14
|
+
SnakeAggFunc,
|
|
15
|
+
SnakeAggregate,
|
|
16
|
+
SnakeOrder,
|
|
17
|
+
SnakeStringAgg,
|
|
18
|
+
SnakeValue,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
T = TypeVar("T")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def count(
|
|
25
|
+
arg: SnakeValue[Any] | None = None, *, distinct: bool = False
|
|
26
|
+
) -> SnakeAggregate[int]:
|
|
27
|
+
"""`COUNT(*)` with no argument; `COUNT(col)` or `COUNT(DISTINCT col)` with one. Always `int`."""
|
|
28
|
+
return SnakeAggregate(SnakeAggFunc.COUNT, arg, distinct)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def sum_(arg: SnakeValue[T]) -> SnakeAggregate[T | None]:
|
|
32
|
+
"""`SUM(col)`. Preserves the column's type and adds `None`: with no rows to add up, it is NULL."""
|
|
33
|
+
return SnakeAggregate(SnakeAggFunc.SUM, arg)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def avg(arg: SnakeValue[Any]) -> SnakeAggregate[float | None]:
|
|
37
|
+
"""`AVG(col)`. The average is a real number (`float`), and it is NULL if there are no rows to average."""
|
|
38
|
+
return SnakeAggregate(SnakeAggFunc.AVG, arg)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def min_(arg: SnakeValue[T]) -> SnakeAggregate[T | None]:
|
|
42
|
+
"""`MIN(col)`. Preserves the column's type and adds `None`: with no rows, it is NULL."""
|
|
43
|
+
return SnakeAggregate(SnakeAggFunc.MIN, arg)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def max_(arg: SnakeValue[T]) -> SnakeAggregate[T | None]:
|
|
47
|
+
"""`MAX(col)`. Preserves the column's type and adds `None`: with no rows, it is NULL."""
|
|
48
|
+
return SnakeAggregate(SnakeAggFunc.MAX, arg)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def string_agg(
|
|
52
|
+
value: SnakeValue[Any],
|
|
53
|
+
separator: str = ",",
|
|
54
|
+
*,
|
|
55
|
+
order_by: tuple[SnakeOrder, ...] | list[SnakeOrder] = (),
|
|
56
|
+
) -> SnakeStringAgg[str | None]:
|
|
57
|
+
"""Joins a group's values into one string: `string_agg(Tag.name, ", ", order_by=[...])`.
|
|
58
|
+
|
|
59
|
+
`str | None` because an aggregate over no rows is NULL on every engine, exactly like `sum_`.
|
|
60
|
+
|
|
61
|
+
THE SEPARATOR DOES NOT TRAVEL THE SAME WAY ON THE THREE, which is why the dialect writes this.
|
|
62
|
+
On PostgreSQL and SQLite it is a normal argument and goes in `params`; on MySQL it is the
|
|
63
|
+
`SEPARATOR` keyword, which was measured to reject a placeholder, so that dialect escapes it
|
|
64
|
+
through the same `literal()` the DDL defaults use.
|
|
65
|
+
|
|
66
|
+
`order_by` is worth passing whenever a person reads the result: without it the order is the
|
|
67
|
+
engine's business and can differ between two runs of the same query.
|
|
68
|
+
"""
|
|
69
|
+
return SnakeStringAgg[str | None](
|
|
70
|
+
arg=value, separator=separator, order_by=tuple(order_by)
|
|
71
|
+
)
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""The typed composite `IN`: a tuple of columns compared against a set of tuples of values.
|
|
2
|
+
|
|
3
|
+
`(warehouse_id, product_id) IN ((7, 3), (9, 1))` is standard SQL and all three engines run it, and
|
|
4
|
+
until this module the only way to ask for it was one `in_()` per column — which is the CARTESIAN
|
|
5
|
+
PRODUCT and also answers `(7, 1)` and `(9, 3)`. On a small fixture the two are indistinguishable,
|
|
6
|
+
which is what makes the gap worth closing rather than documenting.
|
|
7
|
+
|
|
8
|
+
WHY THE COLUMN AND ITS VALUE ARE PAIRED, and not positional (`snake_tuple(a, b).in_([(7, 3)])`): a
|
|
9
|
+
positional tuple gives the checker nothing to line up, so with two columns of the same type a
|
|
10
|
+
swapped pair passes mypy AND pyright and comes back with the wrong rows in silence. It also needs
|
|
11
|
+
one overload per arity, so it has a CEILING, and the identifying relationships this ORM is built
|
|
12
|
+
for widen their key one level at a time. Pairing each column with its own value in `set()` lets the
|
|
13
|
+
checker bind the type per slot, at any width, with no overloads at all.
|
|
14
|
+
|
|
15
|
+
WHAT THE CHECKER STILL CANNOT DO is COUNT. A two-column key and a three-column one are both
|
|
16
|
+
`SnakeKey[M]`, so the shape of the list is checked here, at build time, and it raises. A warning
|
|
17
|
+
would be followed by wrong rows or by SQL no engine parses.
|
|
18
|
+
|
|
19
|
+
This module builds a `SnakeTupleIn` and stops there: the emitter for that node already exists, and
|
|
20
|
+
already falls back to the equivalent OR-of-ANDs on a dialect that declares no `Cap.ROW_CONSTRUCTOR`.
|
|
21
|
+
A second node would have needed its own copy of that fallback.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Iterable
|
|
27
|
+
from typing import Any, Generic, Self, TypeAlias, TypeVar
|
|
28
|
+
|
|
29
|
+
from snakeorm.core.exceptions import SnakeEmitError
|
|
30
|
+
from snakeorm.expressions.conditional import SnakeCase, SnakeCoalesce, SnakeNullIf
|
|
31
|
+
from snakeorm.expressions.expression import (
|
|
32
|
+
SnakeArith,
|
|
33
|
+
SnakeCast,
|
|
34
|
+
SnakeCondition,
|
|
35
|
+
SnakeExpr,
|
|
36
|
+
SnakeJsonGet,
|
|
37
|
+
SnakeSubquery,
|
|
38
|
+
SnakeSubqueryAggregate,
|
|
39
|
+
SnakeTupleIn,
|
|
40
|
+
SnakeValue,
|
|
41
|
+
)
|
|
42
|
+
from snakeorm.expressions.scalar import SnakeDateShift, SnakeFuncCall
|
|
43
|
+
|
|
44
|
+
T = TypeVar("T")
|
|
45
|
+
M = TypeVar("M")
|
|
46
|
+
|
|
47
|
+
SnakeScalar: TypeAlias = (
|
|
48
|
+
SnakeExpr[T]
|
|
49
|
+
| SnakeArith[T]
|
|
50
|
+
| SnakeCast[T]
|
|
51
|
+
| SnakeJsonGet[T]
|
|
52
|
+
| SnakeCase[T]
|
|
53
|
+
| SnakeCoalesce[T]
|
|
54
|
+
| SnakeNullIf[T]
|
|
55
|
+
| SnakeFuncCall[T]
|
|
56
|
+
| SnakeDateShift[T]
|
|
57
|
+
| SnakeSubquery[T]
|
|
58
|
+
| SnakeSubqueryAggregate[T]
|
|
59
|
+
)
|
|
60
|
+
"""Every `SnakeValue` that may stand on the left of a comparison inside a WHERE.
|
|
61
|
+
|
|
62
|
+
It is a union and not a base class because NOTHING in the hierarchy separates the two kinds:
|
|
63
|
+
`SnakeExpr`, `SnakeCase`, `SnakeAggregate` and `SnakeWindow` are flat siblings under `SnakeValue`.
|
|
64
|
+
Without it a `Fleet.depots.count()` reaches `set()` past both checkers, reads like a value, and is
|
|
65
|
+
refused by the engine at execution — a bare aggregate belongs in a HAVING, and a window function is
|
|
66
|
+
evaluated after the WHERE has already chosen the rows.
|
|
67
|
+
|
|
68
|
+
A union FAILS IN CLOSED: a scalar node added later and not listed here would be refused while being
|
|
69
|
+
perfectly valid. That is why `test_every_value_declares_whether_it_is_scalar.py` walks the package
|
|
70
|
+
and demands that every subclass appear either here or in its table of values that cannot — a new
|
|
71
|
+
node fails that test on the day it is written, which is the day the decision is cheap.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
_Slot: TypeAlias = tuple[SnakeValue[Any], object]
|
|
75
|
+
_Fingerprint: TypeAlias = tuple[str, object]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _fingerprint(slot: SnakeValue[Any]) -> _Fingerprint:
|
|
79
|
+
"""What makes two slots THE SAME slot when comparing one key against another.
|
|
80
|
+
|
|
81
|
+
A column is its path, which is exact. Anything else is its object identity, and that is a
|
|
82
|
+
deliberate choice to fail in closed: comparing two expressions by their shape would call
|
|
83
|
+
`SUBSTRING(code, 1, 3)` and `SUBSTRING(code, 2, 4)` equal — a FALSE match that emits wrong SQL
|
|
84
|
+
without a word. Refusing two separately built expressions is loud and has an obvious fix; the
|
|
85
|
+
message says it.
|
|
86
|
+
"""
|
|
87
|
+
if isinstance(slot, SnakeExpr):
|
|
88
|
+
return ("path", slot.path)
|
|
89
|
+
return ("object", id(slot))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _describe(slot: SnakeValue[Any]) -> str:
|
|
93
|
+
"""How a slot is named in an error message: the column, or the kind of expression it is."""
|
|
94
|
+
if isinstance(slot, SnakeExpr):
|
|
95
|
+
return ".".join(slot.path)
|
|
96
|
+
return type(slot).__name__
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _column_order(model: type) -> dict[str, int]:
|
|
100
|
+
"""Where each column of the model sits in its DECLARATION, by SQL name.
|
|
101
|
+
|
|
102
|
+
An empty map for a model the registry never compiled: the slots then keep the order they were
|
|
103
|
+
chained in, which is still consistent across the keys because the shape check demands it.
|
|
104
|
+
"""
|
|
105
|
+
from snakeorm.registry import registry
|
|
106
|
+
|
|
107
|
+
table = registry.table_of(model)
|
|
108
|
+
if table is None:
|
|
109
|
+
return {}
|
|
110
|
+
return {column.name: position for position, column in enumerate(table.columns)}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class SnakeKey(Generic[M]):
|
|
114
|
+
"""ONE row of the right-hand side: a column paired with its value, as many times as needed.
|
|
115
|
+
|
|
116
|
+
Immutable — `set` returns a new key. A shared prefix is a natural thing to write
|
|
117
|
+
(`base.set(a, 1)` and `base.set(a, 2)` off the same partial key), and with mutation the second
|
|
118
|
+
branch would either overwrite the first or trip the duplicate guard for no reason at all.
|
|
119
|
+
|
|
120
|
+
`M` is invariant, which is what makes a `SnakeKey[Truck]` inside a list of `SnakeKey[Province]`
|
|
121
|
+
a type error under both checkers. That is the guard that matters; the runtime one below is for
|
|
122
|
+
whoever runs no checker.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
__slots__ = ("_model", "_slots")
|
|
126
|
+
|
|
127
|
+
def __init__(self, model: type[M], slots: tuple[_Slot, ...] = ()) -> None:
|
|
128
|
+
self._model = model
|
|
129
|
+
self._slots = slots
|
|
130
|
+
|
|
131
|
+
def set(self, slot: SnakeScalar[T], value: T) -> Self:
|
|
132
|
+
"""Pairs one column (or scalar expression) with the value this row compares it against.
|
|
133
|
+
|
|
134
|
+
`T` is bound by the SLOT, so the value has to match the column's type: this is the whole
|
|
135
|
+
reason the API is not a positional tuple.
|
|
136
|
+
"""
|
|
137
|
+
self._guard_belongs_here(slot)
|
|
138
|
+
seen = _fingerprint(slot)
|
|
139
|
+
if any(_fingerprint(existing) == seen for existing, _ in self._slots):
|
|
140
|
+
raise SnakeEmitError(
|
|
141
|
+
f"'{_describe(slot)}' is already in this key of {self._model.__name__}. A row "
|
|
142
|
+
f"constructor compares each column once, so `(a, a) IN ((1, 2))` is a question "
|
|
143
|
+
f"with no answer. Letting the second value win would be the ORM choosing between "
|
|
144
|
+
f"two things you asked for."
|
|
145
|
+
)
|
|
146
|
+
return type(self)(self._model, (*self._slots, (slot, value)))
|
|
147
|
+
|
|
148
|
+
def _guard_belongs_here(self, slot: SnakeValue[Any]) -> None:
|
|
149
|
+
"""A BARE column name has to be a column of this model. The type cannot say this.
|
|
150
|
+
|
|
151
|
+
`SnakeKey[M]`'s invariance guards the KEY's model, which is what keeps a whole key of another
|
|
152
|
+
table out of the list. It says nothing about where each SLOT came from: `Depot.code` and
|
|
153
|
+
`Stock.city` are both `SnakeExpr[str]`, so both checkers accept either in a key of either
|
|
154
|
+
model, and what would come out is a column of one table inside a filter over another.
|
|
155
|
+
|
|
156
|
+
Putting the model into the EXPRESSION would close it and cost too much: `join()` returns
|
|
157
|
+
`SnakeJoinedQuery[T, M]` and is bi-rooted on purpose, so a condition over two models is
|
|
158
|
+
legitimate there. `annotate` documents the same wall in `session/session.py` — a dependent
|
|
159
|
+
bound, which TypeVar bounds cannot be. So the check lives here, where the model IS known.
|
|
160
|
+
|
|
161
|
+
It covers a bare name only. A path of more than one element navigates a relationship and
|
|
162
|
+
whether the hop exists is the linker's question; a compound expression is the emitter's.
|
|
163
|
+
Refusing either would forbid a legal filter for not being understood by a check written for
|
|
164
|
+
something else. A model the registry never compiled cannot be checked at all, and is left.
|
|
165
|
+
"""
|
|
166
|
+
if not isinstance(slot, SnakeExpr) or len(slot.path) != 1:
|
|
167
|
+
return
|
|
168
|
+
order = _column_order(self._model)
|
|
169
|
+
if not order or slot.path[0] in order:
|
|
170
|
+
return
|
|
171
|
+
raise SnakeEmitError(
|
|
172
|
+
f"'{slot.path[0]}' is not a column of {self._model.__name__}, so it cannot be a slot of "
|
|
173
|
+
f"one of its keys: the filter would name a column of another table. Neither checker "
|
|
174
|
+
f"catches this — a column expression carries its own type but not the model it came "
|
|
175
|
+
f"from — which is why it is refused here."
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def _ordered(self, order: dict[str, int]) -> tuple[_Slot, ...]:
|
|
179
|
+
"""The slots in the model's declaration order, whatever order they were chained in.
|
|
180
|
+
|
|
181
|
+
This is correctness and not tidiness. Every tuple's values have to line up with the ONE
|
|
182
|
+
column list the emitter writes, and the caller chains freely: trusting each key's insertion
|
|
183
|
+
order would put `(warehouse, product)` on one row and `(product, warehouse)` on the next,
|
|
184
|
+
and the engine would compare a warehouse against a product without complaining — both are
|
|
185
|
+
integers.
|
|
186
|
+
|
|
187
|
+
A slot that is not a plain column of this model has no declared position, so it keeps its
|
|
188
|
+
relative order and follows the ones that do. The pairing is still consistent across keys
|
|
189
|
+
because they are all required to present the same slots.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
def position(entry: tuple[int, _Slot]) -> tuple[int, int]:
|
|
193
|
+
index, (slot, _value) = entry
|
|
194
|
+
if isinstance(slot, SnakeExpr) and len(slot.path) == 1:
|
|
195
|
+
declared = order.get(slot.path[0])
|
|
196
|
+
if declared is not None:
|
|
197
|
+
return (0, declared)
|
|
198
|
+
return (1, index)
|
|
199
|
+
|
|
200
|
+
return tuple(slot for _, slot in sorted(enumerate(self._slots), key=position))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class SnakeKeys(Generic[M]):
|
|
204
|
+
"""The left-hand side: the tuple of columns, taken from the keys themselves.
|
|
205
|
+
|
|
206
|
+
It carries only the model, because the columns are whatever the keys declare — which is what
|
|
207
|
+
lets one API serve any width. What it does is CHECK that they all declare the same ones.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
__slots__ = ("_model",)
|
|
211
|
+
|
|
212
|
+
def __init__(self, model: type[M]) -> None:
|
|
213
|
+
self._model = model
|
|
214
|
+
|
|
215
|
+
def in_(self, keys: Iterable[SnakeKey[M]]) -> SnakeCondition:
|
|
216
|
+
"""`(c1, c2, ...) IN ((v1a, v2a, ...), ...)`, refusing anything that would not be that."""
|
|
217
|
+
rows = list(keys)
|
|
218
|
+
if not rows:
|
|
219
|
+
raise SnakeEmitError(
|
|
220
|
+
f"A composite IN over {self._model.__name__} needs at least one key: there is no "
|
|
221
|
+
f"`IN ()` in SQL."
|
|
222
|
+
)
|
|
223
|
+
order = _column_order(self._model)
|
|
224
|
+
self._guard_models(rows)
|
|
225
|
+
ordered = [self._ordered_slots(key, order) for key in rows]
|
|
226
|
+
self._guard_same_slots(ordered)
|
|
227
|
+
columns = tuple(slot for slot, _ in ordered[0])
|
|
228
|
+
return SnakeTupleIn(
|
|
229
|
+
columns=columns,
|
|
230
|
+
rows=tuple(tuple(value for _, value in row) for row in ordered),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def _guard_models(self, keys: list[SnakeKey[M]]) -> None:
|
|
234
|
+
"""Every key belongs to the model being filtered. Both checkers refuse this already."""
|
|
235
|
+
for key in keys:
|
|
236
|
+
if key._model is not self._model:
|
|
237
|
+
raise SnakeEmitError(
|
|
238
|
+
f"A key built for {key._model.__name__} cannot go in a composite IN over "
|
|
239
|
+
f"{self._model.__name__}: its columns belong to another table. Both type "
|
|
240
|
+
f"checkers refuse this too — `SnakeKey` is invariant in its model."
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def _ordered_slots(
|
|
244
|
+
self, key: SnakeKey[M], order: dict[str, int]
|
|
245
|
+
) -> tuple[_Slot, ...]:
|
|
246
|
+
"""One key's slots, canonicalised, refusing the empty one."""
|
|
247
|
+
if not key._slots:
|
|
248
|
+
raise SnakeEmitError(
|
|
249
|
+
f"A key of {self._model.__name__} has no columns: it would emit `() IN (())`, "
|
|
250
|
+
f"which is not SQL on any engine. Add at least one `.set(column, value)`."
|
|
251
|
+
)
|
|
252
|
+
return key._ordered(order)
|
|
253
|
+
|
|
254
|
+
def _guard_same_slots(self, ordered: list[tuple[_Slot, ...]]) -> None:
|
|
255
|
+
"""Every key presents the SAME columns, in the same canonical order.
|
|
256
|
+
|
|
257
|
+
Two failures live here and only one of them is visible. A key of a different WIDTH emits SQL
|
|
258
|
+
no engine parses, so it is caught either way. Two keys of the same width over DIFFERENT
|
|
259
|
+
columns is the quiet one: the shapes agree, the second row's values line up against the
|
|
260
|
+
first row's columns, and `units` compared against `product_id` is two integers and no
|
|
261
|
+
complaint from anywhere.
|
|
262
|
+
"""
|
|
263
|
+
first = [_fingerprint(slot) for slot, _ in ordered[0]]
|
|
264
|
+
for position, row in enumerate(ordered[1:], start=2):
|
|
265
|
+
current = [_fingerprint(slot) for slot, _ in row]
|
|
266
|
+
if current == first:
|
|
267
|
+
continue
|
|
268
|
+
if len(current) != len(first):
|
|
269
|
+
raise SnakeEmitError(
|
|
270
|
+
f"The keys of this composite IN over {self._model.__name__} are not the same "
|
|
271
|
+
f"width: the first has {len(first)} columns and key {position} has "
|
|
272
|
+
f"{len(current)}. Every row of a row constructor compares against the same "
|
|
273
|
+
f"column list."
|
|
274
|
+
)
|
|
275
|
+
mine = [_describe(slot) for slot, _ in ordered[0]]
|
|
276
|
+
theirs = [_describe(slot) for slot, _ in row]
|
|
277
|
+
unstated = (
|
|
278
|
+
" A slot that is not a plain column is matched by identity, so two separately "
|
|
279
|
+
"built expressions never match: bind it to a name and pass the same expression "
|
|
280
|
+
"object to every key."
|
|
281
|
+
if any(kind == "object" for kind, _ in first + current)
|
|
282
|
+
else ""
|
|
283
|
+
)
|
|
284
|
+
raise SnakeEmitError(
|
|
285
|
+
f"Key {position} of this composite IN over {self._model.__name__} is over "
|
|
286
|
+
f"different columns from the first: {theirs} instead of {mine}. Both are the same "
|
|
287
|
+
f"width, so the values would have been compared against the wrong columns without "
|
|
288
|
+
f"any engine objecting.{unstated}"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def snake_key(model: type[M]) -> SnakeKey[M]:
|
|
293
|
+
"""Starts one row of a composite IN. Chain `.set(column, value)` once per column."""
|
|
294
|
+
return SnakeKey(model)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def snake_keys(model: type[M]) -> SnakeKeys[M]:
|
|
298
|
+
"""Starts a composite IN over this model. Feed it the keys with `.in_([...])`."""
|
|
299
|
+
return SnakeKeys(model)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Collection of the column paths a condition of the boolean AST references.
|
|
2
|
+
|
|
3
|
+
It lives here (not in `query/`) because both `SnakeQuery` and `SnakeCollection` use it, and those two
|
|
4
|
+
cannot import each other; `expressions/` imports nobody.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from snakeorm.expressions.expression import (
|
|
10
|
+
SnakeAnd,
|
|
11
|
+
SnakeComparison,
|
|
12
|
+
SnakeCondition,
|
|
13
|
+
SnakeInList,
|
|
14
|
+
SnakeInSubquery,
|
|
15
|
+
SnakeIsNotNull,
|
|
16
|
+
SnakeIsNull,
|
|
17
|
+
SnakeLike,
|
|
18
|
+
SnakeNot,
|
|
19
|
+
SnakeOr,
|
|
20
|
+
SnakeTupleIn,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def condition_paths(condition: SnakeCondition) -> list[tuple[str, ...]]:
|
|
25
|
+
"""Column paths referenced by a condition (recursive).
|
|
26
|
+
|
|
27
|
+
It delegates to `left.paths()`. A `SnakeExists` (`.any()`) and the right-hand side of a
|
|
28
|
+
`SnakeInSubquery` return `[]`: their columns live inside the subquery and must not drag JOINs
|
|
29
|
+
into the outer query.
|
|
30
|
+
"""
|
|
31
|
+
if isinstance(condition, SnakeComparison):
|
|
32
|
+
return list(condition.left.paths())
|
|
33
|
+
if isinstance(condition, (SnakeAnd, SnakeOr)):
|
|
34
|
+
return [path for part in condition.parts for path in condition_paths(part)]
|
|
35
|
+
if isinstance(condition, (SnakeInList, SnakeIsNull, SnakeIsNotNull, SnakeLike)):
|
|
36
|
+
return list(condition.left.paths())
|
|
37
|
+
if isinstance(condition, SnakeTupleIn):
|
|
38
|
+
return [path for column in condition.columns for path in column.paths()]
|
|
39
|
+
if isinstance(condition, SnakeInSubquery):
|
|
40
|
+
return list(condition.left.paths())
|
|
41
|
+
if isinstance(condition, SnakeNot):
|
|
42
|
+
return condition_paths(condition.operand)
|
|
43
|
+
return []
|