snake-orm 0.1.0b1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- snake_orm-0.1.0b1.dist-info/METADATA +366 -0
- snake_orm-0.1.0b1.dist-info/RECORD +198 -0
- snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
- snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
- snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
- snakeorm/__init__.py +214 -0
- snakeorm/advisor.py +109 -0
- snakeorm/cli/__init__.py +3 -0
- snakeorm/cli/app.py +992 -0
- snakeorm/cli/discovery.py +179 -0
- snakeorm/cli/hooks.py +86 -0
- snakeorm/compiler/__init__.py +3 -0
- snakeorm/compiler/compiler.py +330 -0
- snakeorm/connection.py +228 -0
- snakeorm/contrib/__init__.py +17 -0
- snakeorm/contrib/asgi.py +192 -0
- snakeorm/contrib/config.py +264 -0
- snakeorm/contrib/deliver.py +256 -0
- snakeorm/contrib/django.py +245 -0
- snakeorm/contrib/sidecar.py +44 -0
- snakeorm/contrib/wsgi.py +176 -0
- snakeorm/core/__init__.py +1 -0
- snakeorm/core/config.py +173 -0
- snakeorm/core/converters.py +172 -0
- snakeorm/core/exceptions.py +175 -0
- snakeorm/core/placement.py +15 -0
- snakeorm/core/sentinels.py +37 -0
- snakeorm/core/signals.py +114 -0
- snakeorm/debug/__init__.py +42 -0
- snakeorm/debug/assets/css/panel.css +616 -0
- snakeorm/debug/assets/css/snake_orm_app.css +293 -0
- snakeorm/debug/assets/img/icon.svg +5 -0
- snakeorm/debug/assets/js/history.js +425 -0
- snakeorm/debug/assets/js/language.js +283 -0
- snakeorm/debug/assets/js/panel.js +241 -0
- snakeorm/debug/assets/js/snake_orm_app.js +82 -0
- snakeorm/debug/assets/pages/cli.html +28 -0
- snakeorm/debug/assets/pages/config.html +34 -0
- snakeorm/debug/assets/pages/database_first.html +29 -0
- snakeorm/debug/assets/pages/help.html +21 -0
- snakeorm/debug/assets/pages/history.html +27 -0
- snakeorm/debug/capture.py +234 -0
- snakeorm/debug/channel.py +131 -0
- snakeorm/debug/collector.py +144 -0
- snakeorm/debug/config.py +149 -0
- snakeorm/debug/html.py +578 -0
- snakeorm/debug/matrix.py +60 -0
- snakeorm/debug/origin.py +42 -0
- snakeorm/debug/otel/__init__.py +68 -0
- snakeorm/debug/otel/context.py +43 -0
- snakeorm/debug/otel/exporter.py +277 -0
- snakeorm/debug/otel/payload.py +97 -0
- snakeorm/debug/otel/report.py +29 -0
- snakeorm/debug/otel/spans.py +393 -0
- snakeorm/debug/otel/summary.py +59 -0
- snakeorm/debug/record.py +54 -0
- snakeorm/debug/report.py +311 -0
- snakeorm/debug/testing.py +23 -0
- snakeorm/decorators/__init__.py +13 -0
- snakeorm/decorators/abstract.py +26 -0
- snakeorm/decorators/db_first.py +104 -0
- snakeorm/decorators/function.py +28 -0
- snakeorm/decorators/model.py +268 -0
- snakeorm/decorators/polymorphic.py +140 -0
- snakeorm/decorators/result.py +167 -0
- snakeorm/decorators/row.py +90 -0
- snakeorm/decorators/trigger.py +47 -0
- snakeorm/decorators/view.py +149 -0
- snakeorm/dialects/__init__.py +6 -0
- snakeorm/dialects/base.py +373 -0
- snakeorm/dialects/capabilities.py +571 -0
- snakeorm/dialects/literals.py +46 -0
- snakeorm/dialects/matrix.py +384 -0
- snakeorm/dialects/mysql.py +519 -0
- snakeorm/dialects/postgres.py +504 -0
- snakeorm/dialects/sqlite.py +417 -0
- snakeorm/drivers/__init__.py +18 -0
- snakeorm/drivers/asyncbase.py +73 -0
- snakeorm/drivers/asyncdecorators.py +244 -0
- snakeorm/drivers/asyncpool.py +228 -0
- snakeorm/drivers/asyncpsycopg.py +124 -0
- snakeorm/drivers/asyncpymysql.py +50 -0
- snakeorm/drivers/asyncsqlite.py +30 -0
- snakeorm/drivers/base.py +76 -0
- snakeorm/drivers/failures.py +181 -0
- snakeorm/drivers/logging.py +167 -0
- snakeorm/drivers/pool.py +352 -0
- snakeorm/drivers/psycopg.py +188 -0
- snakeorm/drivers/pymysql.py +176 -0
- snakeorm/drivers/savepoints.py +46 -0
- snakeorm/drivers/sqlite.py +170 -0
- snakeorm/drivers/threaded.py +143 -0
- snakeorm/drivers/timeout.py +87 -0
- snakeorm/dto/__init__.py +55 -0
- snakeorm/dto/read.py +231 -0
- snakeorm/dto/region.py +326 -0
- snakeorm/dto/resolve.py +354 -0
- snakeorm/dto/spec.py +201 -0
- snakeorm/expressions/__init__.py +88 -0
- snakeorm/expressions/conditional.py +178 -0
- snakeorm/expressions/expression.py +810 -0
- snakeorm/expressions/functions.py +71 -0
- snakeorm/expressions/keys.py +299 -0
- snakeorm/expressions/paths.py +43 -0
- snakeorm/expressions/scalar.py +293 -0
- snakeorm/expressions/window.py +248 -0
- snakeorm/fields/__init__.py +68 -0
- snakeorm/fields/check.py +79 -0
- snakeorm/fields/column.py +316 -0
- snakeorm/fields/enum.py +64 -0
- snakeorm/fields/index.py +46 -0
- snakeorm/fields/relationship.py +738 -0
- snakeorm/fields/typed.py +736 -0
- snakeorm/helpers/__init__.py +1 -0
- snakeorm/helpers/annotations.py +21 -0
- snakeorm/helpers/inheritance.py +41 -0
- snakeorm/helpers/pyliteral.py +52 -0
- snakeorm/helpers/pytype.py +50 -0
- snakeorm/introspection/__init__.py +13 -0
- snakeorm/introspection/base.py +30 -0
- snakeorm/introspection/drift.py +78 -0
- snakeorm/introspection/models.py +633 -0
- snakeorm/introspection/mysql.py +367 -0
- snakeorm/introspection/postgres.py +397 -0
- snakeorm/introspection/sqlite.py +217 -0
- snakeorm/introspection/unsupported.py +94 -0
- snakeorm/linker/__init__.py +3 -0
- snakeorm/linker/linker.py +521 -0
- snakeorm/metadata/__init__.py +41 -0
- snakeorm/metadata/check.py +37 -0
- snakeorm/metadata/column.py +201 -0
- snakeorm/metadata/enum_storage.py +33 -0
- snakeorm/metadata/fk_action.py +19 -0
- snakeorm/metadata/foreign_key.py +26 -0
- snakeorm/metadata/index.py +43 -0
- snakeorm/metadata/index_method.py +19 -0
- snakeorm/metadata/int_size.py +20 -0
- snakeorm/metadata/json_storage.py +21 -0
- snakeorm/metadata/polymorphic.py +30 -0
- snakeorm/metadata/primary_key.py +23 -0
- snakeorm/metadata/relationship.py +60 -0
- snakeorm/metadata/relationship_kind.py +43 -0
- snakeorm/metadata/routine.py +26 -0
- snakeorm/metadata/server_default.py +20 -0
- snakeorm/metadata/table.py +97 -0
- snakeorm/metadata/table_kind.py +23 -0
- snakeorm/metadata/trigger.py +62 -0
- snakeorm/metadata/type_params.py +348 -0
- snakeorm/migration/__init__.py +88 -0
- snakeorm/migration/asyncrunner.py +168 -0
- snakeorm/migration/autodetect.py +96 -0
- snakeorm/migration/ddl.py +1172 -0
- snakeorm/migration/diff.py +701 -0
- snakeorm/migration/loader.py +77 -0
- snakeorm/migration/operations.py +952 -0
- snakeorm/migration/planner.py +78 -0
- snakeorm/migration/realize.py +420 -0
- snakeorm/migration/renames.py +153 -0
- snakeorm/migration/render.py +1095 -0
- snakeorm/migration/runner.py +314 -0
- snakeorm/migration/squash.py +60 -0
- snakeorm/migration/state.py +107 -0
- snakeorm/model.py +162 -0
- snakeorm/py.typed +0 -0
- snakeorm/query/__init__.py +9 -0
- snakeorm/query/compound.py +288 -0
- snakeorm/query/join_kind.py +19 -0
- snakeorm/query/joined.py +143 -0
- snakeorm/query/query.py +1001 -0
- snakeorm/query/recursive.py +244 -0
- snakeorm/registry/__init__.py +5 -0
- snakeorm/registry/by_module.py +163 -0
- snakeorm/registry/registry.py +166 -0
- snakeorm/session/__init__.py +8 -0
- snakeorm/session/asyncsession.py +638 -0
- snakeorm/session/coercion.py +306 -0
- snakeorm/session/factory.py +29 -0
- snakeorm/session/guards.py +269 -0
- snakeorm/session/isolation.py +23 -0
- snakeorm/session/mapper.py +213 -0
- snakeorm/session/planning.py +815 -0
- snakeorm/session/retry.py +96 -0
- snakeorm/session/session.py +871 -0
- snakeorm/session/shared.py +355 -0
- snakeorm/sql/__init__.py +18 -0
- snakeorm/sql/adapt.py +104 -0
- snakeorm/sql/aggregate.py +232 -0
- snakeorm/sql/condition.py +359 -0
- snakeorm/sql/delete.py +46 -0
- snakeorm/sql/insert.py +149 -0
- snakeorm/sql/joins.py +158 -0
- snakeorm/sql/pk_subquery.py +54 -0
- snakeorm/sql/refs.py +19 -0
- snakeorm/sql/resolver.py +28 -0
- snakeorm/sql/select.py +175 -0
- snakeorm/sql/update.py +82 -0
- snakeorm/sql/value.py +552 -0
- snakeorm/times.py +276 -0
|
@@ -0,0 +1,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()
|