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,638 @@
1
+ """`AsyncSession`: the same session, awaiting.
2
+
3
+ It is not a copy of `SnakeSession` with `await` in front: the DECISIONS (which SQL to emit and how to
4
+ interpret the rows) live in `planning.py`, colourless, and here they only get executed. Compiler,
5
+ dialect, `query/`, `expressions/`, `sql/` and migrations are reused as they are because they do not
6
+ execute.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import AsyncIterator, Callable, Sequence
12
+ from contextlib import asynccontextmanager
13
+ from types import TracebackType
14
+ from typing import Any, TypeVar, cast
15
+
16
+ from snakeorm.dialects import SnakeDialect
17
+ from snakeorm.drivers.asyncbase import AsyncDriver
18
+ from snakeorm.core.exceptions import (
19
+ SnakeEmitError,
20
+ SnakeRegistryError,
21
+ SnakeUnsupportedFeature,
22
+ )
23
+ from snakeorm.decorators.result import SnakeResult
24
+ from snakeorm.decorators.row import SnakeRow
25
+ from snakeorm.expressions import SnakeExpr, SnakeValue
26
+ from snakeorm.fields import SnakePrefetch, SnakePrefetchHop
27
+ from snakeorm.metadata import SnakeRelationshipInfo, SnakeTableInfo
28
+ from snakeorm.model import SnakeModel
29
+ from snakeorm.query import (
30
+ SnakeCompound,
31
+ SnakeJoinedQuery,
32
+ SnakeQuery,
33
+ SnakeRecursive,
34
+ )
35
+ from snakeorm.registry import SnakeRegistry, registry_of
36
+ from snakeorm.session.guards import (
37
+ _guard_declared_limits,
38
+ guard_can_set_isolation,
39
+ guard_uniform_bulk_columns,
40
+ )
41
+ from snakeorm.session.isolation import SnakeIsolation
42
+ from snakeorm.session.planning import (
43
+ Plan,
44
+ Rows,
45
+ plan_annotate,
46
+ plan_call,
47
+ plan_insert,
48
+ parents_per_batch,
49
+ plan_level,
50
+ plan_raw,
51
+ plan_scalar,
52
+ project_rows,
53
+ routine_name,
54
+ )
55
+ from snakeorm.session.mapper import hydrate_partial, partial_plan_for
56
+ from snakeorm.session.planning import (
57
+ _instantiate,
58
+ _instantiate_with_compiled,
59
+ compile_segments,
60
+ instantiate_rows_with_includes,
61
+ )
62
+ from snakeorm.session.session import SnakeSession, map_rows
63
+ from snakeorm.session.shared import (
64
+ apply_last_insert_id,
65
+ apply_returned,
66
+ column_name,
67
+ direct_column,
68
+ guard_plain_query,
69
+ guard_set_value,
70
+ insert_values,
71
+ pk_condition,
72
+ table_of,
73
+ table_with_pk,
74
+ update_values,
75
+ warn_bulk_loses_generated_keys,
76
+ warn_reduced_fidelity,
77
+ )
78
+ from snakeorm.core.signals import SnakeSignal, warn_bulk_skips_signals
79
+ from snakeorm.core.signals import emit as emit_signal
80
+ from snakeorm.sql import (
81
+ emit_delete,
82
+ emit_insert,
83
+ emit_insert_many,
84
+ emit_select,
85
+ emit_update,
86
+ emit_upsert,
87
+ )
88
+
89
+ T = TypeVar("T")
90
+ ModelT = TypeVar("ModelT", bound=SnakeModel)
91
+ Row = TypeVar("Row", bound="SnakeRow")
92
+ R = TypeVar("R", bound="SnakeResult[Any]")
93
+
94
+
95
+ class AsyncSession:
96
+ """Runs queries against an `AsyncDriver` and maps the results to models."""
97
+
98
+ def __init__(
99
+ self,
100
+ driver: AsyncDriver,
101
+ dialect: SnakeDialect,
102
+ *,
103
+ model_registry: SnakeRegistry | None = None,
104
+ ) -> None:
105
+ """The session, and the registry whose models decide which TYPE caveats it mentions.
106
+
107
+ `model_registry` defaults to the global one, which is what almost every project has. It
108
+ exists because a project built entirely on `@snake_model(registry=...)` used to open a
109
+ session and hear NOTHING: the advisor enumerated the global registry, found no models, and
110
+ the fidelity caveats — the `Decimal` that SQLite degrades, the timestamps without a zone —
111
+ went unsaid. Structural caveats came out either way; the type ones did not.
112
+ """
113
+ self._driver = driver
114
+ self._dialect = dialect
115
+ self._savepoint_depth = 0 # depth of nested savepoints (sp1, sp2, ...)
116
+ warn_reduced_fidelity(dialect, model_registry)
117
+
118
+ @property
119
+ def dialect(self) -> SnakeDialect:
120
+ """This session's dialect, read-only: to ask it what the engine knows how to do.
121
+
122
+ Public because there is application code that LEGITIMATELY changes with the engine — the
123
+ demos' seeder inserts row by row where there is no `RETURNING`, because it needs the ids —
124
+ and the alternative was for each of them to sneak in through `session._dialect`. Asking about
125
+ a declared capability is the exact opposite of coupling to an engine: it is not taking for
126
+ granted the one you happen to have.
127
+ """
128
+ return self._dialect
129
+
130
+ async def __aenter__(self) -> AsyncSession:
131
+ """Enters a transaction: it returns the session itself."""
132
+ return self
133
+
134
+ async def __aexit__(
135
+ self,
136
+ exc_type: type[BaseException] | None,
137
+ exc: BaseException | None,
138
+ traceback: TracebackType | None,
139
+ ) -> None:
140
+ """Commits if it came out clean, rolls back if there was an exception. It does not close the connection."""
141
+ if exc_type is None:
142
+ await self.commit()
143
+ else:
144
+ await self.rollback()
145
+
146
+ async def _run(self, plan: Plan[T]) -> T:
147
+ """Runs a plan: the ONLY real difference from the synchronous session (the driver's `await`)."""
148
+ rows: Rows = (
149
+ await self._driver.fetch_all(plan.sql, plan.params)
150
+ if plan.needs_rows
151
+ else []
152
+ )
153
+ if not plan.needs_rows:
154
+ await self._driver.execute(plan.sql, plan.params)
155
+ return plan.apply(rows)
156
+
157
+ def iterate(
158
+ self,
159
+ query: SnakeQuery[T] | SnakeCompound[T] | SnakeRecursive[T],
160
+ *,
161
+ chunk: int = 1000,
162
+ ) -> AsyncIterator[T]:
163
+ """Async mirror of `SnakeSession.iterate`: it walks the result without materialising it whole.
164
+
165
+ async for invoice in session.iterate(SnakeQuery(Invoice), chunk=500):
166
+ await export(invoice)
167
+
168
+ It is NOT `async def`: it returns the iterator so the GUARD fires on the call and not on the
169
+ first `async for`. With the same restriction as the synchronous one: `include()` of a to-many
170
+ and prefetch RAISE, because the select-in needs every root and in streaming they do not exist.
171
+ """
172
+ if isinstance(query, SnakeQuery):
173
+ SnakeSession._guard_streamable(query)
174
+ return self._stream(query, chunk)
175
+
176
+ async def _stream(
177
+ self,
178
+ query: SnakeQuery[T] | SnakeCompound[T] | SnakeRecursive[T],
179
+ chunk: int,
180
+ ) -> AsyncIterator[T]:
181
+ """The generator of `iterate()`. The guard has already passed; here it only hydrates row by row."""
182
+ if isinstance(query, SnakeQuery) and query.to_one_includes():
183
+ sql, params = query.to_include_sql(self._dialect)
184
+ # Compiled ONCE, above the loop. Resolving inside it cost three lookups per
185
+ # segment per row, and streaming is precisely where the row count is unbounded.
186
+ compiled = compile_segments(query.include_segments())
187
+ async for row in self._driver.fetch_iter(sql, params, chunk=chunk):
188
+ yield cast("T", _instantiate_with_compiled(compiled, row))
189
+ return
190
+ sql, params = query.to_sql(self._dialect)
191
+ table = registry_of(query.model).table_of(query.model)
192
+ assert (
193
+ table is not None
194
+ ) # the query would not have been built without a registered table
195
+ # `only()`/`defer()` narrow the ROW, and the plan has to be narrowed with it. The same
196
+ # question the synchronous `_stream` now asks, and it was missing from BOTH of them: the SQL
197
+ # is compiled by one shared `to_sql`, so a narrowed stream emitted the short SELECT and then
198
+ # hydrated against every column of the table. Written out here as well as there because
199
+ # `await` is syntax and one body cannot serve both colours — which is exactly why a fix to
200
+ # one of the two is a demo that works on two frameworks and raises on the third.
201
+ columns = getattr(query, "projected_columns", None)
202
+ if columns is not None:
203
+ plan, missing = partial_plan_for(query.model, table, columns)
204
+ async for row in self._driver.fetch_iter(sql, params, chunk=chunk):
205
+ yield hydrate_partial(query.model, plan, missing, row)
206
+ return
207
+ async for row in self._driver.fetch_iter(sql, params, chunk=chunk):
208
+ # `_instantiate` and not `hydrate`: it is the only place that decides the concrete class of
209
+ # a polymorphic hierarchy, and calling `hydrate` straight made a streamed read answer with
210
+ # the BASE class while `all()` over the same query answered with the children — and left the
211
+ # siblings' columns on the instance. `_instantiate`'s own docstring lists the paths that
212
+ # inherit the dispatch; this one was missing from the list rather than from the design.
213
+ yield _instantiate(query.model, table, row)
214
+
215
+ async def all(
216
+ self, query: SnakeQuery[T] | SnakeCompound[T] | SnakeRecursive[T]
217
+ ) -> list[T]:
218
+ """Runs the query and returns the rows as instances of the model.
219
+
220
+ It loads the `include`/`prefetch` relationships with the SAME criteria as the synchronous
221
+ session (to-one by LEFT JOIN, to-many by select-in, one query per level): the plans are shared.
222
+ """
223
+ if isinstance(query, (SnakeCompound, SnakeRecursive)) or not query.has_includes:
224
+ sql, params = query.to_sql(self._dialect)
225
+ table = registry_of(query.model).table_of(query.model)
226
+ assert table is not None
227
+ rows = await self._driver.fetch_all(sql, params)
228
+ return map_rows(query, rows)
229
+
230
+ if query.to_one_includes():
231
+ sql, params = query.to_include_sql(self._dialect)
232
+ segments = query.include_segments()
233
+ rows = await self._driver.fetch_all(sql, params)
234
+ parents = cast("list[T]", instantiate_rows_with_includes(segments, rows))
235
+ else:
236
+ sql, params = query.to_sql(self._dialect)
237
+ table = registry_of(query.model).table_of(query.model)
238
+ assert table is not None
239
+ rows = await self._driver.fetch_all(sql, params)
240
+ parents = map_rows(query, rows)
241
+
242
+ parent_table = registry_of(query.model).table_of(query.model)
243
+ assert parent_table is not None
244
+ for relationship in query.to_many_includes():
245
+ await self._load_to_many(parents, parent_table, relationship)
246
+ for prefetch in query.prefetches():
247
+ await self._run_prefetch(parents, prefetch)
248
+ return parents
249
+
250
+ async def _load_to_many(
251
+ self,
252
+ parents: Sequence[object],
253
+ parent_table: SnakeTableInfo,
254
+ relationship: SnakeRelationshipInfo,
255
+ ) -> None:
256
+ """Loads a ONE-hop to-many relationship with a select-in."""
257
+ if not parents:
258
+ return
259
+ # The PARENT's registry, which the rows themselves carry. `_load_to_many` has no query in
260
+ # scope, and the global registry is the wrong answer for any model with `registry=`.
261
+ child_table, child_model = registry_of(type(parents[0])).resolve_relationship(
262
+ relationship
263
+ )
264
+ assert child_model is not None and child_table is not None
265
+ hop = SnakePrefetchHop(
266
+ name=relationship.name,
267
+ kind=relationship.kind,
268
+ parent_table=parent_table,
269
+ child_model=child_model,
270
+ child_table=child_table,
271
+ relationship=relationship,
272
+ )
273
+ await self._load_level(parents, hop)
274
+
275
+ async def _run_prefetch(
276
+ self, roots: Sequence[object], prefetch: SnakePrefetch[Any]
277
+ ) -> None:
278
+ """Resolves a nested prefetch chain LEVEL BY LEVEL: one query per hop, never an N+1.
279
+
280
+ The frontier starts at the roots and advances hop by hop (one level's children are the next
281
+ one's frontier). If it empties out, the chain stops: there are no parents to hang
282
+ grandchildren on.
283
+ """
284
+ frontier: list[object] = list(roots)
285
+ for hop in prefetch.hops():
286
+ if not frontier:
287
+ break
288
+ frontier = await self._load_level(frontier, hop)
289
+
290
+ async def _load_level(
291
+ self, parents: Sequence[object], hop: SnakePrefetchHop
292
+ ) -> list[object]:
293
+ """Runs the plan of ONE level (to-one, to-many or many-to-many), in as many statements as the
294
+ engine's placeholder ceiling needs.
295
+
296
+ Which plan is due is decided by `planning.plan_level`, and how many parents fit in a batch by
297
+ `planning.parents_per_batch` — the same two the synchronous session uses. Splitting here in a
298
+ way of its own is exactly how the two halves drifted the last time.
299
+ """
300
+ loaded: list[object] = []
301
+ size = parents_per_batch(hop, self._dialect)
302
+ for start in range(0, len(parents), size):
303
+ plan = plan_level(parents[start : start + size], hop, self._dialect)
304
+ if plan is None:
305
+ continue
306
+ sql, params, attach = plan
307
+ loaded.extend(attach(await self._driver.fetch_all(sql, params)))
308
+ return loaded
309
+
310
+ async def first(
311
+ self, query: SnakeQuery[T] | SnakeCompound[T] | SnakeRecursive[T]
312
+ ) -> T | None:
313
+ """The first row, or None. It bounds with LIMIT 1: it does not fetch the rest just to throw it away."""
314
+ rows = await self.all(query.limit(1))
315
+ return rows[0] if rows else None
316
+
317
+ async def count(self, query: SnakeQuery[T]) -> int:
318
+ """Counts the rows that match the query."""
319
+ guard_plain_query(query, "count")
320
+ sql, params = query.to_count_sql(self._dialect)
321
+ return cast(
322
+ "int",
323
+ await self._run(plan_scalar(sql, params, lambda v: int(cast("int", v)))),
324
+ )
325
+
326
+ async def exists(self, query: SnakeQuery[T]) -> bool:
327
+ """Tells whether any row matching the query exists."""
328
+ guard_plain_query(query, "exists")
329
+ sql, params = query.to_exists_sql(self._dialect)
330
+ return cast("bool", await self._run(plan_scalar(sql, params, bool)))
331
+
332
+ async def add(self, instance: ModelT) -> ModelT:
333
+ """Inserts the instance and copies back onto it whatever the `RETURNING` gives.
334
+
335
+ The plan is built by `planning.plan_insert`, the same one as the synchronous session (a
336
+ single decision about which columns go and what gets copied back).
337
+ """
338
+ table = table_of(instance)
339
+ emit_signal(type(instance), SnakeSignal.PRE_SAVE, instance)
340
+ plan = plan_insert(
341
+ instance,
342
+ table,
343
+ self._dialect,
344
+ insert_values(instance, table),
345
+ apply_returned,
346
+ )
347
+ await self._run(plan)
348
+ if not self._dialect.supports_returning:
349
+ # Same as in the synchronous one: without RETURNING the PK does not come back in the
350
+ # row and it has to be fetched from the driver. It does not fit in the plan because the
351
+ # plan is COLOURLESS and this asks the connection — which is exactly why it was left
352
+ # unwritten here.
353
+ apply_last_insert_id(instance, table, self._driver)
354
+ emit_signal(type(instance), SnakeSignal.POST_SAVE, instance)
355
+ return instance
356
+
357
+ async def update(self, instance: SnakeModel) -> None:
358
+ """Updates the instance's non-PK columns, filtering by its primary key."""
359
+ table = table_with_pk(instance)
360
+ values = update_values(instance, table)
361
+ emit_signal(type(instance), SnakeSignal.PRE_SAVE, instance)
362
+ sql, params = emit_update(
363
+ table, self._dialect, values, where=pk_condition(table, instance)
364
+ )
365
+ await self._driver.execute(sql, params)
366
+ emit_signal(type(instance), SnakeSignal.POST_SAVE, instance)
367
+
368
+ async def delete(self, instance: SnakeModel) -> None:
369
+ """Deletes the instance's row, filtering by its primary key."""
370
+ table = table_with_pk(instance)
371
+ emit_signal(type(instance), SnakeSignal.PRE_DELETE, instance)
372
+ sql, params = emit_delete(
373
+ table, self._dialect, where=pk_condition(table, instance)
374
+ )
375
+ await self._driver.execute(sql, params)
376
+ emit_signal(type(instance), SnakeSignal.POST_DELETE, instance)
377
+
378
+ async def delete_where(self, query: SnakeQuery[ModelT], /) -> int:
379
+ """Deletes IN BULK the rows that match the filter. It warns about the signals it skips."""
380
+ warn_bulk_skips_signals(query.model, "delete_where")
381
+ sql, params = query.to_delete_sql(self._dialect)
382
+ return await self._driver.execute(sql, params)
383
+
384
+ async def update_where(
385
+ self, query: SnakeQuery[ModelT], values: list[tuple[Any, object]], /
386
+ ) -> int:
387
+ """Updates IN BULK. It warns about the signals it skips, just like the synchronous session."""
388
+ warn_bulk_skips_signals(query.model, "update_where")
389
+ columns: dict[str, object] = {}
390
+ for column, value in values:
391
+ guard_set_value(value)
392
+ columns[column_name(column)] = value
393
+ # Same guard as the synchronous session: a declared limit holds just the same while
394
+ # awaiting. The EXPRESSIONS, whose value the server computes, are filtered out.
395
+ table = registry_of(query.model).table_of(query.model)
396
+ assert (
397
+ table is not None
398
+ ) # the query would not have been built without a registered table
399
+ _guard_declared_limits(
400
+ table,
401
+ {
402
+ k: v
403
+ for k, v in columns.items()
404
+ if not isinstance(v, (SnakeValue, SnakeExpr))
405
+ },
406
+ )
407
+ sql, params = query.to_update_sql(self._dialect, columns)
408
+ return await self._driver.execute(sql, params)
409
+
410
+ async def select(
411
+ self,
412
+ query: SnakeQuery[Any] | SnakeJoinedQuery[Any, Any],
413
+ /,
414
+ *columns: SnakeValue[Any],
415
+ ) -> list[tuple[Any, ...]]:
416
+ """Projects columns and/or aggregates: it returns TUPLES, not instances.
417
+
418
+ The projection and the coercion live in `planning.project_rows`, colourless: here it only
419
+ awaits. Typed like its synchronous sibling's implementation: `query: Any` there switched off
420
+ the guard that a compound query cannot be projected, and `*columns: Any` let a plain value
421
+ through where a `SnakeValue` is required.
422
+ """
423
+ guard_plain_query(query, "select")
424
+ sql, params = query.to_project_sql(self._dialect, columns)
425
+ rows = await self._driver.fetch_all(sql, params)
426
+ return project_rows(query, columns, rows)
427
+
428
+ async def annotate(
429
+ self,
430
+ query: SnakeQuery[T],
431
+ result: type[R],
432
+ /,
433
+ **aggregates: SnakeValue[Any],
434
+ ) -> list[R]:
435
+ """Annotates each row with aggregates and wraps it in a typed `@snake_result`.
436
+
437
+ Typed EXACTLY like its synchronous sibling: the `R: SnakeResult` bound (which rejects a
438
+ `result` that is not a `@snake_result`) must stay live in async, not `Any -> list[Any]`.
439
+ """
440
+ sql, params, build = plan_annotate(query, result, self._dialect, aggregates)
441
+ return build(await self._driver.fetch_all(sql, params))
442
+
443
+ async def call(
444
+ self, name: str, args: Sequence[object], *, into: type[Row]
445
+ ) -> list[Row]:
446
+ """Calls a database FUNCTION that returns rows and hydrates them into `into`.
447
+
448
+ Typed EXACTLY like its synchronous sibling: `into: Any -> list[Any]` would switch off the
449
+ `Row: SnakeRow` bound (the lock that rejects an `into` that is not a `@snake_row`).
450
+ """
451
+ sql, params, build = plan_call(name, args, into, self._dialect)
452
+ return build(await self._driver.fetch_all(sql, params))
453
+
454
+ async def explain(self, query: SnakeQuery[ModelT]) -> list[str]:
455
+ """The plan for this query, without running it. Same contract as the synchronous session."""
456
+ sql, params = query.to_sql(self._dialect)
457
+ rows = await self._driver.fetch_all(self._dialect.explain_sql(sql), params)
458
+ return [
459
+ " ".join("" if cell is None else str(cell) for cell in row) for row in rows
460
+ ]
461
+
462
+ async def raw(
463
+ self, sql: str, params: Sequence[object] = (), *, into: type[Row]
464
+ ) -> list[Row]:
465
+ """The escape hatch: raw SQL hydrated into a DECLARED shape (`@snake_row`).
466
+
467
+ Parametrised values; the SHAPE is not checked (the same contract as the synchronous session:
468
+ you declare, I hydrate).
469
+ """
470
+ build = plan_raw(into)
471
+ return build(await self._driver.fetch_all(sql, tuple(params)))
472
+
473
+ async def add_all(self, instances: Sequence[ModelT], /) -> None:
474
+ """Inserts a batch with a single multi-row INSERT per chunk.
475
+
476
+ The PREs are ALL fired before anything is emitted (a handler can modify the instance, and
477
+ doing so halfway through the batch would leave some rows with the change and others without).
478
+ """
479
+ if not instances:
480
+ return
481
+ model = type(instances[0])
482
+ for instance in instances:
483
+ if type(instance) is not model:
484
+ raise SnakeEmitError(
485
+ "add_all requires every instance to be of the same model; "
486
+ f"{model.__name__} and {type(instance).__name__} were mixed."
487
+ )
488
+ table = table_of(instances[0])
489
+ for instance in instances:
490
+ emit_signal(model, SnakeSignal.PRE_SAVE, instance)
491
+ warn_bulk_loses_generated_keys(self._dialect, table)
492
+ rows = [insert_values(instance, table) for instance in instances]
493
+ # The same guard as the synchronous colour, from the same shared function: the defect was
494
+ # identical in both, letting the first instance answer for the batch.
495
+ guard_uniform_bulk_columns(rows, model.__name__)
496
+ # An all-defaults batch -> `insert_values` gives `{}` -> `DEFAULT VALUES`, of ONE row
497
+ # (there is no portable all-defaults multi-row). Same path as `add`.
498
+ if not rows[0]:
499
+ for instance in instances:
500
+ sql, params = emit_insert(table, self._dialect, {})
501
+ if self._dialect.supports_returning:
502
+ apply_returned(
503
+ instance, table, (await self._driver.fetch_all(sql, params))[0]
504
+ )
505
+ else:
506
+ await self._driver.execute(sql, params)
507
+ for instance in instances:
508
+ emit_signal(model, SnakeSignal.POST_SAVE, instance)
509
+ return
510
+ chunk = max(1, self._dialect.max_bind_params // max(1, len(rows[0])))
511
+ for start in range(0, len(instances), chunk):
512
+ batch = instances[start : start + chunk]
513
+ sql, params = emit_insert_many(
514
+ table, self._dialect, rows[start : start + chunk]
515
+ )
516
+ if self._dialect.supports_returning:
517
+ returned = await self._driver.fetch_all(sql, params)
518
+ for instance, row in zip(batch, returned, strict=True):
519
+ apply_returned(instance, table, row)
520
+ else:
521
+ await self._driver.execute(sql, params)
522
+ for instance in instances:
523
+ emit_signal(model, SnakeSignal.POST_SAVE, instance)
524
+
525
+ async def upsert(
526
+ self,
527
+ instance: SnakeModel,
528
+ /,
529
+ *,
530
+ on_conflict: Sequence[Any],
531
+ update: Sequence[Any] = (),
532
+ ) -> None:
533
+ """Inserts resolving the conflict over `on_conflict` (an idempotent upsert)."""
534
+ if not self._dialect.supports_upsert:
535
+ raise SnakeUnsupportedFeature(
536
+ "this dialect does not support upsert; it is NOT emulated with SELECT+INSERT "
537
+ "because that emulation has a race condition and would fake an atomicity it "
538
+ "does not have."
539
+ )
540
+ table = table_of(instance)
541
+ emit_signal(type(instance), SnakeSignal.PRE_SAVE, instance)
542
+ sql, params = emit_upsert(
543
+ table,
544
+ self._dialect,
545
+ insert_values(instance, table),
546
+ conflict_columns=[direct_column(column) for column in on_conflict],
547
+ update_columns=[direct_column(column) for column in update],
548
+ )
549
+ if self._dialect.supports_returning:
550
+ rows = await self._driver.fetch_all(sql, params)
551
+ if rows:
552
+ apply_returned(instance, table, rows[0])
553
+ else:
554
+ await self._driver.execute(sql, params)
555
+ emit_signal(type(instance), SnakeSignal.POST_SAVE, instance)
556
+
557
+ async def refresh(self, instance: ModelT) -> ModelT:
558
+ """Reloads the instance from the database, overwriting ALL of its columns."""
559
+ table = table_with_pk(instance)
560
+ sql, params = emit_select(
561
+ table, self._dialect, where=pk_condition(table, instance)
562
+ )
563
+ rows = await self._driver.fetch_all(sql, params)
564
+ if not rows:
565
+ raise SnakeRegistryError(
566
+ f"{type(instance).__name__} could not be refreshed: its row is gone from "
567
+ f"'{table.name}'. Did another transaction delete it?"
568
+ )
569
+ apply_returned(instance, table, rows[0])
570
+ return instance
571
+
572
+ async def get_or_create(
573
+ self, query: SnakeQuery[ModelT], build: Callable[[], ModelT]
574
+ ) -> tuple[ModelT, bool]:
575
+ """Looks it up and, if there is nothing, inserts whatever `build` returns. Gives `(row, created)`.
576
+
577
+ The boolean is the reason it exists: `upsert` writes too, but it does not say whether it
578
+ created it or it was already there.
579
+ """
580
+ found = await self.first(query)
581
+ if found is not None:
582
+ return found, False
583
+ return await self.add(build()), True
584
+
585
+ async def set_isolation(self, level: SnakeIsolation) -> None:
586
+ """Sets the ISOLATION of the transaction starting now (before reading or writing).
587
+
588
+ The engine is ASKED first, through the SAME guard the synchronous session calls. This used
589
+ to hand the statement straight to the driver, so on an engine without it SQLite answered
590
+ `near "SET": syntax error` — the failure its sibling's docstring says the check exists to
591
+ prevent, alive in this colour because the fix had been applied to one of the two.
592
+ """
593
+ guard_can_set_isolation(self._dialect)
594
+ await self._driver.execute(f"SET TRANSACTION ISOLATION LEVEL {level.value}", ())
595
+
596
+ @asynccontextmanager
597
+ async def savepoint(self) -> AsyncIterator[None]:
598
+ """SAVEPOINT as an asynchronous context manager: it isolates a block inside the transaction.
599
+
600
+ Same contract as the synchronous one (nesting by depth): on a clean exit `RELEASE`, if the
601
+ block raises it discards ONLY what is inside and re-raises.
602
+ """
603
+ self._savepoint_depth += 1
604
+ name = f"sp{self._savepoint_depth}"
605
+ await self._driver.savepoint(name)
606
+ try:
607
+ yield
608
+ except Exception:
609
+ await self._driver.rollback_to_savepoint(name)
610
+ raise
611
+ else:
612
+ await self._driver.release_savepoint(name)
613
+ finally:
614
+ self._savepoint_depth -= 1
615
+
616
+ async def execute_procedure(self, name: str, args: Sequence[object]) -> None:
617
+ """Runs a PROCEDURE that returns NO rows (`CALL name(...)`); the opposite of `call(...)`.
618
+
619
+ The ARGS travel parametrised (user data); the NAME goes through the same `routine_name`
620
+ check the other three doors use — the SAME sentence, not a reworded one.
621
+ """
622
+ checked = routine_name(name)
623
+ placeholders = ", ".join(
624
+ self._dialect.placeholder(index + 1) for index in range(len(args))
625
+ )
626
+ await self._driver.execute(f"CALL {checked}({placeholders})", list(args))
627
+
628
+ async def commit(self) -> None:
629
+ """Commits the transaction in progress."""
630
+ await self._driver.commit()
631
+
632
+ async def rollback(self) -> None:
633
+ """Rolls back the transaction in progress."""
634
+ await self._driver.rollback()
635
+
636
+ async def close(self) -> None:
637
+ """Closes the underlying connection."""
638
+ await self._driver.close()