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,738 @@
|
|
|
1
|
+
"""The SnakeToOne descriptor and its field specifier snake_to_one (to-one relation)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from typing import Any, Generic, Literal, Never, TypeVar, overload
|
|
8
|
+
|
|
9
|
+
from snakeorm.core.exceptions import (
|
|
10
|
+
SnakeModelError,
|
|
11
|
+
SnakeRegistryError,
|
|
12
|
+
SnakeRelationshipNotLoaded,
|
|
13
|
+
SnakeUnknownColumn,
|
|
14
|
+
SnakeUnknownRelationship,
|
|
15
|
+
SnakeUnlinkedRelationship,
|
|
16
|
+
SnakeUnsupportedFeature,
|
|
17
|
+
)
|
|
18
|
+
from snakeorm.expressions import (
|
|
19
|
+
SnakeAggFunc,
|
|
20
|
+
SnakeAnd,
|
|
21
|
+
SnakeCondition,
|
|
22
|
+
SnakeExists,
|
|
23
|
+
SnakeExistsJoin,
|
|
24
|
+
SnakeExpr,
|
|
25
|
+
SnakeSubqueryAggregate,
|
|
26
|
+
condition_paths,
|
|
27
|
+
)
|
|
28
|
+
from snakeorm.fields.column import SnakeColumn
|
|
29
|
+
from snakeorm.metadata import (
|
|
30
|
+
SnakeFkAction,
|
|
31
|
+
SnakeRelationshipKind,
|
|
32
|
+
SnakeRelationshipInfo,
|
|
33
|
+
SnakeTableInfo,
|
|
34
|
+
)
|
|
35
|
+
from snakeorm.registry import SnakeRegistry, registry_of
|
|
36
|
+
|
|
37
|
+
M = TypeVar("M")
|
|
38
|
+
N = TypeVar("N")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def relationship_storage_key(name: str) -> str:
|
|
42
|
+
"""Per-instance key where a loaded relation lives. Same computation as `__set_name__`."""
|
|
43
|
+
return f"__snake_{name}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def attach_relationship(instance: object, name: str, value: object) -> None:
|
|
47
|
+
"""Hang an ALREADY LOADED relation off the instance. The session loader uses this.
|
|
48
|
+
|
|
49
|
+
It writes the internal key directly instead of going through the descriptor, because the
|
|
50
|
+
`__set__` of relations now RAISES: assigning and loading are different things and each one has
|
|
51
|
+
its own door. Assigning is a user error (the FK would not move); loading is the ORM placing
|
|
52
|
+
what it has just brought back from the database, which is exactly the opposite.
|
|
53
|
+
"""
|
|
54
|
+
object.__setattr__(instance, relationship_storage_key(name), value)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _registry_of(model: type) -> SnakeRegistry:
|
|
58
|
+
"""Alias of `registry.registry_of`, kept so this module's own call sites read locally.
|
|
59
|
+
|
|
60
|
+
The answer belongs to the registry and now lives there. It used to live here, private, and be
|
|
61
|
+
imported across package boundaries by `query/` and `fields/check.py` — which is what a helper
|
|
62
|
+
looks like just before it becomes public by accident.
|
|
63
|
+
"""
|
|
64
|
+
return registry_of(model)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SnakePathProxy:
|
|
68
|
+
"""Runtime navigation over the linked graph (the counterpart of class access, which the
|
|
69
|
+
checker sees as `type[M]`).
|
|
70
|
+
|
|
71
|
+
By attribute: a column of the target → `SnakeExpr` carrying the accumulated path; a relation →
|
|
72
|
+
a new `SnakePathProxy` over its target. The `registry` is mandatory on purpose, so that no
|
|
73
|
+
place falls back to the global one silently.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self, table: SnakeTableInfo, path: tuple[str, ...], reg: SnakeRegistry
|
|
78
|
+
) -> None:
|
|
79
|
+
self._table = table
|
|
80
|
+
self._path = path
|
|
81
|
+
self._registry = reg
|
|
82
|
+
|
|
83
|
+
def __getattr__(self, name: str) -> Any:
|
|
84
|
+
# `name` is the Python attribute; the path carries the SQL name (the one emitted). With
|
|
85
|
+
# `snake_column(name=...)` they differ.
|
|
86
|
+
column = self._table.get_column_by_attr(name)
|
|
87
|
+
if column is not None:
|
|
88
|
+
return SnakeExpr(
|
|
89
|
+
path=(*self._path, column.name), python_type=column.python_type
|
|
90
|
+
)
|
|
91
|
+
for relationship in self._table.relationships:
|
|
92
|
+
if relationship.name == name:
|
|
93
|
+
target = self._registry.resolve_relationship(relationship)[0]
|
|
94
|
+
if target is None:
|
|
95
|
+
raise AttributeError(
|
|
96
|
+
f"Target '{relationship.target}' of '{name}' is not registered."
|
|
97
|
+
)
|
|
98
|
+
return SnakePathProxy(target, (*self._path, name), self._registry)
|
|
99
|
+
raise AttributeError(
|
|
100
|
+
f"'{self._table.name}' has no column or relation called '{name}'."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def path_of(proxy: object) -> tuple[str, ...]:
|
|
105
|
+
"""The navigation path a to-one proxy has accumulated (`Post.author.country` -> two hops).
|
|
106
|
+
|
|
107
|
+
A FUNCTION and not a property, and that is not style. `SnakePathProxy.__getattr__` IS the target
|
|
108
|
+
model's namespace: every public attribute the class grows shadows a column of that name, so a
|
|
109
|
+
model with a `path` column would lose it to the ORM's own plumbing without an error anywhere.
|
|
110
|
+
Asking from outside costs one import and cannot collide with anybody's schema.
|
|
111
|
+
|
|
112
|
+
It takes `object` and narrows at runtime, because the two halves of a descriptor disagree on
|
|
113
|
+
purpose: class access is TYPED `type[M]` and IS a `SnakePathProxy`, so a signature demanding the
|
|
114
|
+
proxy would reject `path_of(Post.author)` — every real call site. The isinstance check is the
|
|
115
|
+
contract, and it is what the caller gets told about.
|
|
116
|
+
"""
|
|
117
|
+
if not isinstance(proxy, SnakePathProxy):
|
|
118
|
+
raise TypeError(
|
|
119
|
+
f"path_of() takes a SnakePathProxy (class access on a to-one, like `Post.author`), "
|
|
120
|
+
f"not {type(proxy).__name__}."
|
|
121
|
+
)
|
|
122
|
+
return proxy._path
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class SnakeToOne(Generic[M]):
|
|
126
|
+
"""To-one relation descriptor.
|
|
127
|
+
|
|
128
|
+
CLASS access (`House.owner`) → `type[M]` (for navigation/queries).
|
|
129
|
+
INSTANCE access (`house.owner`) → the related object (`M`).
|
|
130
|
+
It also holds the local FK columns and the referential actions for the compiler.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
*source_columns: SnakeColumn[Any],
|
|
136
|
+
on_delete: SnakeFkAction = SnakeFkAction.NO_ACTION,
|
|
137
|
+
on_update: SnakeFkAction = SnakeFkAction.NO_ACTION,
|
|
138
|
+
) -> None:
|
|
139
|
+
self._local_columns = source_columns
|
|
140
|
+
self.on_delete = on_delete
|
|
141
|
+
self.on_update = on_update
|
|
142
|
+
self._attr_name = ""
|
|
143
|
+
self._storage_key = ""
|
|
144
|
+
self._target_table: SnakeTableInfo | None = None
|
|
145
|
+
|
|
146
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
147
|
+
"""Capture the property name and compute the per-instance storage key."""
|
|
148
|
+
self._attr_name = name
|
|
149
|
+
self._storage_key = f"__snake_{name}"
|
|
150
|
+
|
|
151
|
+
def local_column_names(self) -> tuple[str, ...]:
|
|
152
|
+
"""SQL names of the local FK columns (resolved at compile time)."""
|
|
153
|
+
return tuple(column.column_name for column in self._local_columns)
|
|
154
|
+
|
|
155
|
+
@overload
|
|
156
|
+
def __get__(self: SnakeToOne[N | None], instance: None, owner: Any) -> type[N]: ...
|
|
157
|
+
@overload
|
|
158
|
+
def __get__(self: SnakeToOne[M], instance: None, owner: Any) -> type[M]: ...
|
|
159
|
+
@overload
|
|
160
|
+
def __get__(self, instance: object, owner: Any) -> M: ...
|
|
161
|
+
def __get__(self, instance: object | None, owner: Any) -> Any:
|
|
162
|
+
"""Class access gives the target to navigate from; instance access gives the loaded value.
|
|
163
|
+
|
|
164
|
+
The FIRST overload is what lets a NULLABLE relation be navigated. Without it, class access
|
|
165
|
+
on `SnakeToOne[Author | None]` is `type[Author | None]`, which distributes to
|
|
166
|
+
`type[Author] | type[None]`; `type[None]` has no columns, so `Post.editor.username` is an
|
|
167
|
+
error in both checkers with a `SnakeExpr[str] | Any` leaking out behind it. Narrowing `self`
|
|
168
|
+
to `SnakeToOne[N | None]` binds `N` to the model alone and hands back `type[N]`.
|
|
169
|
+
|
|
170
|
+
Dropping the `| None` is correct HERE and only here: class access does not read a value, it
|
|
171
|
+
names the far side of a LEFT JOIN, a table that always exists whether or not any given row
|
|
172
|
+
has a partner. The third overload deliberately does NOT narrow `self`, so instance access
|
|
173
|
+
keeps `M` whole and `post.editor` stays `Author | None` — the row really may have no editor.
|
|
174
|
+
|
|
175
|
+
THE ORDER IS LOAD-BEARING AND FAILS SILENTLY. Overload resolution takes the first arm that
|
|
176
|
+
matches, and the narrow arm matches a subset of the generic one; declared second it never
|
|
177
|
+
wins and this whole mechanism quietly reverts, with no checker, linter or test of the
|
|
178
|
+
signature itself complaining. `src/test/typing/test_the_optional_overload_comes_first.py`
|
|
179
|
+
is what notices.
|
|
180
|
+
|
|
181
|
+
THE SIGNATURE HAS ONE HOLE, AND THE LINKER IS WHAT CLOSES IT. Given a union of two or more
|
|
182
|
+
models plus `| None` — `SnakeToOne[Card | Transfer | None]` — the two checkers disagree:
|
|
183
|
+
mypy infers `type[Never]` and pyright infers `type[Card] | type[Transfer]`. `type[Never]` is
|
|
184
|
+
not an error but a TYPE, so mypy would pass in green over an expression that means nothing
|
|
185
|
+
and the project's two gates would answer differently about the same line. That case is made
|
|
186
|
+
UNREACHABLE rather than patched here: `_guard_one_target_model` in `linker/linker.py`
|
|
187
|
+
refuses a relationship whose target is a union of models. A hierarchy is pointed at through
|
|
188
|
+
its base class instead (`SnakeToOne[Payment]`), which is how this ORM expresses
|
|
189
|
+
polymorphism anyway.
|
|
190
|
+
"""
|
|
191
|
+
if instance is None:
|
|
192
|
+
if self._target_table is None:
|
|
193
|
+
raise SnakeUnlinkedRelationship(
|
|
194
|
+
f"Relation '{self._attr_name}' is not linked; call snake_link() first."
|
|
195
|
+
)
|
|
196
|
+
return SnakePathProxy(
|
|
197
|
+
self._target_table, (self._attr_name,), _registry_of(owner)
|
|
198
|
+
)
|
|
199
|
+
try:
|
|
200
|
+
return getattr(instance, self._storage_key)
|
|
201
|
+
except AttributeError:
|
|
202
|
+
raise SnakeRelationshipNotLoaded(
|
|
203
|
+
f"Relation '{self._attr_name}' was not loaded. "
|
|
204
|
+
f"Use .include({owner.__name__}.{self._attr_name}) in the query."
|
|
205
|
+
) from None
|
|
206
|
+
|
|
207
|
+
def __set__(self, instance: object, value: Never) -> None:
|
|
208
|
+
"""FORBIDDEN to assign a relation. The type rejects it; this covers whoever skips the type.
|
|
209
|
+
|
|
210
|
+
`value: Never` is what makes it a CHECKER error: no value is assignable to `Never`, so mypy
|
|
211
|
+
and pyright reject the line. The method still exists at runtime on purpose: without
|
|
212
|
+
`__set__` the descriptor would stop being a data descriptor and an `instance.__dict__`
|
|
213
|
+
would shadow it.
|
|
214
|
+
"""
|
|
215
|
+
raise SnakeModelError(
|
|
216
|
+
f"'{self._attr_name}' is a relation: it is not assigned. Saving the object would NOT move "
|
|
217
|
+
f"the foreign key, so the row would keep pointing where it was and the change would be "
|
|
218
|
+
f"lost without a single warning. To POINT at another row write its FK column "
|
|
219
|
+
f"({', '.join(self.local_column_names()) or 'the local FK'}); to READ the relation "
|
|
220
|
+
f"load it with .include(...) in the query."
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def snake_to_one(
|
|
225
|
+
*source_columns: SnakeColumn[Any],
|
|
226
|
+
on_delete: SnakeFkAction = SnakeFkAction.NO_ACTION,
|
|
227
|
+
on_update: SnakeFkAction = SnakeFkAction.NO_ACTION,
|
|
228
|
+
init: Literal[False] = False,
|
|
229
|
+
) -> Any:
|
|
230
|
+
"""Declare a to-one relation (FK). It references the model's local FK columns.
|
|
231
|
+
|
|
232
|
+
The target comes from the `SnakeToOne[Target]` annotation; the mapping to the PK is resolved
|
|
233
|
+
in the linker. It is excluded from the constructor (it gets loaded with `.include(...)`): the
|
|
234
|
+
`init: Literal[False]` is the typing signal — without it the checker read it as "a field with
|
|
235
|
+
a default" and blessed a line that the runtime rejects with `TypeError`.
|
|
236
|
+
"""
|
|
237
|
+
return SnakeToOne(*source_columns, on_delete=on_delete, on_update=on_update)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class SnakeCollection(Generic[M]):
|
|
241
|
+
"""COLLECTION view: what CLASS access to a to-many returns (`Nation.makers`).
|
|
242
|
+
|
|
243
|
+
A to-many changes the cardinality, so it does NOT expose the child's columns (implicit
|
|
244
|
+
navigation): only collection operations — `.any(...)` (a correlated EXISTS) and the scalar
|
|
245
|
+
aggregates `.count()/.sum_()/.avg()/.min_()/.max_()`. That way `Nation.makers.name` is a TYPE
|
|
246
|
+
error, not unrunnable SQL. Generic in `M` so the child's type travels into the condition.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
__slots__ = (
|
|
250
|
+
"_parent_table",
|
|
251
|
+
"_child_table",
|
|
252
|
+
"_relationship",
|
|
253
|
+
"_attr_name",
|
|
254
|
+
"_registry",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def __init__(
|
|
258
|
+
self,
|
|
259
|
+
parent_table: SnakeTableInfo,
|
|
260
|
+
child_table: SnakeTableInfo,
|
|
261
|
+
relationship: SnakeRelationshipInfo,
|
|
262
|
+
attr_name: str,
|
|
263
|
+
reg: SnakeRegistry,
|
|
264
|
+
) -> None:
|
|
265
|
+
self._parent_table = parent_table
|
|
266
|
+
self._child_table = child_table
|
|
267
|
+
self._relationship = relationship
|
|
268
|
+
self._attr_name = attr_name
|
|
269
|
+
self._registry = reg
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def path(self) -> tuple[str, ...]:
|
|
273
|
+
"""One-hop path for `.include(...)` (select-in resolves it as a to-many)."""
|
|
274
|
+
return (self._attr_name,)
|
|
275
|
+
|
|
276
|
+
def any(self, condition: SnakeCondition | None = None) -> SnakeExists:
|
|
277
|
+
"""Correlated EXISTS: does the parent have at least one child [matching `condition`]?
|
|
278
|
+
|
|
279
|
+
The condition is over the CHILD MODEL (relative paths, re-anchored at emission time). It
|
|
280
|
+
can navigate the child's to-one relations (`Maker.nation.name`): those JOINs are resolved
|
|
281
|
+
here and emitted INSIDE the EXISTS with their own alias space. See
|
|
282
|
+
`_resolve_exists_joins`.
|
|
283
|
+
"""
|
|
284
|
+
joins: tuple[SnakeExistsJoin, ...] = ()
|
|
285
|
+
if condition is not None:
|
|
286
|
+
joins = _resolve_exists_joins(
|
|
287
|
+
self._child_table, condition_paths(condition), self._registry
|
|
288
|
+
)
|
|
289
|
+
return SnakeExists(
|
|
290
|
+
child_schema=self._child_table.schema,
|
|
291
|
+
child_name=self._child_table.name,
|
|
292
|
+
pairs=self._relationship.foreign_key.pairs,
|
|
293
|
+
condition=condition,
|
|
294
|
+
joins=joins,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def count(self) -> SnakeSubqueryAggregate[int]:
|
|
298
|
+
"""Scalar `COUNT(*)` subquery over the correlated children. Comparable (`.count() > 3`).
|
|
299
|
+
|
|
300
|
+
No `| None`: a parent without children counts 0, not NULL. The only aggregate that gets
|
|
301
|
+
away with it; the rest carry `| None` because they aggregate zero rows. See `sum_`.
|
|
302
|
+
"""
|
|
303
|
+
return self._aggregate(SnakeAggFunc.COUNT, None)
|
|
304
|
+
|
|
305
|
+
def sum_(self, column: SnakeExpr[N]) -> SnakeSubqueryAggregate[N | None]:
|
|
306
|
+
"""`SUM(col)` over the correlated children. The child column's type, plus `None`.
|
|
307
|
+
|
|
308
|
+
The `None` is not theoretical: a parent without children aggregates zero rows, and `SUM`
|
|
309
|
+
of zero rows is NULL.
|
|
310
|
+
"""
|
|
311
|
+
return self._aggregate(SnakeAggFunc.SUM, column)
|
|
312
|
+
|
|
313
|
+
def avg(self, column: SnakeExpr[Any]) -> SnakeSubqueryAggregate[float | None]:
|
|
314
|
+
"""`AVG(col)` over the correlated children. The average is real (`float`), or NULL with no children."""
|
|
315
|
+
return self._aggregate(SnakeAggFunc.AVG, column)
|
|
316
|
+
|
|
317
|
+
def min_(self, column: SnakeExpr[N]) -> SnakeSubqueryAggregate[N | None]:
|
|
318
|
+
"""`MIN(col)` over the correlated children. The child column's type, or NULL with no children."""
|
|
319
|
+
return self._aggregate(SnakeAggFunc.MIN, column)
|
|
320
|
+
|
|
321
|
+
def max_(self, column: SnakeExpr[N]) -> SnakeSubqueryAggregate[N | None]:
|
|
322
|
+
"""`MAX(col)` over the correlated children. The child column's type, or NULL with no children."""
|
|
323
|
+
return self._aggregate(SnakeAggFunc.MAX, column)
|
|
324
|
+
|
|
325
|
+
def _guard_child_column(self, path: tuple[str, ...]) -> None:
|
|
326
|
+
"""Demand that `path` be a DIRECT column of the child. Fails loudly if it is not.
|
|
327
|
+
|
|
328
|
+
At runtime and not in the type: `SnakeExpr[T]` carries the type of the VALUE, not the
|
|
329
|
+
owning model, so `Maker.id` and `Truck.id` are indistinguishable to the checker; putting
|
|
330
|
+
the owner in would kill deep navigation and the `&`. Without this,
|
|
331
|
+
`Nation.makers.sum_(Truck.id)` emitted valid SQL but the answer to another question (a
|
|
332
|
+
multi-step path demands a JOIN that has not been built).
|
|
333
|
+
"""
|
|
334
|
+
_guard_direct_child_column(
|
|
335
|
+
self._child_table,
|
|
336
|
+
path,
|
|
337
|
+
"A collection's aggregates only accept direct columns of the child model.",
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
def _aggregate(
|
|
341
|
+
self, func: SnakeAggFunc, column: SnakeExpr[Any] | None
|
|
342
|
+
) -> SnakeSubqueryAggregate[Any]:
|
|
343
|
+
"""Build the scalar correlated-aggregation subquery over the child's FK.
|
|
344
|
+
|
|
345
|
+
The argument column (or `None` for COUNT(*)) belongs to the CHILD MODEL: relative paths
|
|
346
|
+
re-anchored at emission time. It is validated to be a DIRECT column of the child: see
|
|
347
|
+
`_guard_child_column`.
|
|
348
|
+
"""
|
|
349
|
+
if column is not None:
|
|
350
|
+
for path in column.paths():
|
|
351
|
+
self._guard_child_column(path)
|
|
352
|
+
return SnakeSubqueryAggregate(
|
|
353
|
+
func=func,
|
|
354
|
+
arg=column,
|
|
355
|
+
child_schema=self._child_table.schema,
|
|
356
|
+
child_name=self._child_table.name,
|
|
357
|
+
pairs=self._relationship.foreign_key.pairs,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class SnakeToMany(Generic[M]):
|
|
362
|
+
"""To-many relation descriptor: the INVERSE of an FK on the child.
|
|
363
|
+
|
|
364
|
+
CLASS access (`Country.cities`) → `SnakeCollection[M]`; INSTANCE access → `list[M]`.
|
|
365
|
+
It holds the NAME of the child FK to reverse (`fk_name`); the linker resolves the child from
|
|
366
|
+
the `SnakeToMany[Child]` annotation and the FK columns from that relation.
|
|
367
|
+
"""
|
|
368
|
+
|
|
369
|
+
def __init__(
|
|
370
|
+
self,
|
|
371
|
+
fk_name: str,
|
|
372
|
+
*,
|
|
373
|
+
through: str | type | None = None,
|
|
374
|
+
via: str | None = None,
|
|
375
|
+
to: str | None = None,
|
|
376
|
+
) -> None:
|
|
377
|
+
self.fk_name = fk_name
|
|
378
|
+
# The BRIDGE of a many-to-many. It reuses this descriptor because the typed behaviour is
|
|
379
|
+
# identical; what changes is only HOW the rows get loaded.
|
|
380
|
+
self.through = through
|
|
381
|
+
self.via = via
|
|
382
|
+
self.to = to
|
|
383
|
+
self._attr_name = ""
|
|
384
|
+
self._storage_key = ""
|
|
385
|
+
self._target_table: SnakeTableInfo | None = None
|
|
386
|
+
|
|
387
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
388
|
+
"""Capture the property name and compute the per-instance storage key."""
|
|
389
|
+
self._attr_name = name
|
|
390
|
+
self._storage_key = f"__snake_{name}"
|
|
391
|
+
|
|
392
|
+
@overload
|
|
393
|
+
def __get__(self, instance: None, owner: Any) -> SnakeCollection[M]: ...
|
|
394
|
+
@overload
|
|
395
|
+
def __get__(self, instance: object, owner: Any) -> list[M]: ...
|
|
396
|
+
def __get__(self, instance: object | None, owner: Any) -> Any:
|
|
397
|
+
if instance is None:
|
|
398
|
+
return self._collection(owner)
|
|
399
|
+
try:
|
|
400
|
+
return getattr(instance, self._storage_key)
|
|
401
|
+
except AttributeError:
|
|
402
|
+
raise SnakeRelationshipNotLoaded(
|
|
403
|
+
f"Relation '{self._attr_name}' was not loaded. "
|
|
404
|
+
f"Use .include({owner.__name__}.{self._attr_name}) in the query."
|
|
405
|
+
) from None
|
|
406
|
+
|
|
407
|
+
def _collection(self, owner: type) -> SnakeCollection[M]:
|
|
408
|
+
"""Build the SnakeCollection of class access, resolving the parent's relation."""
|
|
409
|
+
if self._target_table is None:
|
|
410
|
+
raise SnakeUnlinkedRelationship(
|
|
411
|
+
f"Relation '{self._attr_name}' is not linked; call snake_link() first."
|
|
412
|
+
)
|
|
413
|
+
reg = _registry_of(owner)
|
|
414
|
+
parent_table = reg.table_of(owner)
|
|
415
|
+
relationship = _find_relationship(parent_table, self._attr_name)
|
|
416
|
+
if parent_table is None or relationship is None:
|
|
417
|
+
raise SnakeUnlinkedRelationship(
|
|
418
|
+
f"Relation '{self._attr_name}' is not linked; call snake_link() first."
|
|
419
|
+
)
|
|
420
|
+
return SnakeCollection(
|
|
421
|
+
parent_table=parent_table,
|
|
422
|
+
child_table=self._target_table,
|
|
423
|
+
relationship=relationship,
|
|
424
|
+
attr_name=self._attr_name,
|
|
425
|
+
reg=reg,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
def __set__(self, instance: object, value: Never) -> None:
|
|
429
|
+
"""FORBIDDEN to assign a collection. Same criterion as the to-one (see
|
|
430
|
+
`SnakeToOne.__set__`).
|
|
431
|
+
|
|
432
|
+
Here it is even clearer: the children hold THEIR own FK, so hanging a list off the parent
|
|
433
|
+
writes absolutely nothing to the database.
|
|
434
|
+
"""
|
|
435
|
+
raise SnakeModelError(
|
|
436
|
+
f"'{self._attr_name}' is a collection: it is not assigned. The children point at the parent "
|
|
437
|
+
f"through THEIR own foreign key, so hanging a list here would write NOTHING to the "
|
|
438
|
+
f"database. To add a child, give it the parent's FK and save it (session.add); to READ "
|
|
439
|
+
f"the collection load it with .include(...) in the query."
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _resolve_exists_joins(
|
|
444
|
+
child_table: SnakeTableInfo,
|
|
445
|
+
paths: Iterable[tuple[str, ...]],
|
|
446
|
+
reg: SnakeRegistry,
|
|
447
|
+
) -> tuple[SnakeExistsJoin, ...]:
|
|
448
|
+
"""Validate the paths of an `.any()` condition and build the navigation JOINs.
|
|
449
|
+
|
|
450
|
+
Last step = column; intermediate ones = to-one relations of the model in play, navigated inside
|
|
451
|
+
the EXISTS. An intermediate to-many step would change the cardinality →
|
|
452
|
+
`SnakeUnsupportedFeature` (nest it with another `.any()`). It returns the JOINs without
|
|
453
|
+
duplicating prefixes, ordered from parent to child, ready for emission to assign aliases in its
|
|
454
|
+
own space.
|
|
455
|
+
"""
|
|
456
|
+
joins: dict[tuple[str, ...], SnakeExistsJoin] = {}
|
|
457
|
+
for path in paths:
|
|
458
|
+
_collect_navigation(child_table, path, joins, reg)
|
|
459
|
+
return tuple(
|
|
460
|
+
joins[prefix]
|
|
461
|
+
for prefix in sorted(joins, key=lambda prefix: (len(prefix), prefix))
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _collect_navigation(
|
|
466
|
+
child_table: SnakeTableInfo,
|
|
467
|
+
path: tuple[str, ...],
|
|
468
|
+
joins: dict[tuple[str, ...], SnakeExistsJoin],
|
|
469
|
+
reg: SnakeRegistry,
|
|
470
|
+
) -> None:
|
|
471
|
+
"""Walk ONE path validating it against the graph and accumulate its navigation JOINs in `joins`."""
|
|
472
|
+
if len(path) == 1:
|
|
473
|
+
if child_table.get_column(path[0]) is None:
|
|
474
|
+
raise SnakeUnknownColumn(
|
|
475
|
+
f"'{path[0]}' is not a column of the child model '{child_table.name}' nor a navigable "
|
|
476
|
+
f"relation. The .any() of a collection filters by columns of the child or "
|
|
477
|
+
f"navigates its to-one relations."
|
|
478
|
+
)
|
|
479
|
+
return
|
|
480
|
+
table = child_table
|
|
481
|
+
for depth, step in enumerate(path[:-1]):
|
|
482
|
+
relationship = _find_relationship(table, step)
|
|
483
|
+
if relationship is None:
|
|
484
|
+
raise SnakeUnknownColumn(
|
|
485
|
+
f"'{step}' is neither a column nor a relation of '{table.name}': there is no way to "
|
|
486
|
+
f"navigate the condition of the .any() ({'.'.join(path)})."
|
|
487
|
+
)
|
|
488
|
+
if relationship.kind is not SnakeRelationshipKind.TO_ONE:
|
|
489
|
+
raise SnakeUnsupportedFeature(
|
|
490
|
+
f"'{step}' is a to-many relation: navigating it inside an EXISTS would change the "
|
|
491
|
+
f"cardinality. Nest the existence with another explicit .any()."
|
|
492
|
+
)
|
|
493
|
+
target = reg.resolve_relationship(relationship)[0]
|
|
494
|
+
if target is None:
|
|
495
|
+
raise SnakeRegistryError(
|
|
496
|
+
f"Target '{relationship.target}' of '{step}' is not registered."
|
|
497
|
+
)
|
|
498
|
+
prefix = path[: depth + 1]
|
|
499
|
+
if prefix not in joins:
|
|
500
|
+
joins[prefix] = SnakeExistsJoin(
|
|
501
|
+
prefix=prefix,
|
|
502
|
+
schema=target.schema,
|
|
503
|
+
name=target.name,
|
|
504
|
+
pairs=relationship.foreign_key.pairs,
|
|
505
|
+
)
|
|
506
|
+
table = target
|
|
507
|
+
if table.get_column(path[-1]) is None:
|
|
508
|
+
raise SnakeUnknownColumn(
|
|
509
|
+
f"'{path[-1]}' is not a column of '{table.name}' (target of the navigation "
|
|
510
|
+
f"{'.'.join(path)})."
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _guard_direct_child_column(
|
|
515
|
+
child_table: SnakeTableInfo, path: tuple[str, ...], reason: str
|
|
516
|
+
) -> None:
|
|
517
|
+
"""Demand that `path` be a DIRECT column of `child_table`; fail loudly if it is not.
|
|
518
|
+
|
|
519
|
+
The single point of truth for the "direct column of the child" criterion (shared by the
|
|
520
|
+
aggregates, `.any()` and the `.filter()` of a prefetch). A multi-step path is navigation (a
|
|
521
|
+
JOIN that has not been built); both → `SnakeUnknownColumn`. `reason` gives the context of who
|
|
522
|
+
did the validating.
|
|
523
|
+
"""
|
|
524
|
+
if len(path) != 1 or child_table.get_column(path[0]) is None:
|
|
525
|
+
name = ".".join(path)
|
|
526
|
+
raise SnakeUnknownColumn(
|
|
527
|
+
f"'{name}' is not a direct column of '{child_table.name}'. {reason}"
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _find_relationship(
|
|
532
|
+
table: SnakeTableInfo | None, name: str
|
|
533
|
+
) -> SnakeRelationshipInfo | None:
|
|
534
|
+
"""Look up the relation by name in the linked table, or None if it is not there yet."""
|
|
535
|
+
if table is None:
|
|
536
|
+
return None
|
|
537
|
+
for relationship in table.relationships:
|
|
538
|
+
if relationship.name == name:
|
|
539
|
+
return relationship
|
|
540
|
+
return None
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def snake_to_many_through(
|
|
544
|
+
*, through: str | type, via: str, to: str, init: Literal[False] = False
|
|
545
|
+
) -> Any:
|
|
546
|
+
"""Declare a MANY-TO-MANY that crosses a DECLARED bridge table.
|
|
547
|
+
|
|
548
|
+
tags: SnakeToMany["Tag"] = snake_to_many_through(
|
|
549
|
+
through="PostTag", via="post", to="tag"
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
`through` is the BRIDGE model (a normal one), `via` the bridge relation pointing at THIS model
|
|
553
|
+
and `to` the one pointing at the target. The bridge is a real model (not an implicit
|
|
554
|
+
Django-style m2m): adding a column to it is just one more field. It is NAVIGATION, not writing:
|
|
555
|
+
linking means inserting the bridge row with `add()`.
|
|
556
|
+
|
|
557
|
+
`through` takes a NAME or the CLASS. The name is the usual way, because the bridge is normally
|
|
558
|
+
declared after the model that crosses it and there is no class to hand over yet. The class is
|
|
559
|
+
the way out when the name is ambiguous: two apps can each declare a `Tagging`, and a name is
|
|
560
|
+
resolved through an index kept by whichever registered LAST — the same index that produced bug
|
|
561
|
+
#14. With the class there is nothing to look up, so nothing to get wrong. Handing it over means
|
|
562
|
+
declaring the bridge first, with string annotations pointing back.
|
|
563
|
+
"""
|
|
564
|
+
return SnakeToMany(via, through=through, via=via, to=to)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def snake_to_many(fk_name: str, *, init: Literal[False] = False) -> Any:
|
|
568
|
+
"""Declare a to-many relation: the inverse of the child's FK relation `fk_name`.
|
|
569
|
+
|
|
570
|
+
The child comes out of the `SnakeToMany[Child]` annotation. It is EXCLUDED from the
|
|
571
|
+
constructor (you do not build it, you load it with `.include(...)`): the `init:
|
|
572
|
+
Literal[False]` is the signal that mypy and pyright read. It has to be a string because the
|
|
573
|
+
child is usually defined AFTER the parent.
|
|
574
|
+
"""
|
|
575
|
+
return SnakeToMany(fk_name)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
@dataclass(frozen=True, slots=True)
|
|
579
|
+
class SnakePrefetchHop:
|
|
580
|
+
"""A normalised hop of a prefetch chain (already resolved against the metadata graph).
|
|
581
|
+
|
|
582
|
+
`SnakePrefetch` produces it at construction time (with the relations already linked). The
|
|
583
|
+
session consumes it level by level: to_many with select-in (a list per parent), to_one with an
|
|
584
|
+
extra query (an object per parent). It carries the parent's table and the child's model+table
|
|
585
|
+
(to instantiate it).
|
|
586
|
+
"""
|
|
587
|
+
|
|
588
|
+
name: str
|
|
589
|
+
kind: SnakeRelationshipKind
|
|
590
|
+
"""The same cardinality (and the SAME type) as `SnakeRelationshipInfo`, not a copy of the enum."""
|
|
591
|
+
parent_table: SnakeTableInfo
|
|
592
|
+
child_model: type[object]
|
|
593
|
+
child_table: SnakeTableInfo
|
|
594
|
+
relationship: SnakeRelationshipInfo
|
|
595
|
+
# OPTIONAL filter for the level: it narrows which CHILDREN get loaded (direct columns). It is
|
|
596
|
+
# ADDED with AND to the `WHERE fk IN (...)` of the select-in; a parent with no matching children
|
|
597
|
+
# gets [] but STILL comes back. Different from `query.filter()`, which would drop the parent.
|
|
598
|
+
child_filter: SnakeCondition | None = None
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
class SnakePrefetch(Generic[M]):
|
|
602
|
+
"""EXPLICIT chain of nested (eager) loading that starts at a to-many (`Nation.makers`).
|
|
603
|
+
|
|
604
|
+
A collection does not expose the child's relations, so the chain is not navigated: it is
|
|
605
|
+
declared with `SnakePrefetch(Nation.makers).then(Maker.trucks)`. Generic in the model of the
|
|
606
|
+
LAST hop so that `.then(...)` only accepts relations of THAT child. The session resolves it
|
|
607
|
+
with ONE query per LEVEL (never N+1).
|
|
608
|
+
"""
|
|
609
|
+
|
|
610
|
+
__slots__ = ("_hops",)
|
|
611
|
+
|
|
612
|
+
def __init__(self, root: SnakeCollection[M]) -> None:
|
|
613
|
+
# The root is ALWAYS a to-many: that is what needs nesting. The to-ones already get loaded
|
|
614
|
+
# with a LEFT JOIN via `.include(User.car)`.
|
|
615
|
+
self._hops: tuple[SnakePrefetchHop, ...] = (_hop_from_collection(root),)
|
|
616
|
+
|
|
617
|
+
@overload
|
|
618
|
+
def then(self, relation: SnakeCollection[N]) -> SnakePrefetch[N]: ...
|
|
619
|
+
@overload
|
|
620
|
+
def then(self, relation: type[N]) -> SnakePrefetch[N]: ...
|
|
621
|
+
def then(self, relation: SnakeCollection[Any] | type[Any]) -> SnakePrefetch[Any]:
|
|
622
|
+
"""Chain one more hop onto the current child: to-many (`SnakeCollection`) or to-one
|
|
623
|
+
(`type`).
|
|
624
|
+
|
|
625
|
+
It returns a NEW `SnakePrefetch` (immutable). A column (`SnakeExpr`) matches no overload:
|
|
626
|
+
`.then(Truck.model)` does not compile.
|
|
627
|
+
"""
|
|
628
|
+
current = self._hops[-1].child_table
|
|
629
|
+
extended: SnakePrefetch[Any] = SnakePrefetch.__new__(SnakePrefetch)
|
|
630
|
+
extended._hops = (*self._hops, *_hops_for_then(current, relation))
|
|
631
|
+
return extended
|
|
632
|
+
|
|
633
|
+
def filter(self, condition: SnakeCondition) -> SnakePrefetch[M]:
|
|
634
|
+
"""Narrow WHICH CHILDREN get loaded at the CURRENT level (the last hop), WITHOUT dropping
|
|
635
|
+
parents.
|
|
636
|
+
|
|
637
|
+
Different from `query.filter()` (which discards parents): it only narrows the select-in of
|
|
638
|
+
the level, a parent with no matching children gets [] but STILL comes back. It accumulates
|
|
639
|
+
with AND and returns a NEW `SnakePrefetch` (immutable) of the SAME type. The condition must
|
|
640
|
+
be over DIRECT columns of the model at THAT level; navigating or naming another column →
|
|
641
|
+
`SnakeUnknownColumn`.
|
|
642
|
+
"""
|
|
643
|
+
last = self._hops[-1]
|
|
644
|
+
for path in condition_paths(condition):
|
|
645
|
+
_guard_direct_child_column(
|
|
646
|
+
last.child_table,
|
|
647
|
+
path,
|
|
648
|
+
"A prefetch's .filter() only narrows by columns of that level's model.",
|
|
649
|
+
)
|
|
650
|
+
merged = (
|
|
651
|
+
condition
|
|
652
|
+
if last.child_filter is None
|
|
653
|
+
else SnakeAnd(parts=(last.child_filter, condition))
|
|
654
|
+
)
|
|
655
|
+
filtered: SnakePrefetch[Any] = SnakePrefetch.__new__(SnakePrefetch)
|
|
656
|
+
filtered._hops = (*self._hops[:-1], replace(last, child_filter=merged))
|
|
657
|
+
return filtered
|
|
658
|
+
|
|
659
|
+
def hops(self) -> tuple[SnakePrefetchHop, ...]:
|
|
660
|
+
"""The normalised hops in order (root first). The session walks them, one per level."""
|
|
661
|
+
return self._hops
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _hop_from_collection(collection: SnakeCollection[Any]) -> SnakePrefetchHop:
|
|
665
|
+
"""Normalise class access to a to-many (`SnakeCollection`) into a prefetch hop."""
|
|
666
|
+
relationship = collection._relationship # noqa: SLF001 - same module, internal contract
|
|
667
|
+
child_table = collection._child_table # noqa: SLF001 - same module, internal contract
|
|
668
|
+
child_model = collection._registry.resolve_relationship(relationship)[1] # noqa: SLF001
|
|
669
|
+
if child_model is None:
|
|
670
|
+
raise SnakeRegistryError(
|
|
671
|
+
f"Target '{relationship.target}' of '{relationship.name}' is not registered."
|
|
672
|
+
)
|
|
673
|
+
return SnakePrefetchHop(
|
|
674
|
+
# The relation dictates the KIND, it is not hardcoded: an m2m (to-many-through) routes to
|
|
675
|
+
# another planner, and assuming TO_MANY sent it to the wrong one (a direct FK that does not
|
|
676
|
+
# exist → error).
|
|
677
|
+
name=collection._attr_name, # noqa: SLF001 - same module, internal contract
|
|
678
|
+
kind=relationship.kind,
|
|
679
|
+
parent_table=collection._parent_table, # noqa: SLF001 - same module, internal contract
|
|
680
|
+
child_model=child_model,
|
|
681
|
+
child_table=child_table,
|
|
682
|
+
relationship=relationship,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _hops_for_then(
|
|
687
|
+
parent_table: SnakeTableInfo, relation: SnakeCollection[Any] | type[Any]
|
|
688
|
+
) -> tuple[SnakePrefetchHop, ...]:
|
|
689
|
+
"""Resolve the argument of `.then(...)` into one or more hops against the current child.
|
|
690
|
+
|
|
691
|
+
A `SnakeCollection` is a single to-many hop. A to-one arrives at runtime as a `SnakePathProxy`
|
|
692
|
+
(even though the checker sees it as `type[M]`): its path gets expanded into to-one hops. A
|
|
693
|
+
to-many hidden inside the path is rejected: to nest a to-many you use another explicit
|
|
694
|
+
`.then(...)`.
|
|
695
|
+
"""
|
|
696
|
+
if isinstance(relation, SnakeCollection):
|
|
697
|
+
return (_hop_from_collection(relation),)
|
|
698
|
+
if not isinstance(relation, SnakePathProxy):
|
|
699
|
+
# Anything that is not relation navigation. The checker already rejects it; the guard gives
|
|
700
|
+
# a clear message instead of an `AttributeError` from the guts of the prefetch.
|
|
701
|
+
raise SnakeUnknownRelationship(
|
|
702
|
+
f"`.then()` expects a RELATION navigated from the class (e.g. `.then(Brand.cars)`), "
|
|
703
|
+
f"not {type(relation).__name__!r}. A relation name given as text is no good here."
|
|
704
|
+
)
|
|
705
|
+
proxy = relation
|
|
706
|
+
hops: list[SnakePrefetchHop] = []
|
|
707
|
+
table = parent_table
|
|
708
|
+
for step in proxy._path: # noqa: SLF001 - same module, internal contract
|
|
709
|
+
relationship = _relationship_of(table, step)
|
|
710
|
+
target_table, target_model = proxy._registry.resolve_relationship(relationship) # noqa: SLF001
|
|
711
|
+
if target_table is None or target_model is None:
|
|
712
|
+
raise SnakeRegistryError(
|
|
713
|
+
f"Target '{relationship.target}' of '{step}' is not registered."
|
|
714
|
+
)
|
|
715
|
+
if relationship.kind is not SnakeRelationshipKind.TO_ONE:
|
|
716
|
+
raise SnakeUnsupportedFeature(
|
|
717
|
+
f"'{step}' is to-many: chain it with another explicit .then(...), do not navigate it."
|
|
718
|
+
)
|
|
719
|
+
hops.append(
|
|
720
|
+
SnakePrefetchHop(
|
|
721
|
+
name=step,
|
|
722
|
+
kind=SnakeRelationshipKind.TO_ONE,
|
|
723
|
+
parent_table=table,
|
|
724
|
+
child_model=target_model,
|
|
725
|
+
child_table=target_table,
|
|
726
|
+
relationship=relationship,
|
|
727
|
+
)
|
|
728
|
+
)
|
|
729
|
+
table = target_table
|
|
730
|
+
return tuple(hops)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _relationship_of(table: SnakeTableInfo, name: str) -> SnakeRelationshipInfo:
|
|
734
|
+
"""Look up a relation by name in the table; fails clearly if absent (a `.then` step)."""
|
|
735
|
+
for relationship in table.relationships:
|
|
736
|
+
if relationship.name == name:
|
|
737
|
+
return relationship
|
|
738
|
+
raise SnakeUnknownRelationship(f"'{table.name}' has no relation called '{name}'.")
|