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,1001 @@
1
+ """SnakeQuery: an IMMUTABLE builder of queries over a compiled model.
2
+
3
+ `.filter()`/`.order_by()`/... accumulate state by returning a NEW query; `.to_sql(dialect)` compiles
4
+ to `(sql, params)`. EXECUTION does not live here (that is `session/`).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping, Sequence
10
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
11
+
12
+ from snakeorm.dialects import SnakeDialect
13
+ from snakeorm.core.exceptions import (
14
+ SnakeEmitError,
15
+ SnakeRegistryError,
16
+ SnakeUnknownColumn,
17
+ SnakeUnknownRelationship,
18
+ SnakeUnsupportedFeature,
19
+ )
20
+ from snakeorm.expressions import (
21
+ SnakeAnd,
22
+ SnakeCondition,
23
+ SnakeExpr,
24
+ SnakeLock,
25
+ SnakeOrder,
26
+ SnakeSubquery,
27
+ SnakeValue,
28
+ condition_paths,
29
+ )
30
+ from snakeorm.fields.relationship import SnakeCollection, SnakePathProxy, SnakePrefetch
31
+ from snakeorm.helpers.inheritance import is_abstract
32
+ from snakeorm.metadata import (
33
+ SnakeRelationshipKind,
34
+ SnakeRelationshipInfo,
35
+ SnakeTableInfo,
36
+ )
37
+ from snakeorm.query.compound import (
38
+ SnakeCompoundBranch,
39
+ SnakeCompound,
40
+ SnakeSetOp,
41
+ _compose,
42
+ )
43
+ from snakeorm.query.join_kind import SnakeJoin
44
+ from snakeorm.query.recursive import SnakeRecursive, build_recursive
45
+ from snakeorm.registry import SnakeRegistry, registry, registry_of
46
+ from snakeorm.sql import (
47
+ JoinPlan,
48
+ emit_count,
49
+ emit_delete,
50
+ emit_delete_pk_in_subquery,
51
+ emit_exists,
52
+ emit_project,
53
+ emit_select,
54
+ emit_select_with_includes,
55
+ emit_update,
56
+ emit_update_pk_in_subquery,
57
+ )
58
+
59
+ if TYPE_CHECKING:
60
+ # Import for the checker only: `compound` imports this module (it needs the type of the
61
+ # branches), so at module level it would be a cycle. The composition methods import it inside.
62
+ from snakeorm.query.joined import SnakeJoinedQuery
63
+
64
+ T = TypeVar("T")
65
+ V = TypeVar("V")
66
+ M = TypeVar("M")
67
+
68
+ IncludeSegment = tuple[tuple[str, ...], type, SnakeTableInfo]
69
+
70
+
71
+ def _include_path(relation: type[Any] | SnakeCollection[Any]) -> tuple[str, ...]:
72
+ """Extracts the path of a class navigation for `.include()`.
73
+
74
+ A to-many arrives as a `SnakeCollection` (`.path`); a to-one as a `SnakePathProxy` (even though
75
+ the checker sees it as `type[M]`), with the path in `._path`. A column is not navigable: it gets
76
+ rejected in plain words.
77
+ """
78
+ if isinstance(relation, SnakeCollection):
79
+ return relation.path
80
+ if isinstance(relation, SnakePathProxy):
81
+ return relation._path
82
+ # A column (`SnakeExpr`) is not navigable: `.include(Model.column)` makes no sense. The checker
83
+ # already rejects it (`[arg-type]`), but whoever skips the types deserves a clear message and not
84
+ # an `AttributeError: 'SnakeExpr' has no '_path'` from the builder's guts.
85
+ detail = (
86
+ f" (got {'.'.join(relation.path)})" if isinstance(relation, SnakeExpr) else ""
87
+ )
88
+ raise SnakeUnknownRelationship(
89
+ f"`.include()` expects a RELATIONSHIP (a `SnakeToOne`/`SnakeToMany`), not a column and "
90
+ f"not an expression{detail}. To bring related data in, navigate to the relationship, "
91
+ f"e.g. `.include(Car.brand)`."
92
+ )
93
+
94
+
95
+ def _relationship(table: SnakeTableInfo, name: str) -> SnakeRelationshipInfo:
96
+ """Looks a relationship up by name in the table; fails in plain words if it does not exist."""
97
+ for relationship in table.relationships:
98
+ if relationship.name == name:
99
+ return relationship
100
+ raise SnakeUnknownRelationship(
101
+ f"'{table.name}' does not have a relationship named '{name}'."
102
+ )
103
+
104
+
105
+ def _discriminator_filter(table: SnakeTableInfo) -> tuple[SnakeCondition, ...]:
106
+ """The `WHERE kind = 'dog'` of a polymorphic child; empty for any other table.
107
+
108
+ The BASE carries no filter: querying `Animal` must see the whole hierarchy.
109
+ """
110
+ if not table.is_polymorphic_child:
111
+ return ()
112
+ assert table.polymorphic is not None # is_polymorphic_child guarantees it
113
+ column: SnakeExpr[str] = SnakeExpr(path=(table.polymorphic.column,))
114
+ return (column == table.polymorphic.value,)
115
+
116
+
117
+ # Slots that are NOT a knob: the query's identity and its WHERE, which every emitter honours.
118
+ # Everything else in `__slots__` is something the caller ASKED for, and `_guard_unsupported` derives
119
+ # the list from there so a new knob cannot be left out of the guard.
120
+ _NOT_A_KNOB = frozenset({"_model", "_table", "_filters", "_registry"})
121
+ """Slots that are NOT something the caller asked the emitter to write.
122
+
123
+ `_registry` belongs here and forgetting it breaks EVERY select: the guards derive their list
124
+ from `__slots__` by comparing against a pristine query, and a query built on a custom registry
125
+ differs from the default one in exactly that slot — so every plain SELECT would refuse itself
126
+ with "does not emit registry()"."""
127
+
128
+ # `_prefetches` and `_includes` are honoured by the plain SELECT even though it writes NEITHER: the
129
+ # SESSION resolves them afterwards, one query per level, and `session.py:274` takes exactly this
130
+ # route when every include is a to-many. They are DEFERRED, not dropped, and the difference is the
131
+ # whole point of this guard — refusing them here turned eighteen green tests red, which is how the
132
+ # distinction got measured rather than assumed.
133
+ _HONOURED_BY_SELECT = frozenset(
134
+ {
135
+ "_order",
136
+ "_limit",
137
+ "_offset",
138
+ "_distinct",
139
+ "_lock",
140
+ "_includes",
141
+ "_prefetches",
142
+ "_columns",
143
+ }
144
+ )
145
+
146
+ # A COUNT ignores order/limit/offset by design (they do not change how many rows match) and the
147
+ # includes do not change the number of ROOT rows either. What it cannot ignore is `group_by`,
148
+ # `having` and `distinct`: each of those makes the answer a different number.
149
+ _HONOURED_BY_COUNT = frozenset(
150
+ {"_order", "_limit", "_offset", "_includes", "_prefetches"}
151
+ )
152
+ _HONOURED_BY_INCLUDES = frozenset(
153
+ {"_order", "_limit", "_offset", "_includes", "_prefetches"}
154
+ )
155
+
156
+ # How a knob is SPELT by the person who set it. A message that says `lock` sends the reader looking
157
+ # for something they never typed; they typed `for_update()`. Only the knobs whose slot name differs
158
+ # from their method need an entry — the rest fall back to the slot, so this cannot go stale into a
159
+ # wrong answer, only into a duller one.
160
+ _KNOB_SPELLING = {
161
+ "_lock": "for_update()",
162
+ "_includes": "include()",
163
+ "_prefetches": "include()",
164
+ "_order": "order_by()",
165
+ "_columns": "only()/defer()",
166
+ }
167
+ """How each knob is SPELLED to the user. The fallback —the slot without its underscore— only works
168
+ where the two happen to match, and where they do not the message names a method that does not
169
+ exist: `_order` came out as `order()`, `_prefetches` as `prefetches()`. Somebody reading that goes
170
+ looking for a call they never made."""
171
+
172
+ # What to do INSTEAD, per knob rather than per path. The remedy each caller passes describes its own
173
+ # emitter —"count the projection", "lock in a separate query"— and that is right until two different
174
+ # knobs are dropped at once: the message then names `columns()` and advises about locking, which
175
+ # reads like the ORM answering a question nobody asked. Measured on the first `only().include()`.
176
+ #
177
+ # Only the knobs whose remedy does not depend on the path get an entry, which is why this is small.
178
+ _KNOB_REMEDY = {
179
+ "_columns": (
180
+ "Ask for the columns with select(...) if what you want is the values, or drop the "
181
+ "only()/defer()."
182
+ ),
183
+ }
184
+
185
+
186
+ class SnakeQuery(Generic[T]):
187
+ """Typed query over a model. Immutable: every method returns a new query."""
188
+
189
+ __slots__ = (
190
+ "_model",
191
+ "_table",
192
+ "_filters",
193
+ "_order",
194
+ "_limit",
195
+ "_offset",
196
+ "_includes",
197
+ "_prefetches",
198
+ "_group_by",
199
+ "_having",
200
+ "_distinct",
201
+ "_lock",
202
+ "_columns",
203
+ "_registry",
204
+ )
205
+
206
+ def __init__(
207
+ self, model: type[T], *, registry: SnakeRegistry | None = None
208
+ ) -> None:
209
+ """The query and the REGISTRY it resolves against, which is the model's unless told otherwise.
210
+
211
+ It used to ask the global registry unconditionally, so `@snake_model(registry=reg)`
212
+ produced a model that could not be queried — and said so with a message that MISDIRECTED:
213
+ "is it missing @snake_model?", with the decorator right there. The answer was one `getattr`
214
+ away the whole time.
215
+
216
+ `registry=` is for the case where the model itself does not say: a `SnakeRow`, or a query
217
+ built before the decorator ran. Otherwise the model's own answer wins, which is what makes
218
+ navigation, the session and the emitter agree without anybody threading it through.
219
+ """
220
+ registry = registry_of(model) if registry is None else registry
221
+ table = registry.table_of(model)
222
+ if table is None:
223
+ # An abstract base is NOT an oversight, and suggesting @snake_model would be sending
224
+ # them off to turn into a table something that exists precisely so as not to be one.
225
+ if is_abstract(model):
226
+ raise SnakeRegistryError(
227
+ f"{model.__name__} is an abstract base (@snake_abstract): it contributes its "
228
+ f"columns to the tables that inherit from it, but it is not a table and "
229
+ f"cannot be queried. Query the concrete model that inherits it."
230
+ )
231
+ raise SnakeRegistryError(
232
+ f"{model.__name__} is not registered: is it missing @snake_model?"
233
+ )
234
+ self._model = model
235
+ self._table = table
236
+ self._registry = registry
237
+ # A polymorphic CHILD starts out with its discriminator filtered: it shares a table with
238
+ # its siblings, so `session.all(Dog)` would otherwise also return cats hydrated as dogs.
239
+ # It goes in the constructor (not in the emitter) so every path inherits it for free (all,
240
+ # first, count, exists, delete_where...).
241
+ self._filters: tuple[SnakeCondition, ...] = _discriminator_filter(table)
242
+ self._order: tuple[SnakeOrder, ...] = ()
243
+ self._limit: int | None = None
244
+ self._offset: int | None = None
245
+ self._includes: tuple[tuple[str, ...], ...] = ()
246
+ self._prefetches: tuple[SnakePrefetch[Any], ...] = ()
247
+ self._group_by: tuple[SnakeValue[Any], ...] = ()
248
+ self._having: tuple[SnakeCondition, ...] = ()
249
+ self._distinct: bool = False
250
+ self._lock: SnakeLock | None = None
251
+ # The columns to bring, by NAME, or `None` for "all of them". It is the resolved set and not
252
+ # the user's two verbs, because `only` and `defer` are one question asked from two sides and
253
+ # keeping both would mean every reader downstream doing the subtraction again.
254
+ self._columns: frozenset[str] | None = None
255
+
256
+ @property
257
+ def projected_columns(self) -> frozenset[str] | None:
258
+ """The columns `only()`/`defer()` asked for, or `None` for every one of them.
259
+
260
+ The session asks for this rather than counting the row's width: two columns of one table can
261
+ be projected two ways, and lining a plan up by counting is how a value lands on the wrong
262
+ attribute without anybody noticing.
263
+ """
264
+ return self._columns
265
+
266
+ @property
267
+ def model(self) -> type[T]:
268
+ """The model being queried. The session uses it to map the rows back to T."""
269
+ return self._model
270
+
271
+ def filter(self, *conditions: SnakeCondition) -> SnakeQuery[T]:
272
+ """Adds conditions (AND). Returns a NEW query; the current one is untouched."""
273
+ clone = self._clone()
274
+ clone._filters = (*self._filters, *conditions)
275
+ return clone
276
+
277
+ @property
278
+ def registry(self) -> SnakeRegistry:
279
+ """The registry this query resolves against: the model's own unless one was passed.
280
+
281
+ Public because the SESSION has to ask it. Reaching for the global registry there is the same
282
+ defect one layer up: it works until two models share a class name, or until somebody uses
283
+ `@snake_model(registry=...)`, and then it is wrong in silence.
284
+ """
285
+ return self._registry
286
+
287
+ def order_by(self, *keys: SnakeExpr[Any] | SnakeOrder) -> SnakeQuery[T]:
288
+ """Adds ordering keys. A bare column orders ascending."""
289
+ normalized = tuple(
290
+ key if isinstance(key, SnakeOrder) else key.asc() for key in keys
291
+ )
292
+ clone = self._clone()
293
+ clone._order = (*self._order, *normalized)
294
+ return clone
295
+
296
+ def limit(self, limit: int) -> SnakeQuery[T]:
297
+ """Sets the LIMIT (it replaces the previous one). Returns a new query."""
298
+ clone = self._clone()
299
+ clone._limit = limit
300
+ return clone
301
+
302
+ def offset(self, offset: int) -> SnakeQuery[T]:
303
+ """Sets the OFFSET (it replaces the previous one). Returns a new query."""
304
+ clone = self._clone()
305
+ clone._offset = offset
306
+ return clone
307
+
308
+ def group_by(self, *columns: SnakeValue[Any]) -> SnakeQuery[T]:
309
+ """Groups by the given columns. Returns a NEW query; the current one is untouched."""
310
+ clone = self._clone()
311
+ clone._group_by = (*self._group_by, *columns)
312
+ return clone
313
+
314
+ def having(self, condition: SnakeCondition) -> SnakeQuery[T]:
315
+ """Filters over the groups (cumulative with AND, just like `.filter()`)."""
316
+ clone = self._clone()
317
+ clone._having = (*self._having, condition)
318
+ return clone
319
+
320
+ def distinct(self) -> SnakeQuery[T]:
321
+ """Marks the query as `SELECT DISTINCT`. Returns a new query.
322
+
323
+ It applies to the full SELECT and to the projection. Standard `DISTINCT` only (no `DISTINCT ON`).
324
+ """
325
+ clone = self._clone()
326
+ clone._distinct = True
327
+ return clone
328
+
329
+ def only(self, *columns: SnakeValue[Any]) -> SnakeQuery[T]:
330
+ """Brings ONLY these columns (plus the primary key). Returns a new query.
331
+
332
+ What it buys is bytes on a wide table; what it costs is an instance that is not whole, and
333
+ reading a column left out RAISES rather than handing back the column's default. That refusal
334
+ is the feature: without it a deferred name reads as `None` and the caller never learns.
335
+
336
+ The PRIMARY KEY comes back whether it was named or not, and that is not a convenience. An
337
+ instance with no identity cannot be updated, deleted, or matched to the children of a
338
+ prefetch — it would be a tuple with methods.
339
+
340
+ When what you want is the VALUES rather than the model, `session.select(query, a, b)` is the
341
+ better tool: typed tuples, no half-built instance, and nothing that can raise later.
342
+ """
343
+ return self._with_columns(self._named(columns), verb="only")
344
+
345
+ def defer(self, *columns: SnakeValue[Any]) -> SnakeQuery[T]:
346
+ """Brings everything EXCEPT these columns. Returns a new query.
347
+
348
+ The other side of `only()`, and the same warning applies to what comes back. Deferring the
349
+ primary key is refused: see `only()`.
350
+ """
351
+ deferred = self._named(columns)
352
+ keys = {column.name for column in self._table.primary_key.columns}
353
+ if deferred & keys:
354
+ raise SnakeUnsupportedFeature(
355
+ f"the primary key cannot be deferred ({', '.join(sorted(deferred & keys))}): a row "
356
+ f"with no identity cannot be written back nor matched to its relations. Deferring "
357
+ f"is for the wide columns a page does not print."
358
+ )
359
+ every = {column.name for column in self._table.columns}
360
+ return self._with_columns(every - deferred, verb="defer")
361
+
362
+ def _named(self, columns: tuple[SnakeValue[Any], ...]) -> set[str]:
363
+ """The column NAMES behind the given expressions, refusing anything this table lacks."""
364
+ known = {column.name for column in self._table.columns}
365
+ names: set[str] = set()
366
+ for column in columns:
367
+ path = getattr(column, "path", None)
368
+ if path is None or len(path) != 1 or path[0] not in known:
369
+ raise SnakeUnknownColumn(
370
+ f"only()/defer() take columns of {self._model.__name__} itself; got "
371
+ f"{column!r}. A column of a related model cannot be deferred: it arrives "
372
+ f"through its own include()."
373
+ )
374
+ names.add(path[0])
375
+ return names
376
+
377
+ def _with_columns(self, columns: set[str], *, verb: str) -> SnakeQuery[T]:
378
+ """Records the resolved column set, refusing a second, contradictory answer."""
379
+ if self._columns is not None:
380
+ raise SnakeUnsupportedFeature(
381
+ f"this query already names the columns to bring; a second {verb}() would be a "
382
+ f"second answer to one question. Say it once, with every column it needs."
383
+ )
384
+ if not columns:
385
+ raise SnakeUnsupportedFeature(
386
+ f"{verb}() would leave the row with no columns at all"
387
+ )
388
+ clone = self._clone()
389
+ # The PK always travels, whether or not it was named: an instance without identity cannot be
390
+ # updated, deleted, or matched to the children of a prefetch.
391
+ clone._columns = frozenset(
392
+ columns | {column.name for column in self._table.primary_key.columns}
393
+ )
394
+ return clone
395
+
396
+ def for_update(
397
+ self, *, nowait: bool = False, skip_locked: bool = False
398
+ ) -> SnakeQuery[T]:
399
+ """Locks the selected rows until the end of the transaction (`SELECT ... FOR UPDATE`).
400
+
401
+ It is HALF of concurrency control (the other half is the isolation level). `nowait` fails if
402
+ the row is already locked; `skip_locked` ignores it (the queue pattern): they are opposites,
403
+ asking for both is an error.
404
+ """
405
+ if nowait and skip_locked:
406
+ raise SnakeUnsupportedFeature(
407
+ 'nowait and skip_locked are mutually exclusive: one says "if it is locked, fail" '
408
+ 'and the other "if it is locked, skip it". Choose the behaviour you want.'
409
+ )
410
+ if self._table.is_view:
411
+ raise SnakeUnsupportedFeature(
412
+ f"'{self._table.name}' is a READ-ONLY view: there is nothing of its own to lock. "
413
+ f"Lock the rows of the base table."
414
+ )
415
+ clone = self._clone()
416
+ if skip_locked:
417
+ clone._lock = SnakeLock.SKIP_LOCKED
418
+ elif nowait:
419
+ clone._lock = SnakeLock.NOWAIT
420
+ else:
421
+ clone._lock = SnakeLock.WAIT
422
+ return clone
423
+
424
+ def join(
425
+ self, collection: SnakeCollection[M], how: SnakeJoin = SnakeJoin.INNER
426
+ ) -> SnakeJoinedQuery[T, M]:
427
+ """EXPLICITLY joins a collection (to-many) to get THE CHILD'S ROWS into the projection.
428
+
429
+ It returns a `SnakeJoinedQuery`, a different type that only projects: a JOIN onto a
430
+ collection multiplies rows (a disaster for models), so it has no `.all()`/`.first()`. `how`
431
+ chooses whether childless parents show up.
432
+ """
433
+ from snakeorm.query.joined import SnakeJoinedQuery, _resolve_join
434
+
435
+ spec = _resolve_join(self._model, collection, how)
436
+ return SnakeJoinedQuery(self, (spec,))
437
+
438
+ def as_scalar(self, column: SnakeExpr[V]) -> SnakeSubquery[V]:
439
+ """Wraps the query as a scalar subquery of ONE column, for `.in_(...)`.
440
+
441
+ DIRECT column and flat WHERE only: navigating a relationship would demand a JOIN inside the
442
+ subquery, which is not built yet, so it gets rejected in plain words.
443
+ """
444
+ if len(column.path) != 1:
445
+ raise SnakeUnsupportedFeature(
446
+ "a scalar subquery projects a direct column of its table, not a deep navigation "
447
+ "of relationships."
448
+ )
449
+ where = self._combined_where()
450
+ if where is not None and any(len(path) > 1 for path in condition_paths(where)):
451
+ raise SnakeUnsupportedFeature(
452
+ "the WHERE of a scalar subquery cannot navigate relationships (the JOIN inside the "
453
+ "subquery is not built yet); filter by direct columns."
454
+ )
455
+ return SnakeSubquery(
456
+ schema=self._table.schema,
457
+ name=self._table.name,
458
+ column=column.path[0],
459
+ where=where,
460
+ )
461
+
462
+ def include(
463
+ self, *relations: type[Any] | SnakeCollection[Any] | SnakePrefetch[Any]
464
+ ) -> SnakeQuery[T]:
465
+ """Asks for relationships to be loaded (eagerly). Returns a new query.
466
+
467
+ To-one (`User.car`, proxies carrying a path) -> LEFT JOIN; to-many (`SnakeCollection`) ->
468
+ select-in. To nest to-many you pass a `SnakePrefetch(...).then(...)` (the collection does not
469
+ expose the child's relationships). Without `.include()`, touching the relationship blows up
470
+ with `SnakeRelationshipNotLoaded` (there is no silent N+1).
471
+ """
472
+ prefetches = tuple(r for r in relations if isinstance(r, SnakePrefetch))
473
+ paths = tuple(
474
+ _include_path(r) for r in relations if not isinstance(r, SnakePrefetch)
475
+ )
476
+ clone = self._clone()
477
+ clone._includes = (*self._includes, *paths)
478
+ clone._prefetches = (*self._prefetches, *prefetches)
479
+ return clone
480
+
481
+ @property
482
+ def has_includes(self) -> bool:
483
+ """Tells whether the query asks for relationships to be loaded (it picks the session's route)."""
484
+ return bool(self._includes) or bool(self._prefetches)
485
+
486
+ @property
487
+ def has_lock(self) -> bool:
488
+ """Tells whether the query asks for rows to be locked (`for_update`). The compound looks at this."""
489
+ return self._lock is not None
490
+
491
+ @property
492
+ def has_bounds(self) -> bool:
493
+ """Tells whether the query carries `limit`/`offset` of its own. The compound looks at this."""
494
+ return self._limit is not None or self._offset is not None
495
+
496
+ @property
497
+ def has_order(self) -> bool:
498
+ """Tells whether the query carries `order_by` of its own. The compound looks at this.
499
+
500
+ The same question as `has_bounds` and for the same reason: both need the branch's
501
+ parentheses to stay inside the branch, and only one of the two was being asked.
502
+ """
503
+ return bool(self._order)
504
+
505
+ @property
506
+ def has_cte(self) -> bool:
507
+ """Whether the emitted SQL opens with a `WITH`. A plain query never does."""
508
+ return False
509
+
510
+ def prefetches(self) -> tuple[SnakePrefetch[Any], ...]:
511
+ """Nested prefetch chains (deep to-many); the session resolves them level by level."""
512
+ return self._prefetches
513
+
514
+ def to_one_includes(self) -> tuple[tuple[str, ...], ...]:
515
+ """Includes that are to-one chains (loaded with a JOIN in the same query)."""
516
+ return self._classify_includes()[0]
517
+
518
+ def to_many_includes(self) -> tuple[SnakeRelationshipInfo, ...]:
519
+ """To-many relationships to load (resolved with a separate select-in, in the session)."""
520
+ return self._classify_includes()[1]
521
+
522
+ def _classify_includes(
523
+ self,
524
+ ) -> tuple[tuple[tuple[str, ...], ...], tuple[SnakeRelationshipInfo, ...]]:
525
+ """Splits the includes into to-one chains (JOIN) and to-many relationships (select-in).
526
+
527
+ By direct navigation only these are admitted: to-one chains and a to-many of ONE hop.
528
+ Nesting to-many or mixing them is done with `SnakePrefetch(...)`.
529
+ """
530
+ to_one: list[tuple[str, ...]] = []
531
+ to_many: list[SnakeRelationshipInfo] = []
532
+ for path in self._includes:
533
+ kinds = self._hop_kinds(path)
534
+ # A SINGLE to-many hop (direct or through a bridge) -> select-in. It is compared against
535
+ # the enum, not against tuples of strings (that comparison stayed False forever and
536
+ # disabled to-many loading; mypy caught it).
537
+ if kinds in (
538
+ (SnakeRelationshipKind.TO_MANY,),
539
+ (SnakeRelationshipKind.TO_MANY_THROUGH,),
540
+ ):
541
+ to_many.append(_relationship(self._table, path[0]))
542
+ elif all(kind is SnakeRelationshipKind.TO_ONE for kind in kinds):
543
+ to_one.append(path)
544
+ else:
545
+ raise SnakeUnsupportedFeature(
546
+ f"an include with mixed navigation or a deep to-many is not navigable: {path}. "
547
+ f"To nest to-many use SnakePrefetch(<collection>).then(<relationship>)."
548
+ )
549
+ return tuple(to_one), tuple(to_many)
550
+
551
+ def _hop_kinds(self, path: tuple[str, ...]) -> tuple[SnakeRelationshipKind, ...]:
552
+ """The cardinality of each hop of the path (walking the linked graph)."""
553
+ kinds: list[SnakeRelationshipKind] = []
554
+ table: SnakeTableInfo | None = self._table
555
+ for step in path:
556
+ if table is None:
557
+ break
558
+ relationship = _relationship(table, step)
559
+ kinds.append(relationship.kind)
560
+ table = registry.resolve_relationship(relationship)[0]
561
+ return tuple(kinds)
562
+
563
+ def include_segments(self) -> tuple[IncludeSegment, ...]:
564
+ """Segments to load in order: root + each relationship prefix (parent before child).
565
+
566
+ Each segment is `(prefix, model, table)`. It includes ALL the prefixes
567
+ (`.include(User.car.brand)` also loads `car`, so brand can be nested inside car).
568
+ """
569
+ segments: list[IncludeSegment] = [((), self._model, self._table)]
570
+ for prefix in self._include_prefixes():
571
+ model: type = self._model
572
+ table = self._table
573
+ for step in prefix:
574
+ relationship = _relationship(table, step)
575
+ resolved_table, resolved_model = registry.resolve_relationship(
576
+ relationship
577
+ )
578
+ if resolved_model is None or resolved_table is None:
579
+ raise SnakeRegistryError(
580
+ f"The target '{relationship.target}' of '{step}' is not registered."
581
+ )
582
+ model, table = resolved_model, resolved_table
583
+ segments.append((prefix, model, table))
584
+ return tuple(segments)
585
+
586
+ def to_include_sql(self, dialect: SnakeDialect) -> tuple[str, tuple[object, ...]]:
587
+ """Compiles the SELECT that loads the root + the included relationships (with their LEFT JOINs)."""
588
+ self._guard_unsupported(
589
+ "A SELECT with include(...)",
590
+ _HONOURED_BY_INCLUDES,
591
+ "Lock the rows in a separate query on the table you want to reserve, then load the "
592
+ "relationships.",
593
+ )
594
+ where = self._combined_where()
595
+ column_paths = condition_paths(where) if where is not None else []
596
+ column_paths = [
597
+ *column_paths,
598
+ *(p for key in self._order for p in key.expr.paths()),
599
+ ]
600
+ plan = JoinPlan(
601
+ self._table,
602
+ column_paths,
603
+ dialect,
604
+ self._registry,
605
+ relationship_paths=self.to_one_includes(),
606
+ )
607
+ segments = [
608
+ (prefix, table) for prefix, _model, table in self.include_segments()
609
+ ]
610
+ return emit_select_with_includes(
611
+ dialect,
612
+ segments,
613
+ plan,
614
+ where=where,
615
+ order_by=self._order,
616
+ limit=self._limit,
617
+ offset=self._offset,
618
+ )
619
+
620
+ def _include_prefixes(self) -> list[tuple[str, ...]]:
621
+ """Unique prefixes of the to-one includes, sorted parent-before-child (for the JOIN)."""
622
+ prefixes: set[tuple[str, ...]] = set()
623
+ for path in self.to_one_includes():
624
+ for length in range(1, len(path) + 1):
625
+ prefixes.add(path[:length])
626
+ return sorted(prefixes, key=lambda prefix: (len(prefix), prefix))
627
+
628
+ def union(self, other: SnakeCompoundBranch[T]) -> SnakeCompound[T]:
629
+ """`UNION`: the rows of both, WITHOUT duplicates.
630
+
631
+ The type demands the SAME model, a guarantee SQL does not give (it is happy if the columns line up).
632
+ """
633
+
634
+ return _compose(SnakeSetOp.UNION, self, other)
635
+
636
+ def union_all(self, other: SnakeCompoundBranch[T]) -> SnakeCompound[T]:
637
+ """`UNION ALL`: the rows of both, KEEPING duplicates.
638
+
639
+ It is not an optimised `UNION`: a plain `UNION` deduplicates the whole result. Both exist so
640
+ the choice is made consciously.
641
+ """
642
+
643
+ return _compose(SnakeSetOp.UNION_ALL, self, other)
644
+
645
+ def except_(self, other: SnakeCompoundBranch[T]) -> SnakeCompound[T]:
646
+ """`EXCEPT`: the ones of this query that are NOT in the other. Trailing underscore: `except` is a reserved word."""
647
+
648
+ return _compose(SnakeSetOp.EXCEPT, self, other)
649
+
650
+ def intersect(self, other: SnakeCompoundBranch[T]) -> SnakeCompound[T]:
651
+ """`INTERSECT`: only the rows that are in BOTH queries."""
652
+
653
+ return _compose(SnakeSetOp.INTERSECT, self, other)
654
+
655
+ def recursive(
656
+ self, *, on: tuple[SnakeExpr[Any], SnakeExpr[Any]], distinct: bool = False
657
+ ) -> SnakeRecursive[T]:
658
+ """Expands this query by following a hop onto ITSELF (`WITH RECURSIVE`).
659
+
660
+ This query is the ANCHOR and `on` is the pair of columns that chains each level: first the
661
+ one pointing upwards, then the one identifying the row reached. Swapping it walks the
662
+ ANCESTORS (perfectly legitimate).
663
+
664
+ SnakeQuery(Category).filter(Category.id == 1).recursive(on=(Category.parent_id, Category.id))
665
+
666
+ `distinct` picks the operator joining each step: `False` (default) emits `UNION ALL`, what a
667
+ TREE wants; `True` emits `UNION`, so each step drops the rows already seen.
668
+
669
+ **Pass `distinct=True` if the data may have CYCLES**, which only the caller knows — a
670
+ self-referencing FK admits one. With `UNION ALL` a cyclic walk never ends: every lap yields
671
+ rows the engine counts as new, so it hangs instead of failing. `limit()` is NOT a
672
+ substitute — it bounds what comes back, not the walk. Measured on Postgres, a cyclic walk
673
+ with `order_by()` and `LIMIT 3` never returns.
674
+ """
675
+
676
+ return build_recursive(self, self._table, on[0], on[1], distinct)
677
+
678
+ def _guard_unsupported(
679
+ self, path: str, honoured: frozenset[str], remedy: str
680
+ ) -> None:
681
+ """Refuses the knobs this emitter carries but does NOT emit.
682
+
683
+ A knob that is set and not emitted is a question the caller asked and got no answer to,
684
+ while still getting AN answer — which is the worst shape a failure can take. Three read
685
+ paths did exactly that: the plain SELECT swallowed `group_by`/`having`, the COUNT swallowed
686
+ those plus `distinct` (so a paginator counted rows instead of groups), and the SELECT with
687
+ includes swallowed `for_update` (so a lock the caller asked for was never taken).
688
+
689
+ `honoured` is what the emitter DOES write; everything else is derived from `__slots__`, so a
690
+ tenth knob added to this class is guarded the day it appears instead of the day someone
691
+ remembers. That is the difference between this and `_guard_bulk_write`, which lists its
692
+ knobs by hand and is one `or` away from the same hole.
693
+ """
694
+ carried = {
695
+ slot
696
+ for slot in self.__slots__
697
+ if slot not in _NOT_A_KNOB and getattr(self, slot)
698
+ }
699
+ missing = carried - honoured
700
+ dropped = sorted(
701
+ _KNOB_SPELLING.get(slot, f"{slot.lstrip('_')}()") for slot in missing
702
+ )
703
+ if dropped:
704
+ # The per-knob remedies come first, and the path's own only if some dropped knob has
705
+ # none of its own. Appending it always is what produced "does not emit only()/defer()
706
+ # ... lock the rows in a separate query" — advice about a knob nobody had touched.
707
+ advice = [
708
+ _KNOB_REMEDY[slot] for slot in sorted(missing) if slot in _KNOB_REMEDY
709
+ ]
710
+ if not missing <= _KNOB_REMEDY.keys():
711
+ advice.append(remedy)
712
+ raise SnakeUnsupportedFeature(
713
+ f"{path} does not emit {', '.join(dropped)}, and dropping what you asked for "
714
+ f"would answer a different question without saying so. {' '.join(advice)}"
715
+ )
716
+
717
+ def to_sql(self, dialect: SnakeDialect) -> tuple[str, tuple[object, ...]]:
718
+ """Compiles the query to `(sql, params)`, generating JOINs if there is deep navigation."""
719
+ self._guard_unsupported(
720
+ "A plain SELECT",
721
+ _HONOURED_BY_SELECT,
722
+ "Use select(...) + group_by(...) to project a grouping.",
723
+ )
724
+ where = self._combined_where()
725
+ return emit_select(
726
+ self._table,
727
+ dialect,
728
+ where=where,
729
+ order_by=self._order,
730
+ limit=self._limit,
731
+ offset=self._offset,
732
+ plan=self._plan(where, dialect, include_order=True),
733
+ distinct=self._distinct,
734
+ lock=self._lock,
735
+ columns=self._columns,
736
+ )
737
+
738
+ def to_count_sql(self, dialect: SnakeDialect) -> tuple[str, tuple[object, ...]]:
739
+ """Compiles a `SELECT COUNT(*)` with the same filters/JOINs (it ignores order and limit)."""
740
+ self._guard_unsupported(
741
+ "A COUNT(*)",
742
+ _HONOURED_BY_COUNT,
743
+ "Count the projection with select(...) if you need the number of groups or of "
744
+ "distinct rows.",
745
+ )
746
+ where = self._combined_where()
747
+ return emit_count(
748
+ self._table, dialect, where=where, plan=self._plan(where, dialect)
749
+ )
750
+
751
+ def to_exists_sql(self, dialect: SnakeDialect) -> tuple[str, tuple[object, ...]]:
752
+ """Compiles a `SELECT EXISTS(...)` with the same filters/JOINs.
753
+
754
+ IT GUARDS THE SAME KNOBS AS THE COUNT, and it did not until this line was written. `exists`
755
+ was the one read path that called no guard at all, so it swallowed `group_by`, `having`,
756
+ `distinct` and `lock` in silence — an EXISTS over groups is a different question from an
757
+ EXISTS over rows, and the caller who asked got the second one with no word about it.
758
+
759
+ It is the same shape as entry #18 of the bug journal, which is about `count()` being fixed
760
+ and its two brothers being left. This is the third brother, found by adding a tenth knob and
761
+ noticing that one path did not refuse it.
762
+
763
+ What it honours is what a COUNT honours, for the same reasons: order, limit and offset do not
764
+ change whether a row exists, and the includes are resolved afterwards by the session.
765
+ """
766
+ self._guard_unsupported(
767
+ "A SELECT EXISTS",
768
+ _HONOURED_BY_COUNT,
769
+ "Ask it of the projection with select(...) if the question is about groups.",
770
+ )
771
+ where = self._combined_where()
772
+ return emit_exists(
773
+ self._table, dialect, where=where, plan=self._plan(where, dialect)
774
+ )
775
+
776
+ def to_update_sql(
777
+ self, dialect: SnakeDialect, values: Mapping[str, object]
778
+ ) -> tuple[str, tuple[object, ...]]:
779
+ """Compiles a BULK UPDATE with the query's WHERE (EXECUTION lives in the session).
780
+
781
+ The values arrive already as SQL column names. If the WHERE navigates a relationship it is
782
+ rewritten to `<pk> IN (subquery)` (no `UPDATE ... FROM`). The guards are put in place by
783
+ `_guard_bulk_write`.
784
+ """
785
+ self._guard_bulk_write("UPDATE")
786
+ where = self._combined_where()
787
+ deep_plan = self._deep_write_plan(where, dialect)
788
+ if deep_plan is not None:
789
+ assert where is not None # a deep plan exists => a WHERE exists
790
+ return emit_update_pk_in_subquery(
791
+ self._table, dialect, values, deep_plan, where
792
+ )
793
+ return emit_update(self._table, dialect, values, where=where)
794
+
795
+ def to_delete_sql(self, dialect: SnakeDialect) -> tuple[str, tuple[object, ...]]:
796
+ """Compiles a BULK DELETE with the query's WHERE (EXECUTION lives in the session).
797
+
798
+ Like `to_update_sql`: a WHERE that navigates is rewritten to `<pk> IN (subquery)` (no `DELETE ... FROM`).
799
+ """
800
+ self._guard_bulk_write("DELETE")
801
+ where = self._combined_where()
802
+ deep_plan = self._deep_write_plan(where, dialect)
803
+ if deep_plan is not None:
804
+ assert where is not None # a deep plan exists => a WHERE exists
805
+ return emit_delete_pk_in_subquery(self._table, dialect, deep_plan, where)
806
+ return emit_delete(self._table, dialect, where=where)
807
+
808
+ def _deep_write_plan(
809
+ self, where: SnakeCondition | None, dialect: SnakeDialect
810
+ ) -> JoinPlan | None:
811
+ """JoinPlan for the subquery of a deep bulk write, or None if the WHERE is flat.
812
+
813
+ It returns the plan only when some path navigates a relationship.
814
+ """
815
+ if where is None:
816
+ return None
817
+ paths = condition_paths(where)
818
+ if not any(len(path) > 1 for path in paths):
819
+ return None
820
+ return JoinPlan(self._table, paths, dialect, self._registry)
821
+
822
+ def _guard_bulk_write(self, verb: str) -> None:
823
+ """Guards the bulk write: WHERE only, with a filter, and not over a view.
824
+
825
+ - It only uses the filter: limit/offset/order_by/group_by/having/include are rejected (better
826
+ an error than ignoring them in silence).
827
+ - With no filter it would hit the WHOLE table: an explicit WHERE is demanded.
828
+ - A WHERE that navigates IS fine (it is rewritten to `<pk> IN (subquery)`).
829
+ - Over a VIEW there is no writing (a runtime reinforcement).
830
+ """
831
+ if self._table.is_view:
832
+ raise SnakeUnsupportedFeature(
833
+ f"'{self._table.name}' is a READ-ONLY view: it does not accept a bulk {verb}."
834
+ )
835
+ # DERIVED from `__slots__`, not listed by hand: a knob added to this class is guarded the
836
+ # day it appears rather than the day someone remembers. The hand-written chain named six of
837
+ # the ten and dropped four in SILENCE — `distinct()`, `for_update()`, `only()`/`defer()` and
838
+ # the `_prefetches` half of `include()`. A `delete_where(q.distinct())` emitted a plain
839
+ # DELETE and answered a different question.
840
+ #
841
+ # Compared against a PRISTINE query rather than by truthiness, and that is not style:
842
+ # `limit(0)`/`offset(0)` are legal and FALSY, so `getattr(self, slot)` would read `limit(0)`
843
+ # as "no limit set" and emit a DELETE with no limit at all — destroying every matching row
844
+ # for a caller who asked for at most zero.
845
+ pristine = type(self)(self._model)
846
+ carried = sorted(
847
+ slot
848
+ for slot in self.__slots__
849
+ if slot not in _NOT_A_KNOB
850
+ and getattr(self, slot) != getattr(pristine, slot)
851
+ )
852
+ if carried:
853
+ dropped = ", ".join(
854
+ _KNOB_SPELLING.get(slot, f"{slot.lstrip('_')}()") for slot in carried
855
+ )
856
+ raise SnakeUnsupportedFeature(
857
+ f"a bulk {verb} only uses the filter (WHERE), and it does not emit {dropped}. "
858
+ f"Dropping what you asked for would answer a different question without saying so: "
859
+ f"select the rows first if you need those, then write by primary key."
860
+ )
861
+ if self._combined_where() is None:
862
+ raise SnakeUnsupportedFeature(
863
+ f"a {verb} without a WHERE would affect the WHOLE table; if that is what you want, "
864
+ f"pass it an explicit filter with .filter(...)."
865
+ )
866
+
867
+ def to_project_sql(
868
+ self,
869
+ dialect: SnakeDialect,
870
+ columns: Sequence[SnakeValue[Any]],
871
+ explicit_joins: Sequence[tuple[tuple[str, ...], bool]] = (),
872
+ ) -> tuple[str, tuple[object, ...]]:
873
+ """Compiles a `SELECT <columns>` (projection) with filters, GROUP BY/HAVING and their JOINs.
874
+
875
+ The paths of columns, WHERE, GROUP BY and HAVING all go into the `JoinPlan` (that is how a
876
+ `group_by(Truck.maker.name)` generates its JOIN); `paths()` of a `COUNT(*)` is empty.
877
+ `explicit_joins` are the `.join()`s onto collections: if there are any, the plan is ALWAYS
878
+ built (the JOIN already demands aliases).
879
+ """
880
+ where = self._combined_where()
881
+ having = self._combined_having()
882
+ paths = condition_paths(where) if where is not None else []
883
+ paths = [
884
+ *paths,
885
+ *(p for column in columns for p in column.paths()),
886
+ *(p for key in self._order for p in key.expr.paths()),
887
+ *(p for column in self._group_by for p in column.paths()),
888
+ *(condition_paths(having) if having is not None else []),
889
+ ]
890
+ plan = None
891
+ if explicit_joins or any(len(path) > 1 for path in paths):
892
+ plan = JoinPlan(
893
+ self._table,
894
+ paths,
895
+ dialect,
896
+ self._registry,
897
+ explicit_joins=explicit_joins,
898
+ )
899
+ return emit_project(
900
+ self._table,
901
+ dialect,
902
+ columns,
903
+ where=where,
904
+ plan=plan,
905
+ group_by=self._group_by,
906
+ having=having,
907
+ order_by=self._order,
908
+ limit=self._limit,
909
+ offset=self._offset,
910
+ distinct=self._distinct,
911
+ )
912
+
913
+ def to_annotate_sql(
914
+ self, dialect: SnakeDialect, aggregates: Sequence[SnakeValue[Any]]
915
+ ) -> tuple[str, tuple[object, ...]]:
916
+ """Compiles the SELECT of `annotate()`: base model columns + aggregates, grouping by the PK.
917
+
918
+ The remaining columns depend functionally on the PK (Postgres accepts that). An explicit
919
+ `group_by` is an error: to group by something else there is `select()` + `group_by()`.
920
+ """
921
+ if self._group_by:
922
+ raise SnakeEmitError(
923
+ "annotate() groups by the PK of the base model and does not accept an explicit "
924
+ "group_by(). To group by another column use select() + group_by()."
925
+ )
926
+ base_columns: tuple[SnakeValue[Any], ...] = tuple(
927
+ SnakeExpr(path=(column.name,)) for column in self._table.columns
928
+ )
929
+ group_by: tuple[SnakeValue[Any], ...] = tuple(
930
+ SnakeExpr(path=(column.name,)) for column in self._table.primary_key.columns
931
+ )
932
+ columns: tuple[SnakeValue[Any], ...] = (*base_columns, *aggregates)
933
+ where = self._combined_where()
934
+ paths = condition_paths(where) if where is not None else []
935
+ paths = [
936
+ *paths,
937
+ *(p for column in columns for p in column.paths()),
938
+ *(p for key in self._order for p in key.expr.paths()),
939
+ ]
940
+ plan = None
941
+ if any(len(path) > 1 for path in paths):
942
+ plan = JoinPlan(self._table, paths, dialect, self._registry)
943
+ return emit_project(
944
+ self._table,
945
+ dialect,
946
+ columns,
947
+ where=where,
948
+ plan=plan,
949
+ group_by=group_by,
950
+ order_by=self._order,
951
+ limit=self._limit,
952
+ offset=self._offset,
953
+ functional_dependency=True,
954
+ )
955
+
956
+ def _plan(
957
+ self,
958
+ where: SnakeCondition | None,
959
+ dialect: SnakeDialect,
960
+ include_order: bool = False,
961
+ ) -> JoinPlan | None:
962
+ """Builds the JoinPlan if some path is deep; None if everything belongs to the root table."""
963
+ paths = condition_paths(where) if where is not None else []
964
+ if include_order:
965
+ paths = [*paths, *(p for key in self._order for p in key.expr.paths())]
966
+ if any(len(path) > 1 for path in paths):
967
+ return JoinPlan(self._table, paths, dialect, self._registry)
968
+ return None
969
+
970
+ def _clone(self) -> SnakeQuery[T]:
971
+ """Copies the current state into a new query (the basis of the immutability)."""
972
+ clone = SnakeQuery(self._model)
973
+ clone._filters = self._filters
974
+ clone._order = self._order
975
+ clone._limit = self._limit
976
+ clone._offset = self._offset
977
+ clone._includes = self._includes
978
+ clone._prefetches = self._prefetches
979
+ clone._group_by = self._group_by
980
+ clone._having = self._having
981
+ clone._distinct = self._distinct
982
+ clone._lock = self._lock
983
+ clone._columns = self._columns
984
+ return clone
985
+
986
+ def _combined_where(self) -> SnakeCondition | None:
987
+ """Combines every filter into a single condition (a flat AND), or None if there are none."""
988
+ return self._combine(self._filters)
989
+
990
+ def _combined_having(self) -> SnakeCondition | None:
991
+ """Combines the HAVING conditions into a single one (a flat AND), or None if there are none."""
992
+ return self._combine(self._having)
993
+
994
+ @staticmethod
995
+ def _combine(conditions: tuple[SnakeCondition, ...]) -> SnakeCondition | None:
996
+ """Reduces a tuple of conditions to a flat AND (or the only one, or None)."""
997
+ if not conditions:
998
+ return None
999
+ if len(conditions) == 1:
1000
+ return conditions[0]
1001
+ return SnakeAnd(parts=conditions)