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,871 @@
1
+ """SnakeSession: the layer with "colour" that EXECUTES the colourless queries.
2
+
3
+ It orchestrates a driver (execution) and a dialect (emission): it compiles the query, runs it and
4
+ maps the rows to the model. The query NEVER executes, which is why the very same one works for an
5
+ AsyncSession.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable, Iterator, Sequence
11
+ from contextlib import contextmanager
12
+ from types import TracebackType
13
+ from typing import Any, TypeVar, cast, overload
14
+
15
+ from snakeorm.decorators.result import SnakeResult
16
+ from snakeorm.debug.collector import timed_mapping
17
+ from snakeorm.decorators.row import SnakeRow
18
+ from snakeorm.dialects import SnakeDialect
19
+ from snakeorm.drivers import SnakeDriver
20
+ from snakeorm.core.exceptions import (
21
+ SnakeEmitError,
22
+ SnakeRegistryError,
23
+ SnakeUnsupportedFeature,
24
+ )
25
+ from snakeorm.expressions import (
26
+ SnakeExpr,
27
+ SnakeValue,
28
+ )
29
+ from snakeorm.fields import SnakePrefetch, SnakePrefetchHop
30
+ from snakeorm.metadata import (
31
+ SnakeRelationshipInfo,
32
+ SnakeTableInfo,
33
+ )
34
+ from snakeorm.model import SnakeModel
35
+ from snakeorm.query import (
36
+ SnakeCompound,
37
+ SnakeJoinedQuery,
38
+ SnakeQuery,
39
+ SnakeRecursive,
40
+ )
41
+ from snakeorm.registry import SnakeRegistry, registry_of
42
+ from snakeorm.session.shared import (
43
+ apply_last_insert_id,
44
+ apply_returned,
45
+ column_name,
46
+ direct_column,
47
+ guard_plain_query,
48
+ guard_set_value,
49
+ insert_values,
50
+ pk_condition,
51
+ table_of,
52
+ table_with_pk,
53
+ update_values,
54
+ warn_bulk_loses_generated_keys,
55
+ warn_reduced_fidelity,
56
+ )
57
+ from snakeorm.session.guards import (
58
+ _guard_declared_limits,
59
+ guard_can_set_isolation,
60
+ guard_uniform_bulk_columns,
61
+ )
62
+ from snakeorm.session.isolation import SnakeIsolation
63
+ from snakeorm.session.planning import (
64
+ Plan,
65
+ Rows,
66
+ _instantiate,
67
+ _instantiate_all,
68
+ _instantiate_with_compiled,
69
+ compile_segments,
70
+ instantiate_rows_with_includes,
71
+ )
72
+ from snakeorm.session.planning import (
73
+ plan_annotate,
74
+ plan_call,
75
+ plan_insert,
76
+ plan_raw,
77
+ plan_scalar,
78
+ )
79
+ from snakeorm.session.planning import parents_per_batch, plan_level, routine_name
80
+ from snakeorm.session.planning import project_rows
81
+ from snakeorm.session.mapper import hydrate_partial, partial_plan_for
82
+ from snakeorm.core.signals import (
83
+ SnakeSignal,
84
+ warn_bulk_skips_signals,
85
+ )
86
+ from snakeorm.core.signals import (
87
+ emit as emit_signal,
88
+ )
89
+ from snakeorm.sql import (
90
+ emit_delete,
91
+ emit_insert,
92
+ emit_insert_many,
93
+ emit_select,
94
+ emit_update,
95
+ emit_upsert,
96
+ )
97
+
98
+ T = TypeVar("T")
99
+ # The WRITE methods ask for a `SnakeModel`: a `SnakeView` does not inherit from it, so the checker
100
+ # rejects it IN THE TYPE (static read-only). The READ ones accept any `T`.
101
+ ModelT = TypeVar("ModelT", bound=SnakeModel)
102
+ R = TypeVar("R", bound="SnakeResult[Any]")
103
+ Row = TypeVar("Row", bound="SnakeRow")
104
+ A = TypeVar("A")
105
+ B = TypeVar("B")
106
+ C = TypeVar("C")
107
+ D = TypeVar("D")
108
+
109
+
110
+ class SnakeSession:
111
+ """Runs queries against a driver and maps the results to models."""
112
+
113
+ def __init__(
114
+ self,
115
+ driver: SnakeDriver,
116
+ dialect: SnakeDialect,
117
+ *,
118
+ model_registry: SnakeRegistry | None = None,
119
+ ) -> None:
120
+ """The session, and the registry whose models decide which TYPE caveats it mentions.
121
+
122
+ `model_registry` defaults to the global one, which is what almost every project has. It
123
+ exists because a project built entirely on `@snake_model(registry=...)` used to open a
124
+ session and hear NOTHING: the advisor enumerated the global registry, found no models, and
125
+ the fidelity caveats — the `Decimal` that SQLite degrades, the timestamps without a zone —
126
+ went unsaid. Structural caveats came out either way; the type ones did not.
127
+ """
128
+ self._driver = driver
129
+ self._dialect = dialect
130
+ self._savepoint_depth = (
131
+ 0 # depth of nested savepoints (unique names sp1, sp2...)
132
+ )
133
+ warn_reduced_fidelity(dialect, model_registry)
134
+
135
+ @property
136
+ def dialect(self) -> SnakeDialect:
137
+ """This session's dialect, read-only: to ask it what the engine knows how to do.
138
+
139
+ Public because there is application code that LEGITIMATELY changes with the engine — the
140
+ demos' seeder inserts row by row where there is no `RETURNING`, because it needs the ids —
141
+ and the alternative was for each of them to sneak in through `session._dialect`. Asking about
142
+ a declared capability is the exact opposite of coupling to an engine: it is not taking for
143
+ granted the one you happen to have.
144
+ """
145
+ return self._dialect
146
+
147
+ def __enter__(self) -> SnakeSession:
148
+ """Enters a transaction: it returns the session itself."""
149
+ return self
150
+
151
+ def __exit__(
152
+ self,
153
+ exc_type: type[BaseException] | None,
154
+ exc: BaseException | None,
155
+ traceback: TracebackType | None,
156
+ ) -> None:
157
+ """Closes the transaction: commit if it came out clean, rollback if there was an exception.
158
+
159
+ It does not suppress the exception nor close the connection (the driver is injected; its life
160
+ cycle is carried by whoever created it).
161
+ """
162
+ if exc_type is None:
163
+ self.commit()
164
+ else:
165
+ self.rollback()
166
+
167
+ def close(self) -> None:
168
+ """Closes the underlying driver (with a pool, it RETURNS the connection to the pool).
169
+
170
+ `__exit__` does NOT call it (the driver is injected), but without this method a connection
171
+ taken out of a pool had no way back.
172
+ """
173
+ self._driver.close()
174
+
175
+ def _run_plan(self, plan: Plan[T]) -> T:
176
+ """Runs a plan: the ONLY difference from the async session is the `await` that is not here.
177
+
178
+ The plans live in `planning.py`, which is COLOURLESS: it decides which SQL to emit and what
179
+ to do with whatever comes back, without executing. Both sessions consuming the same plan is
180
+ what stops them drifting apart — and they had already drifted: the docstring of
181
+ `AsyncSession.add` swore it shared `plan_insert` with this session when this one was emitting
182
+ on its own.
183
+ """
184
+ rows: Rows = (
185
+ self._driver.fetch_all(plan.sql, plan.params) if plan.needs_rows else []
186
+ )
187
+ if not plan.needs_rows:
188
+ self._driver.execute(plan.sql, plan.params)
189
+ return plan.apply(rows)
190
+
191
+ def all(
192
+ self, query: SnakeQuery[T] | SnakeCompound[T] | SnakeRecursive[T]
193
+ ) -> list[T]:
194
+ """Runs the query and returns every row as an instance of the model.
195
+
196
+ It also accepts a COMPOUND (`UNION`/`EXCEPT`/`INTERSECT`) with no separate path: it fulfils
197
+ the same contract as a query (`model`, `has_includes`, `to_sql`).
198
+ """
199
+ return self._run(query)
200
+
201
+ def first(
202
+ self, query: SnakeQuery[T] | SnakeCompound[T] | SnakeRecursive[T]
203
+ ) -> T | None:
204
+ """Runs the query with LIMIT 1 and returns the first instance, or None if there is none."""
205
+ models = self._run(query.limit(1))
206
+ return models[0] if models else None
207
+
208
+ def iterate(
209
+ self,
210
+ query: SnakeQuery[T] | SnakeCompound[T] | SnakeRecursive[T],
211
+ *,
212
+ chunk: int = 1000,
213
+ ) -> Iterator[T]:
214
+ """Walks the result WITHOUT materialising it whole: one instance at a time.
215
+
216
+ for invoice in session.iterate(SnakeQuery(Invoice), chunk=500):
217
+ export(invoice)
218
+
219
+ `all()` builds a list with ALL the rows before returning the first one; over ten million rows
220
+ that is ten million tuples and ten million objects in memory. Here the engine keeps the
221
+ result (a server-side cursor where there is one) and only `chunk` rows travel at a time. It
222
+ is lazy: nothing is executed until the first row is asked for, so cutting out with a `break`
223
+ does not pay for the rest.
224
+
225
+ It does NOT admit `include()` of a to-many nor prefetch, and it RAISES if you ask for one.
226
+ The select-in needs every root to fire its second query and in streaming they do not exist:
227
+ the ways out would be materialising (which defeats this) or one query per row (an N+1). Both
228
+ betray what was asked for, so it is said out loud. The `include()` of a to-ONE does work: it
229
+ travels in the same JOIN.
230
+ """
231
+ if isinstance(query, SnakeQuery):
232
+ self._guard_streamable(query)
233
+ return self._stream(query, chunk)
234
+
235
+ @staticmethod
236
+ def _guard_streamable(query: SnakeQuery[T]) -> None:
237
+ """Rejects what streaming cannot serve without degrading into materialising or into an N+1."""
238
+ if query.prefetches():
239
+ raise SnakeUnsupportedFeature(
240
+ "iterate() cannot resolve a prefetch: it chains one query PER LEVEL and needs every "
241
+ "parent of the previous level, which in streaming does not exist yet. Use all() "
242
+ "if the result fits, or walk it in batches with limit/offset."
243
+ )
244
+ if query.to_many_includes():
245
+ raise SnakeUnsupportedFeature(
246
+ "iterate() cannot resolve a to-many include(): the select-in needs ALL the roots to "
247
+ "launch its second query, and here the rows come out one at a time. Serving it "
248
+ "would demand materialising them (which is what you wanted to avoid) or one "
249
+ "query per row (an N+1). Drop the include and load the children separately, or "
250
+ "use all(). A to-one include() DOES work: it rides in the same JOIN."
251
+ )
252
+
253
+ def _stream(
254
+ self,
255
+ query: SnakeQuery[T] | SnakeCompound[T] | SnakeRecursive[T],
256
+ chunk: int,
257
+ ) -> Iterator[T]:
258
+ """The generator of `iterate()`. Split off so the GUARD fires on the call, not on consumption.
259
+
260
+ Were this `iterate()` itself, the body would not run until the first `next()` and a
261
+ `session.iterate(query_with_include)` would not raise until somebody walked it — the error
262
+ would show up far from the line that caused it.
263
+ """
264
+ if isinstance(query, SnakeQuery) and query.to_one_includes():
265
+ sql, params = query.to_include_sql(self._dialect)
266
+ # Compiled ONCE, above the loop. Resolving inside it cost three lookups per
267
+ # segment per row, and streaming is precisely where the row count is unbounded.
268
+ compiled = compile_segments(query.include_segments())
269
+ for row in self._driver.fetch_iter(sql, params, chunk=chunk):
270
+ yield cast("T", _instantiate_with_compiled(compiled, row))
271
+ return
272
+ sql, params = query.to_sql(self._dialect)
273
+ model = query.model
274
+ table = registry_of(model).table_of(model)
275
+ assert (
276
+ table is not None
277
+ ) # the query would not have been built without a registered table
278
+ # `only()`/`defer()` narrow the ROW, so the plan has to be narrowed with it — the same
279
+ # question `_map` asks for `all()`, and it was not asked here. The SQL was already right
280
+ # (both paths compile it with the same `to_sql`), so a streamed `only()` emitted three
281
+ # columns and hydrated against four, and the failure surfaced deep in the mapper as a `zip`
282
+ # of unequal lengths that named neither knob. Two read paths over one query may not disagree
283
+ # about what a row IS.
284
+ columns = getattr(query, "projected_columns", None)
285
+ if columns is not None:
286
+ plan, missing = partial_plan_for(model, table, columns)
287
+ for row in self._driver.fetch_iter(sql, params, chunk=chunk):
288
+ yield hydrate_partial(model, plan, missing, row)
289
+ return
290
+ for row in self._driver.fetch_iter(sql, params, chunk=chunk):
291
+ # `_instantiate` and not `hydrate`: it is the only place that decides the concrete class of
292
+ # a polymorphic hierarchy, and calling `hydrate` straight made a streamed read answer with
293
+ # the BASE class while `all()` over the same query answered with the children — and left the
294
+ # siblings' columns on the instance. `_instantiate`'s own docstring lists the paths that
295
+ # inherit the dispatch; this one was missing from the list rather than from the design.
296
+ yield _instantiate(model, table, row)
297
+
298
+ def _run(
299
+ self, query: SnakeQuery[T] | SnakeCompound[T] | SnakeRecursive[T]
300
+ ) -> list[T]:
301
+ """Runs the query: it loads the root (JOINing the to-ones) and then the to-manys separately."""
302
+ # A COMPOUND is dispatched here and leaves (it never has includes). It is checked by type,
303
+ # not by `has_includes`, so the checker narrows to `SnakeQuery` in what follows.
304
+ if isinstance(query, (SnakeCompound, SnakeRecursive)):
305
+ sql, params = query.to_sql(self._dialect)
306
+ return self._map(query, self._driver.fetch_all(sql, params))
307
+
308
+ if not query.has_includes:
309
+ sql, params = query.to_sql(self._dialect)
310
+ return self._map(query, self._driver.fetch_all(sql, params))
311
+
312
+ if query.to_one_includes():
313
+ sql, params = query.to_include_sql(self._dialect)
314
+ segments = query.include_segments()
315
+ rows = self._driver.fetch_all(sql, params)
316
+ parents = cast("list[T]", instantiate_rows_with_includes(segments, rows))
317
+ else:
318
+ sql, params = query.to_sql(self._dialect)
319
+ parents = self._map(query, self._driver.fetch_all(sql, params))
320
+
321
+ parent_table = registry_of(query.model).table_of(query.model)
322
+ assert parent_table is not None
323
+ for relationship in query.to_many_includes():
324
+ self._load_to_many(parents, parent_table, relationship)
325
+ for prefetch in query.prefetches():
326
+ self._run_prefetch(parents, prefetch)
327
+ return parents
328
+
329
+ def _run_prefetch(
330
+ self, roots: Sequence[object], prefetch: SnakePrefetch[Any]
331
+ ) -> None:
332
+ """Resolves a nested prefetch chain level by level: ONE query per hop, never an N+1.
333
+
334
+ The frontier starts at the roots and advances hop by hop (one level's children are the next
335
+ one's frontier). If it empties out, the chain stops: there are no parents to hang grandchildren on.
336
+ """
337
+ frontier: list[object] = list(roots)
338
+ for hop in prefetch.hops():
339
+ if not frontier:
340
+ break
341
+ frontier = self._load_level(frontier, hop)
342
+
343
+ def _load_to_many(
344
+ self,
345
+ parents: Sequence[object],
346
+ parent_table: SnakeTableInfo,
347
+ relationship: SnakeRelationshipInfo,
348
+ ) -> None:
349
+ """Loads a ONE-hop to-many relationship with a select-in and hooks the list onto each parent."""
350
+ if not parents:
351
+ return
352
+ # The PARENT's registry, which the rows themselves carry. `_load_to_many` has no query in
353
+ # scope, and the global registry is the wrong answer for any model with `registry=`.
354
+ child_table, child_model = registry_of(type(parents[0])).resolve_relationship(
355
+ relationship
356
+ )
357
+ assert child_model is not None and child_table is not None
358
+ hop = SnakePrefetchHop(
359
+ name=relationship.name,
360
+ kind=relationship.kind,
361
+ parent_table=parent_table,
362
+ child_model=child_model,
363
+ child_table=child_table,
364
+ relationship=relationship,
365
+ )
366
+ self._load_level(parents, hop)
367
+
368
+ def _load_level(
369
+ self, parents: Sequence[object], hop: SnakePrefetchHop
370
+ ) -> list[object]:
371
+ """Runs the plan of ONE level (to-one, to-many or many-to-many), in as many statements as the
372
+ engine's placeholder ceiling needs.
373
+
374
+ The dispatch lives here and not in each caller: all three levels are asked for the same way.
375
+ The BATCHING lives here too, and not inside the plan, for the reason the plan exists: it does
376
+ not execute. Keeping it colourless is what lets the synchronous and the asynchronous session
377
+ consume the very same one, and a loop inside it would either give it colour or change the
378
+ contract of all three levels.
379
+
380
+ Every parent falls in exactly one batch, so `attach` assigns it its list exactly once.
381
+ """
382
+ loaded: list[object] = []
383
+ size = parents_per_batch(hop, self._dialect)
384
+ for start in range(0, len(parents), size):
385
+ plan = plan_level(parents[start : start + size], hop, self._dialect)
386
+ if plan is None:
387
+ continue
388
+ sql, params, attach = plan
389
+ loaded.extend(attach(self._driver.fetch_all(sql, params)))
390
+ return loaded
391
+
392
+ def count(self, query: SnakeQuery[T]) -> int:
393
+ """Counts the rows that match the query (COUNT(*), it honours filters and JOINs)."""
394
+ guard_plain_query(query, "count")
395
+ sql, params = query.to_count_sql(self._dialect)
396
+ return cast(
397
+ "int",
398
+ self._run_plan(
399
+ plan_scalar(sql, params, lambda value: int(cast("int", value)))
400
+ ),
401
+ )
402
+
403
+ def exists(self, query: SnakeQuery[T]) -> bool:
404
+ """Tells whether any row matching the query exists (EXISTS)."""
405
+ guard_plain_query(query, "exists")
406
+ sql, params = query.to_exists_sql(self._dialect)
407
+ return cast("bool", self._run_plan(plan_scalar(sql, params, bool)))
408
+
409
+ @overload
410
+ def select(
411
+ self, query: SnakeQuery[Any] | SnakeJoinedQuery[Any, Any], c1: SnakeValue[A], /
412
+ ) -> list[tuple[A]]: ...
413
+ @overload
414
+ def select(
415
+ self,
416
+ query: SnakeQuery[Any] | SnakeJoinedQuery[Any, Any],
417
+ c1: SnakeValue[A],
418
+ c2: SnakeValue[B],
419
+ /,
420
+ ) -> list[tuple[A, B]]: ...
421
+ @overload
422
+ def select(
423
+ self,
424
+ query: SnakeQuery[Any] | SnakeJoinedQuery[Any, Any],
425
+ c1: SnakeValue[A],
426
+ c2: SnakeValue[B],
427
+ c3: SnakeValue[C],
428
+ /,
429
+ ) -> list[tuple[A, B, C]]: ...
430
+ @overload
431
+ def select(
432
+ self,
433
+ query: SnakeQuery[Any] | SnakeJoinedQuery[Any, Any],
434
+ c1: SnakeValue[A],
435
+ c2: SnakeValue[B],
436
+ c3: SnakeValue[C],
437
+ c4: SnakeValue[D],
438
+ /,
439
+ ) -> list[tuple[A, B, C, D]]: ...
440
+
441
+ def select(
442
+ self,
443
+ query: SnakeQuery[Any] | SnakeJoinedQuery[Any, Any],
444
+ /,
445
+ *columns: SnakeValue[Any],
446
+ ) -> list[tuple[Any, ...]]:
447
+ """Projects concrete columns and/or aggregates: it returns TUPLES (not model instances).
448
+
449
+ Partial data does not pretend to be a complete model. Typed up to FOUR values, and a
450
+ fifth is not a looser tuple — there is no overload for it, so it fails the checker
451
+ (overloads); beyond that, `tuple[Any, ...]`.
452
+
453
+ It accepts a `SnakeJoinedQuery` (a JOIN onto a collection -> MULTIPLIED rows, one per child),
454
+ which `all`/`first` do NOT accept: hydrating multiplied models is a type error. Each value is
455
+ coerced to the determinable `python_type`; whatever has none (arithmetic, subqueries) passes
456
+ through untouched.
457
+ """
458
+ guard_plain_query(query, "select")
459
+ sql, params = query.to_project_sql(self._dialect, columns)
460
+ # The coercion lives in `planning`, colourless: the SAME decision in both sessions.
461
+ return project_rows(query, columns, self._driver.fetch_all(sql, params))
462
+
463
+ def annotate(
464
+ self, query: SnakeQuery[T], result: type[R], /, **aggregates: SnakeValue[Any]
465
+ ) -> list[R]:
466
+ """Annotates each row of the base model with aggregates and wraps it in a typed `@snake_result`.
467
+
468
+ It returns `list[R]` with `R` the concrete `@snake_result` (not `list[Any]`): the
469
+ `SnakeResult[Any]` bound captures the real type and forces `result` to be a `@snake_result`.
470
+
471
+ The `**aggregates` match by NAME with `result`'s scalar fields (validated BEFORE emitting).
472
+ The scalars are coerced to the type DECLARED in the `@snake_result` (an `avg: float` would
473
+ receive an AVG's `Decimal` without this).
474
+
475
+ LIMIT: that the query consults the SAME model the `result` declares is validated at RUNTIME
476
+ (`SnakeEmitError`), not in the checker: it would demand a dependent bound
477
+ `R <: SnakeResult[T]`, and `TypeVar` bounds cannot be generic. See
478
+ `snakeorm/decorators/result.py`.
479
+ """
480
+ sql, params, build = plan_annotate(query, result, self._dialect, aggregates)
481
+ return cast("list[R]", build(self._driver.fetch_all(sql, params)))
482
+
483
+ def call(self, name: str, args: Sequence[object], *, into: type[Row]) -> list[Row]:
484
+ """Calls a database FUNCTION that returns rows and hydrates them into a DECLARED shape.
485
+
486
+ It emits `SELECT * FROM name(placeholders)` (a `RETURNS TABLE`/`SETOF` function in Postgres).
487
+ The ARGS travel parametrised (user data, which kills injection); the name is a developer's
488
+ identifier and gets emitted as is.
489
+
490
+ POSITIONAL mapping onto the `@snake_row` `into`, coercing each column to its DECLARED type.
491
+ OPAQUE SQL: neither the routine's existence nor its shape is checked (you declare, I
492
+ hydrate). For a PROCEDURE with no rows, use `execute_procedure(...)`.
493
+ """
494
+ sql, params, build = plan_call(name, args, into, self._dialect)
495
+ return cast("list[Row]", build(self._driver.fetch_all(sql, params)))
496
+
497
+ def explain(self, query: SnakeQuery[ModelT]) -> list[str]:
498
+ """Asks the engine for its PLAN for this query, without running it.
499
+
500
+ The dialect wraps the compiled statement and the driver runs it: `EXPLAIN` costs an extra
501
+ round trip and nothing else, and the parameters travel as parameters.
502
+
503
+ The lines come back as the ENGINE writes them. Postgres answers one column, SQLite four and
504
+ MySQL about a dozen, so a row is joined into a line rather than forced into a shape the
505
+ three do not share.
506
+ """
507
+ sql, params = query.to_sql(self._dialect)
508
+ rows = self._driver.fetch_all(self._dialect.explain_sql(sql), params)
509
+ return [
510
+ " ".join("" if cell is None else str(cell) for cell in row) for row in rows
511
+ ]
512
+
513
+ def raw(
514
+ self, sql: str, params: Sequence[object] = (), *, into: type[Row]
515
+ ) -> list[Row]:
516
+ """Runs RAW SQL and hydrates it into a DECLARED shape (`@snake_row`).
517
+
518
+ The escape hatch for the SQL the builder does not cover; at least the result comes back
519
+ TYPED. Parametrised values; the SHAPE is not checked (the same contract as `call`: you
520
+ declare, I hydrate).
521
+
522
+ Limit: the number of columns is checked ROW BY ROW, so a query with no rows passes even if
523
+ its shape does not match (the driver hands over data, not the cursor's description). To write
524
+ without reading (`VACUUM`, `SET`), use the driver directly.
525
+ """
526
+ build = plan_raw(into)
527
+ return cast("list[Row]", build(self._driver.fetch_all(sql, tuple(params))))
528
+
529
+ def get_or_create(
530
+ self, query: SnakeQuery[ModelT], build: Callable[[], ModelT]
531
+ ) -> tuple[ModelT, bool]:
532
+ """Looks up with the query and, if there is nothing, inserts whatever `build` returns. Gives `(row, created)`.
533
+
534
+ user, created = session.get_or_create(
535
+ SnakeQuery(User).filter(User.email == "a@x.com"),
536
+ lambda: User(email="a@x.com", name="Ana"),
537
+ )
538
+
539
+ The boolean is the reason it exists: `upsert` writes too, but it does not say whether it
540
+ created it or it was already there. `build` is an explicit CALLABLE (not the filter's magic
541
+ `**kwargs` as in Django): you build the object yourself with the typed constructor.
542
+
543
+ Mind the race: another transaction fits between the SELECT and the INSERT. If two processes
544
+ can create the same row, put a UNIQUE on it and catch its violation, or use `upsert`.
545
+ """
546
+ found = self.first(query)
547
+ if found is not None:
548
+ return found, False
549
+ return self.add(build()), True
550
+
551
+ def refresh(self, instance: ModelT) -> ModelT:
552
+ """Reloads the instance from the DB, overwriting ALL of its columns. It returns the same one.
553
+
554
+ It is needed when the DB changed the row on its own (a trigger, a `server_default`, a bulk
555
+ write, another transaction); the alternative was re-querying and having TWO objects for the
556
+ same row. It is identified by PK; if the row is no longer there, it is said in plain words.
557
+ """
558
+ table = table_with_pk(instance)
559
+ sql, params = emit_select(
560
+ table, self._dialect, where=pk_condition(table, instance)
561
+ )
562
+ rows = self._driver.fetch_all(sql, params)
563
+ if not rows:
564
+ raise SnakeRegistryError(
565
+ f"{type(instance).__name__} could not be refreshed: its row is gone from "
566
+ f"'{table.name}'. Did another transaction delete it?"
567
+ )
568
+ apply_returned(instance, table, rows[0])
569
+ return instance
570
+
571
+ def execute_procedure(self, name: str, args: Sequence[object]) -> None:
572
+ """Runs a PROCEDURE that returns NO rows (`CALL name(...)`); the opposite of `call(...)`.
573
+
574
+ The ARGS travel parametrised (user data); the NAME is an identifier and cannot, so it goes
575
+ through the same `routine_name` check `call` uses — this door had the very same hole, and
576
+ one rule for the two of them is the point. If your routine returns rows, use `call`.
577
+ """
578
+ checked = routine_name(name)
579
+ placeholders = ", ".join(
580
+ self._dialect.placeholder(index + 1) for index in range(len(args))
581
+ )
582
+ self._driver.execute(f"CALL {checked}({placeholders})", list(args))
583
+
584
+ def add(self, instance: ModelT) -> ModelT:
585
+ """Inserts the instance. With RETURNING, it assigns back ALL the columns from the DB.
586
+
587
+ Not just the PK: a `DEFAULT now()` or a trigger column comes back into the object (coerced).
588
+ The columns omitted from the INSERT (MISSING, e.g. an autoincrementing PK) are the ones the
589
+ server fills in.
590
+ """
591
+ table = table_of(instance)
592
+ emit_signal(type(instance), SnakeSignal.PRE_SAVE, instance)
593
+ plan = plan_insert(
594
+ instance,
595
+ table,
596
+ self._dialect,
597
+ insert_values(instance, table),
598
+ apply_returned,
599
+ )
600
+ self._run_plan(plan)
601
+ if not self._dialect.supports_returning:
602
+ # Without RETURNING the PK does not come back in the row: it has to be fetched from the
603
+ # driver. It is the only part of the INSERT that does NOT fit in the plan, because the
604
+ # plan is colourless and this asks the connection.
605
+ apply_last_insert_id(instance, table, self._driver)
606
+ emit_signal(type(instance), SnakeSignal.POST_SAVE, instance)
607
+ return instance
608
+
609
+ def add_all(self, instances: Sequence[ModelT], /) -> None:
610
+ """Inserts a batch of instances of the SAME model with a single multi-row INSERT per chunk.
611
+
612
+ A single `INSERT ... VALUES (...), (...)` (not `executemany`: psycopg2 would go row by row
613
+ with no RETURNING). It chunks by `max_bind_params // columns` so as not to overshoot the
614
+ engine's limit. With RETURNING it fills in the server's columns IN ORDER. An empty list ->
615
+ nothing. Different models -> an error.
616
+ """
617
+ if not instances:
618
+ return
619
+ model = type(instances[0])
620
+ for instance in instances:
621
+ if type(instance) is not model:
622
+ raise SnakeEmitError(
623
+ "add_all requires every instance to be of the same model; "
624
+ f"{model.__name__} and {type(instance).__name__} were mixed."
625
+ )
626
+ table = table_of(instances[0])
627
+ # PRE for ALL of them before emitting: a handler can modify the instance, and doing so
628
+ # halfway through the batch would leave some rows with the change and others without it.
629
+ for instance in instances:
630
+ emit_signal(model, SnakeSignal.PRE_SAVE, instance)
631
+ warn_bulk_loses_generated_keys(self._dialect, table)
632
+ rows = [insert_values(instance, table) for instance in instances]
633
+ # Same MODEL is not the same SHAPE: a column with a server default stays out of the
634
+ # constructor, so two instances of one class can carry different sets of values. Once this
635
+ # passes, `rows[0]` answers for all of them BY CONSTRUCTION and the branch below is correct
636
+ # rather than lucky — it used to let the first instance decide, and emit `DEFAULT VALUES`
637
+ # for every one of them while the others' values were computed and thrown away.
638
+ guard_uniform_bulk_columns(rows, model.__name__)
639
+ # An all-defaults batch -> no client values -> `DEFAULT VALUES`, of ONE row (there is no
640
+ # portable all-defaults multi-row). It falls back to one insert per instance, like `add`.
641
+ if not rows[0]:
642
+ for instance in instances:
643
+ sql, params = emit_insert(table, self._dialect, {})
644
+ if self._dialect.supports_returning:
645
+ apply_returned(
646
+ instance, table, self._driver.fetch_all(sql, params)[0]
647
+ )
648
+ else:
649
+ self._driver.execute(sql, params)
650
+ for instance in instances:
651
+ emit_signal(model, SnakeSignal.POST_SAVE, instance)
652
+ return
653
+ chunk = max(1, self._dialect.max_bind_params // max(1, len(rows[0])))
654
+ for start in range(0, len(instances), chunk):
655
+ batch = instances[start : start + chunk]
656
+ sql, params = emit_insert_many(
657
+ table, self._dialect, rows[start : start + chunk]
658
+ )
659
+ if self._dialect.supports_returning:
660
+ for instance, row in zip(
661
+ batch, self._driver.fetch_all(sql, params), strict=True
662
+ ):
663
+ apply_returned(instance, table, row)
664
+ else:
665
+ self._driver.execute(sql, params)
666
+ for instance in instances:
667
+ emit_signal(model, SnakeSignal.POST_SAVE, instance)
668
+
669
+ def upsert(
670
+ self,
671
+ instance: SnakeModel,
672
+ /,
673
+ *,
674
+ on_conflict: Sequence[SnakeExpr[Any]],
675
+ update: Sequence[SnakeExpr[Any]] = (),
676
+ ) -> None:
677
+ """Inserts the instance resolving the conflict over `on_conflict` (an idempotent upsert).
678
+
679
+ Without `update` -> it does not touch the row (`DO NOTHING`); with `update` -> it rewrites
680
+ those columns (`DO UPDATE SET c = EXCLUDED.c`). The jargon (`ON CONFLICT`) is translated by
681
+ the dialect. A `DO NOTHING` with a conflict returns no row, so there is nothing to assign.
682
+
683
+ If the dialect does not support it, `SnakeUnsupportedFeature`: it is NOT emulated with
684
+ SELECT+INSERT (a race between the SELECT and the INSERT; it would fake an atomicity it does
685
+ not have).
686
+ """
687
+ if not self._dialect.supports_upsert:
688
+ raise SnakeUnsupportedFeature(
689
+ "this dialect does not support upsert; it is NOT emulated with SELECT+INSERT "
690
+ "because that emulation has a race condition and would fake an atomicity it "
691
+ "does not have."
692
+ )
693
+ table = table_of(instance)
694
+ emit_signal(type(instance), SnakeSignal.PRE_SAVE, instance)
695
+ sql, params = emit_upsert(
696
+ table,
697
+ self._dialect,
698
+ insert_values(instance, table),
699
+ conflict_columns=[direct_column(column) for column in on_conflict],
700
+ update_columns=[direct_column(column) for column in update],
701
+ )
702
+ if self._dialect.supports_returning:
703
+ rows = self._driver.fetch_all(sql, params)
704
+ if rows:
705
+ apply_returned(instance, table, rows[0])
706
+ else:
707
+ self._driver.execute(sql, params)
708
+ emit_signal(type(instance), SnakeSignal.POST_SAVE, instance)
709
+
710
+ def update(self, instance: SnakeModel) -> None:
711
+ """Updates the instance's non-PK columns, filtering by its primary key."""
712
+ table = table_with_pk(instance)
713
+ values = update_values(instance, table)
714
+ emit_signal(type(instance), SnakeSignal.PRE_SAVE, instance)
715
+ sql, params = emit_update(
716
+ table, self._dialect, values, where=pk_condition(table, instance)
717
+ )
718
+ self._driver.execute(sql, params)
719
+ emit_signal(type(instance), SnakeSignal.POST_SAVE, instance)
720
+
721
+ def delete(self, instance: SnakeModel) -> None:
722
+ """Deletes the instance's row, filtering by its primary key."""
723
+ table = table_with_pk(instance)
724
+ emit_signal(type(instance), SnakeSignal.PRE_DELETE, instance)
725
+ sql, params = emit_delete(
726
+ table, self._dialect, where=pk_condition(table, instance)
727
+ )
728
+ self._driver.execute(sql, params)
729
+ emit_signal(type(instance), SnakeSignal.POST_DELETE, instance)
730
+
731
+ def update_where(
732
+ self,
733
+ query: SnakeQuery[ModelT],
734
+ values: Sequence[tuple[SnakeExpr[Any], object]],
735
+ /,
736
+ ) -> int:
737
+ """Updates IN BULK the rows that match the query's filter. It returns the affected ones.
738
+
739
+ `values` are (column, value) pairs: `[(User.views, User.views + 1)]`, with the column as a
740
+ `SnakeExpr` and the value a literal or an expression. It is a SEQUENCE of pairs, NOT a
741
+ `Mapping`, because `SnakeExpr` is deliberately NOT hashable (its `==` returns a condition,
742
+ not a bool).
743
+
744
+ The SET only touches columns of the BASE TABLE: navigating a relationship (key or value) is
745
+ rejected (one cannot assign from a joined table without a FROM). Only the WHERE can go deep.
746
+ """
747
+ columns: dict[str, object] = {}
748
+ for column, value in values:
749
+ guard_set_value(value)
750
+ columns[column_name(column)] = value
751
+ # The bulk SET was the ONLY write path that did not check the declared limits, and it is
752
+ # exactly where an out-of-range value does the most damage: it touches N rows at once. The
753
+ # EXPRESSIONS (`views + 1`) are filtered out: their value is computed by the server and there
754
+ # is nothing to measure here.
755
+ table = registry_of(query.model).table_of(query.model)
756
+ assert (
757
+ table is not None
758
+ ) # the query would not have been built without a registered table
759
+ _guard_declared_limits(
760
+ table,
761
+ {
762
+ k: v
763
+ for k, v in columns.items()
764
+ if not isinstance(v, (SnakeValue, SnakeExpr))
765
+ },
766
+ )
767
+ warn_bulk_skips_signals(query.model, "update_where")
768
+ sql, params = query.to_update_sql(self._dialect, columns)
769
+ return self._driver.execute(sql, params)
770
+
771
+ def delete_where(self, query: SnakeQuery[ModelT], /) -> int:
772
+ """Deletes IN BULK the rows that match the query's filter. It returns the affected ones.
773
+
774
+ The guard lives in the query: with no explicit filter, it raises (a DELETE with no WHERE
775
+ would wipe the table out).
776
+ """
777
+ warn_bulk_skips_signals(query.model, "delete_where")
778
+ sql, params = query.to_delete_sql(self._dialect)
779
+ return self._driver.execute(sql, params)
780
+
781
+ def commit(self) -> None:
782
+ """Commits the transaction in progress (it delegates to the driver)."""
783
+ self._driver.commit()
784
+
785
+ def rollback(self) -> None:
786
+ """Rolls back the transaction in progress (it delegates to the driver)."""
787
+ self._driver.rollback()
788
+
789
+ def set_isolation(self, level: SnakeIsolation) -> None:
790
+ """Sets the ISOLATION level of the transaction starting now.
791
+
792
+ The other half of concurrency control: `for_update()` says which rows you RESERVE, the
793
+ isolation what you SEE meanwhile. It has to be called before reading or writing: `SET
794
+ TRANSACTION` is only valid as the first statement, and the engine rejects it if the DB has
795
+ already been touched.
796
+
797
+ The engine is ASKED first. This used to hand the statement straight to the driver, so on an
798
+ engine without it the ORM emitted SQL the engine refuses — `near "SET": syntax error` on
799
+ SQLite — instead of saying what it could not do. Emitting for one engine from the session is
800
+ also the thing the dialect seam exists to prevent.
801
+ """
802
+ guard_can_set_isolation(self._dialect)
803
+ self._driver.execute(f"SET TRANSACTION ISOLATION LEVEL {level.value}", ())
804
+
805
+ @contextmanager
806
+ def savepoint(self) -> Iterator[None]:
807
+ """SAVEPOINT context manager: it isolates a block inside the transaction in progress.
808
+
809
+ On entry `SAVEPOINT`; on a clean exit `RELEASE`; if the block raises, `ROLLBACK TO SAVEPOINT`
810
+ (it discards ONLY what is inside, the transaction stays alive) and RE-RAISES. It exists so a
811
+ long process does not lose everything if one part of it fails.
812
+
813
+ NESTABLE: each level uses a name unique per depth (`sp1`, `sp2`, ...; an INTERNAL name, never
814
+ user data). On exit it is decremented, so the same level reuses the name.
815
+ """
816
+ self._savepoint_depth += 1
817
+ name = f"sp{self._savepoint_depth}"
818
+ self._driver.savepoint(name)
819
+ try:
820
+ yield
821
+ except Exception:
822
+ self._driver.rollback_to_savepoint(
823
+ name
824
+ ) # discards the block and leaves what came before untouched
825
+ raise
826
+ else:
827
+ self._driver.release_savepoint(name) # happy path: consolidates savepoint
828
+ finally:
829
+ self._savepoint_depth -= 1
830
+
831
+ def _map(
832
+ self,
833
+ query: SnakeQuery[T] | SnakeCompound[T] | SnakeRecursive[T],
834
+ rows: list[tuple[object, ...]],
835
+ ) -> list[T]:
836
+ """Maps the raw rows to instances of the query's model. See `map_rows`, which does the work."""
837
+ return map_rows(query, rows)
838
+
839
+
840
+ @timed_mapping
841
+ def map_rows(
842
+ query: SnakeQuery[T] | SnakeCompound[T] | SnakeRecursive[T],
843
+ rows: list[tuple[object, ...]],
844
+ ) -> list[T]:
845
+ """Maps the raw rows of a query to instances of its model, NARROWED if the query narrowed them.
846
+
847
+ A MODULE-LEVEL FUNCTION AND NOT A METHOD, because it never was one: nothing in this body reads
848
+ the session — not the driver, not the dialect — and the only reason it lived on the class was
849
+ that the class is where it was first needed. Leaving it there cost the asynchronous session,
850
+ which cannot inherit it: it wrote the full-row hydration out by hand in three places, so
851
+ `only()` and `defer()` worked on `SnakeSession.all` and raised a `zip()` of unequal lengths on
852
+ `AsyncSession.all`. A decision written once cannot drift; a decision written on one of two
853
+ twins is a promise that the other one will forget it.
854
+
855
+ `only()`/`defer()` narrow the row, so the plan has to be narrowed with it. It is asked of the
856
+ query rather than inferred from the row's WIDTH: two columns of the same table can be projected
857
+ in two ways, and counting is how a mapping lines up against the wrong attribute.
858
+
859
+ It carries the mapping stopwatch (`@timed_mapping`), and it fits because the rows ARRIVED
860
+ ALREADY: this function never touches the driver, so what it times is hydration and nothing else.
861
+ """
862
+ model = query.model # out of the loop: a property that was called per row
863
+ table = registry_of(model).table_of(model)
864
+ assert (
865
+ table is not None
866
+ ) # the query would not have been built without a registered table
867
+ columns = getattr(query, "projected_columns", None)
868
+ if columns is not None:
869
+ plan, missing = partial_plan_for(model, table, columns)
870
+ return [hydrate_partial(model, plan, missing, row) for row in rows]
871
+ return _instantiate_all(model, table, rows)