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.
Files changed (198) hide show
  1. snake_orm-0.1.0b1.dist-info/METADATA +366 -0
  2. snake_orm-0.1.0b1.dist-info/RECORD +198 -0
  3. snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
  4. snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
  5. snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
  6. snakeorm/__init__.py +214 -0
  7. snakeorm/advisor.py +109 -0
  8. snakeorm/cli/__init__.py +3 -0
  9. snakeorm/cli/app.py +992 -0
  10. snakeorm/cli/discovery.py +179 -0
  11. snakeorm/cli/hooks.py +86 -0
  12. snakeorm/compiler/__init__.py +3 -0
  13. snakeorm/compiler/compiler.py +330 -0
  14. snakeorm/connection.py +228 -0
  15. snakeorm/contrib/__init__.py +17 -0
  16. snakeorm/contrib/asgi.py +192 -0
  17. snakeorm/contrib/config.py +264 -0
  18. snakeorm/contrib/deliver.py +256 -0
  19. snakeorm/contrib/django.py +245 -0
  20. snakeorm/contrib/sidecar.py +44 -0
  21. snakeorm/contrib/wsgi.py +176 -0
  22. snakeorm/core/__init__.py +1 -0
  23. snakeorm/core/config.py +173 -0
  24. snakeorm/core/converters.py +172 -0
  25. snakeorm/core/exceptions.py +175 -0
  26. snakeorm/core/placement.py +15 -0
  27. snakeorm/core/sentinels.py +37 -0
  28. snakeorm/core/signals.py +114 -0
  29. snakeorm/debug/__init__.py +42 -0
  30. snakeorm/debug/assets/css/panel.css +616 -0
  31. snakeorm/debug/assets/css/snake_orm_app.css +293 -0
  32. snakeorm/debug/assets/img/icon.svg +5 -0
  33. snakeorm/debug/assets/js/history.js +425 -0
  34. snakeorm/debug/assets/js/language.js +283 -0
  35. snakeorm/debug/assets/js/panel.js +241 -0
  36. snakeorm/debug/assets/js/snake_orm_app.js +82 -0
  37. snakeorm/debug/assets/pages/cli.html +28 -0
  38. snakeorm/debug/assets/pages/config.html +34 -0
  39. snakeorm/debug/assets/pages/database_first.html +29 -0
  40. snakeorm/debug/assets/pages/help.html +21 -0
  41. snakeorm/debug/assets/pages/history.html +27 -0
  42. snakeorm/debug/capture.py +234 -0
  43. snakeorm/debug/channel.py +131 -0
  44. snakeorm/debug/collector.py +144 -0
  45. snakeorm/debug/config.py +149 -0
  46. snakeorm/debug/html.py +578 -0
  47. snakeorm/debug/matrix.py +60 -0
  48. snakeorm/debug/origin.py +42 -0
  49. snakeorm/debug/otel/__init__.py +68 -0
  50. snakeorm/debug/otel/context.py +43 -0
  51. snakeorm/debug/otel/exporter.py +277 -0
  52. snakeorm/debug/otel/payload.py +97 -0
  53. snakeorm/debug/otel/report.py +29 -0
  54. snakeorm/debug/otel/spans.py +393 -0
  55. snakeorm/debug/otel/summary.py +59 -0
  56. snakeorm/debug/record.py +54 -0
  57. snakeorm/debug/report.py +311 -0
  58. snakeorm/debug/testing.py +23 -0
  59. snakeorm/decorators/__init__.py +13 -0
  60. snakeorm/decorators/abstract.py +26 -0
  61. snakeorm/decorators/db_first.py +104 -0
  62. snakeorm/decorators/function.py +28 -0
  63. snakeorm/decorators/model.py +268 -0
  64. snakeorm/decorators/polymorphic.py +140 -0
  65. snakeorm/decorators/result.py +167 -0
  66. snakeorm/decorators/row.py +90 -0
  67. snakeorm/decorators/trigger.py +47 -0
  68. snakeorm/decorators/view.py +149 -0
  69. snakeorm/dialects/__init__.py +6 -0
  70. snakeorm/dialects/base.py +373 -0
  71. snakeorm/dialects/capabilities.py +571 -0
  72. snakeorm/dialects/literals.py +46 -0
  73. snakeorm/dialects/matrix.py +384 -0
  74. snakeorm/dialects/mysql.py +519 -0
  75. snakeorm/dialects/postgres.py +504 -0
  76. snakeorm/dialects/sqlite.py +417 -0
  77. snakeorm/drivers/__init__.py +18 -0
  78. snakeorm/drivers/asyncbase.py +73 -0
  79. snakeorm/drivers/asyncdecorators.py +244 -0
  80. snakeorm/drivers/asyncpool.py +228 -0
  81. snakeorm/drivers/asyncpsycopg.py +124 -0
  82. snakeorm/drivers/asyncpymysql.py +50 -0
  83. snakeorm/drivers/asyncsqlite.py +30 -0
  84. snakeorm/drivers/base.py +76 -0
  85. snakeorm/drivers/failures.py +181 -0
  86. snakeorm/drivers/logging.py +167 -0
  87. snakeorm/drivers/pool.py +352 -0
  88. snakeorm/drivers/psycopg.py +188 -0
  89. snakeorm/drivers/pymysql.py +176 -0
  90. snakeorm/drivers/savepoints.py +46 -0
  91. snakeorm/drivers/sqlite.py +170 -0
  92. snakeorm/drivers/threaded.py +143 -0
  93. snakeorm/drivers/timeout.py +87 -0
  94. snakeorm/dto/__init__.py +55 -0
  95. snakeorm/dto/read.py +231 -0
  96. snakeorm/dto/region.py +326 -0
  97. snakeorm/dto/resolve.py +354 -0
  98. snakeorm/dto/spec.py +201 -0
  99. snakeorm/expressions/__init__.py +88 -0
  100. snakeorm/expressions/conditional.py +178 -0
  101. snakeorm/expressions/expression.py +810 -0
  102. snakeorm/expressions/functions.py +71 -0
  103. snakeorm/expressions/keys.py +299 -0
  104. snakeorm/expressions/paths.py +43 -0
  105. snakeorm/expressions/scalar.py +293 -0
  106. snakeorm/expressions/window.py +248 -0
  107. snakeorm/fields/__init__.py +68 -0
  108. snakeorm/fields/check.py +79 -0
  109. snakeorm/fields/column.py +316 -0
  110. snakeorm/fields/enum.py +64 -0
  111. snakeorm/fields/index.py +46 -0
  112. snakeorm/fields/relationship.py +738 -0
  113. snakeorm/fields/typed.py +736 -0
  114. snakeorm/helpers/__init__.py +1 -0
  115. snakeorm/helpers/annotations.py +21 -0
  116. snakeorm/helpers/inheritance.py +41 -0
  117. snakeorm/helpers/pyliteral.py +52 -0
  118. snakeorm/helpers/pytype.py +50 -0
  119. snakeorm/introspection/__init__.py +13 -0
  120. snakeorm/introspection/base.py +30 -0
  121. snakeorm/introspection/drift.py +78 -0
  122. snakeorm/introspection/models.py +633 -0
  123. snakeorm/introspection/mysql.py +367 -0
  124. snakeorm/introspection/postgres.py +397 -0
  125. snakeorm/introspection/sqlite.py +217 -0
  126. snakeorm/introspection/unsupported.py +94 -0
  127. snakeorm/linker/__init__.py +3 -0
  128. snakeorm/linker/linker.py +521 -0
  129. snakeorm/metadata/__init__.py +41 -0
  130. snakeorm/metadata/check.py +37 -0
  131. snakeorm/metadata/column.py +201 -0
  132. snakeorm/metadata/enum_storage.py +33 -0
  133. snakeorm/metadata/fk_action.py +19 -0
  134. snakeorm/metadata/foreign_key.py +26 -0
  135. snakeorm/metadata/index.py +43 -0
  136. snakeorm/metadata/index_method.py +19 -0
  137. snakeorm/metadata/int_size.py +20 -0
  138. snakeorm/metadata/json_storage.py +21 -0
  139. snakeorm/metadata/polymorphic.py +30 -0
  140. snakeorm/metadata/primary_key.py +23 -0
  141. snakeorm/metadata/relationship.py +60 -0
  142. snakeorm/metadata/relationship_kind.py +43 -0
  143. snakeorm/metadata/routine.py +26 -0
  144. snakeorm/metadata/server_default.py +20 -0
  145. snakeorm/metadata/table.py +97 -0
  146. snakeorm/metadata/table_kind.py +23 -0
  147. snakeorm/metadata/trigger.py +62 -0
  148. snakeorm/metadata/type_params.py +348 -0
  149. snakeorm/migration/__init__.py +88 -0
  150. snakeorm/migration/asyncrunner.py +168 -0
  151. snakeorm/migration/autodetect.py +96 -0
  152. snakeorm/migration/ddl.py +1172 -0
  153. snakeorm/migration/diff.py +701 -0
  154. snakeorm/migration/loader.py +77 -0
  155. snakeorm/migration/operations.py +952 -0
  156. snakeorm/migration/planner.py +78 -0
  157. snakeorm/migration/realize.py +420 -0
  158. snakeorm/migration/renames.py +153 -0
  159. snakeorm/migration/render.py +1095 -0
  160. snakeorm/migration/runner.py +314 -0
  161. snakeorm/migration/squash.py +60 -0
  162. snakeorm/migration/state.py +107 -0
  163. snakeorm/model.py +162 -0
  164. snakeorm/py.typed +0 -0
  165. snakeorm/query/__init__.py +9 -0
  166. snakeorm/query/compound.py +288 -0
  167. snakeorm/query/join_kind.py +19 -0
  168. snakeorm/query/joined.py +143 -0
  169. snakeorm/query/query.py +1001 -0
  170. snakeorm/query/recursive.py +244 -0
  171. snakeorm/registry/__init__.py +5 -0
  172. snakeorm/registry/by_module.py +163 -0
  173. snakeorm/registry/registry.py +166 -0
  174. snakeorm/session/__init__.py +8 -0
  175. snakeorm/session/asyncsession.py +638 -0
  176. snakeorm/session/coercion.py +306 -0
  177. snakeorm/session/factory.py +29 -0
  178. snakeorm/session/guards.py +269 -0
  179. snakeorm/session/isolation.py +23 -0
  180. snakeorm/session/mapper.py +213 -0
  181. snakeorm/session/planning.py +815 -0
  182. snakeorm/session/retry.py +96 -0
  183. snakeorm/session/session.py +871 -0
  184. snakeorm/session/shared.py +355 -0
  185. snakeorm/sql/__init__.py +18 -0
  186. snakeorm/sql/adapt.py +104 -0
  187. snakeorm/sql/aggregate.py +232 -0
  188. snakeorm/sql/condition.py +359 -0
  189. snakeorm/sql/delete.py +46 -0
  190. snakeorm/sql/insert.py +149 -0
  191. snakeorm/sql/joins.py +158 -0
  192. snakeorm/sql/pk_subquery.py +54 -0
  193. snakeorm/sql/refs.py +19 -0
  194. snakeorm/sql/resolver.py +28 -0
  195. snakeorm/sql/select.py +175 -0
  196. snakeorm/sql/update.py +82 -0
  197. snakeorm/sql/value.py +552 -0
  198. 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}'.")