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,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)
|