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,815 @@
|
|
|
1
|
+
"""What has to be done, WITHOUT doing it: the colourless plan both sessions share.
|
|
2
|
+
|
|
3
|
+
Here lives the single copy of the decisions — which SQL to emit and how to interpret the rows — and
|
|
4
|
+
the synchronous and asynchronous sessions confine themselves to EXECUTING it. Copying the session
|
|
5
|
+
(>1000 lines) with an `await` in front would create two places to fix each bug; this branch has
|
|
6
|
+
already caught three that way.
|
|
7
|
+
|
|
8
|
+
The way back (row -> object) lives here too, which used to be in `session.py` with lazy imports: the
|
|
9
|
+
extracted core depended on one of its own consumers. No function uses the session, so bringing them
|
|
10
|
+
over left the dependency running one way only. Every function returns `(sql, params, apply)`; none
|
|
11
|
+
of them touches a driver.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from snakeorm.decorators.result import snake_result_info
|
|
17
|
+
from snakeorm.decorators.row import snake_row_info
|
|
18
|
+
from snakeorm.core.exceptions import SnakeEmitError
|
|
19
|
+
from snakeorm.model import attach_aggregates
|
|
20
|
+
from snakeorm.registry import SnakeRegistry, registry_of
|
|
21
|
+
from snakeorm.session.coercion import coerce
|
|
22
|
+
from snakeorm.sql import emit_insert
|
|
23
|
+
from snakeorm.sql import emit_select
|
|
24
|
+
from snakeorm.sql.condition import emit_condition
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from typing import TYPE_CHECKING, cast
|
|
28
|
+
|
|
29
|
+
from snakeorm.decorators.result import SnakeResultInfo
|
|
30
|
+
from snakeorm.debug.collector import timed_mapping
|
|
31
|
+
from snakeorm.fields import SnakePrefetchHop
|
|
32
|
+
from snakeorm.fields.relationship import attach_relationship
|
|
33
|
+
from snakeorm.metadata import SnakeColumnInfo
|
|
34
|
+
from snakeorm.core.exceptions import SnakeUnknownColumn, SnakeValueError
|
|
35
|
+
from snakeorm.expressions import (
|
|
36
|
+
SnakeAggFunc,
|
|
37
|
+
SnakeAggregate,
|
|
38
|
+
SnakeAnd,
|
|
39
|
+
SnakeCondition,
|
|
40
|
+
SnakeExpr,
|
|
41
|
+
SnakeTupleIn,
|
|
42
|
+
SnakeValue,
|
|
43
|
+
)
|
|
44
|
+
from snakeorm.session.mapper import (
|
|
45
|
+
_Dispatch,
|
|
46
|
+
_hydrate_with_plan,
|
|
47
|
+
_Instruction,
|
|
48
|
+
dispatch_for,
|
|
49
|
+
hydrate,
|
|
50
|
+
pk_positions,
|
|
51
|
+
plan_for,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
from collections.abc import Callable, Sequence
|
|
55
|
+
from dataclasses import dataclass
|
|
56
|
+
from typing import Any, Generic, TypeVar
|
|
57
|
+
|
|
58
|
+
from snakeorm.dialects import SnakeDialect
|
|
59
|
+
from snakeorm.metadata import SnakeRelationshipKind, SnakeTableInfo
|
|
60
|
+
from snakeorm.sql.refs import qualified
|
|
61
|
+
|
|
62
|
+
if TYPE_CHECKING:
|
|
63
|
+
# For the checker only. `query/` does not import `session/`, so this would not be a cycle at
|
|
64
|
+
# runtime either; it stays here because nothing in this module CALLS a query, it only describes
|
|
65
|
+
# the shape it receives, and an import paid at start-up for an annotation is an import too many.
|
|
66
|
+
from snakeorm.query import SnakeJoinedQuery, SnakeQuery
|
|
67
|
+
|
|
68
|
+
T = TypeVar("T")
|
|
69
|
+
R = TypeVar("R")
|
|
70
|
+
|
|
71
|
+
Rows = list[tuple[object, ...]]
|
|
72
|
+
"""What a driver returns: raw rows, uninterpreted."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True)
|
|
76
|
+
class Plan(Generic[R]):
|
|
77
|
+
"""A parametrised SQL and what to do with whatever comes back.
|
|
78
|
+
|
|
79
|
+
`needs_rows` tells EXECUTING apart from QUERYING: whoever built the plan knows it, it is not
|
|
80
|
+
guessed from the string.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
sql: str
|
|
84
|
+
params: tuple[object, ...]
|
|
85
|
+
apply: Callable[[Rows], R]
|
|
86
|
+
needs_rows: bool = True
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def plan_insert(
|
|
90
|
+
instance: object,
|
|
91
|
+
table: SnakeTableInfo,
|
|
92
|
+
dialect: SnakeDialect,
|
|
93
|
+
values: dict[str, object],
|
|
94
|
+
apply_returned: Callable[[object, SnakeTableInfo, tuple[object, ...]], None],
|
|
95
|
+
) -> Plan[object]:
|
|
96
|
+
"""Plan of an INSERT: the SQL and what to do with the row the `RETURNING` gives back.
|
|
97
|
+
|
|
98
|
+
With `RETURNING`, ALL the columns come back (server defaults, trigger columns) into the in-memory
|
|
99
|
+
object; without it there is nothing to apply.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
# The discriminator is put in by the CLASS, not by the user: trusting them to remember
|
|
103
|
+
# `Dog(kind="dog")` would store a row that is later read as a generic `Animal` and with no
|
|
104
|
+
# error. It gets overwritten.
|
|
105
|
+
if table.is_polymorphic_child:
|
|
106
|
+
assert table.polymorphic is not None
|
|
107
|
+
values = {**values, table.polymorphic.column: table.polymorphic.value}
|
|
108
|
+
|
|
109
|
+
sql, params = emit_insert(table, dialect, values)
|
|
110
|
+
|
|
111
|
+
def apply(rows: Rows) -> object:
|
|
112
|
+
"""Copies back whatever the server returned."""
|
|
113
|
+
if rows:
|
|
114
|
+
apply_returned(instance, table, rows[0])
|
|
115
|
+
return instance
|
|
116
|
+
|
|
117
|
+
return Plan(
|
|
118
|
+
sql=sql, params=params, apply=apply, needs_rows=dialect.supports_returning
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def plan_scalar(
|
|
123
|
+
sql: str, params: tuple[object, ...], cast_to: Callable[[object], Any]
|
|
124
|
+
) -> Plan[Any]:
|
|
125
|
+
"""Plan of a query that returns ONE value (a `COUNT(*)`, an `EXISTS`)."""
|
|
126
|
+
return Plan(sql=sql, params=params, apply=lambda rows: cast_to(rows[0][0]))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@timed_mapping
|
|
130
|
+
def project_rows(
|
|
131
|
+
query: SnakeQuery[Any] | SnakeJoinedQuery[Any, Any],
|
|
132
|
+
columns: Sequence[SnakeValue[Any]],
|
|
133
|
+
rows: Rows,
|
|
134
|
+
) -> list[tuple[Any, ...]]:
|
|
135
|
+
"""Coerces each projected value to the `python_type` that can be determined.
|
|
136
|
+
|
|
137
|
+
Colourless: which type each column has does not depend on whether the driver awaited. Both
|
|
138
|
+
sessions use it.
|
|
139
|
+
|
|
140
|
+
Timed as mapping too: a `.join()` gives back tuples rather than models, but the work is the same
|
|
141
|
+
work — driver values turned into the Python the user declared — and leaving it out would send it
|
|
142
|
+
to `app_ms`, where the panel says the ORM is not.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
reg = registry_of(query.model)
|
|
146
|
+
root = reg.table_of(query.model)
|
|
147
|
+
types = [_projected_python_type(column, root, reg) for column in columns]
|
|
148
|
+
return [
|
|
149
|
+
tuple(
|
|
150
|
+
coerce(value, declared) if declared is not None else value
|
|
151
|
+
for value, declared in zip(row, types, strict=True)
|
|
152
|
+
)
|
|
153
|
+
for row in rows
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def plan_annotate(
|
|
158
|
+
query: SnakeQuery[Any],
|
|
159
|
+
result: type[Any],
|
|
160
|
+
dialect: SnakeDialect,
|
|
161
|
+
aggregates: dict[str, SnakeValue[Any]],
|
|
162
|
+
) -> tuple[str, tuple[object, ...], Callable[[Rows], list[Any]]]:
|
|
163
|
+
"""SQL of an `annotate` and how to build the `@snake_result`s with the rows that come back.
|
|
164
|
+
|
|
165
|
+
`result` is `type[Any]` and not `type[SnakeResult[Any]]` because it gets CALLED with keywords a
|
|
166
|
+
few lines down: the `__init__` that takes them is synthesised by `@dataclass_transform` on the
|
|
167
|
+
concrete subclass, and the marker base does not carry it. The lock that rejects a class which is
|
|
168
|
+
not a `@snake_result` is the `R: SnakeResult` bound of both sessions, and `snake_result_info`
|
|
169
|
+
behind it — not this annotation.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
info = snake_result_info(result)
|
|
173
|
+
if query.model is not info.base_model:
|
|
174
|
+
raise SnakeEmitError(
|
|
175
|
+
f"The base model of {result.__name__} is {info.base_model.__name__}, but the query is "
|
|
176
|
+
f"over {query.model.__name__}: they do not match. Query the same model that the "
|
|
177
|
+
f"@snake_result declares."
|
|
178
|
+
)
|
|
179
|
+
_validate_aggregate_names(result, info, aggregates)
|
|
180
|
+
base_table = registry_of(info.base_model).table_of(info.base_model)
|
|
181
|
+
assert base_table is not None
|
|
182
|
+
ordered = tuple(aggregates[name] for name, _ in info.scalars)
|
|
183
|
+
sql, params = query.to_annotate_sql(dialect, ordered)
|
|
184
|
+
width = len(base_table.columns)
|
|
185
|
+
|
|
186
|
+
@timed_mapping
|
|
187
|
+
def build(rows: Rows) -> list[Any]:
|
|
188
|
+
"""Hydrates the base instance and hooks the aggregates onto it."""
|
|
189
|
+
output: list[Any] = []
|
|
190
|
+
for row in rows:
|
|
191
|
+
base: object = _instantiate(info.base_model, base_table, row[:width])
|
|
192
|
+
values = {
|
|
193
|
+
name: coerce(row[width + index], declared)
|
|
194
|
+
for index, (name, declared) in enumerate(info.scalars)
|
|
195
|
+
}
|
|
196
|
+
attach_aggregates(base, values)
|
|
197
|
+
output.append(result(**{info.base_field: base, **values}))
|
|
198
|
+
return output
|
|
199
|
+
|
|
200
|
+
return sql, params, build
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
_ROUTINE_PART = re.compile(r"[^\W\d][\w$]*")
|
|
204
|
+
"""One dot-separated part of a routine name: a letter or `_`, then letters, digits, `_` or `$`.
|
|
205
|
+
|
|
206
|
+
Deliberately the UNQUOTED identifier of the three engines and nothing else. What it leaves out is
|
|
207
|
+
the point: a quote, a parenthesis, a semicolon, a space, a newline and a comment marker are all
|
|
208
|
+
outside it, so an accepted name cannot end a statement or begin another one.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def routine_name(name: str) -> str:
|
|
213
|
+
"""The identifier of a routine, checked and returned as written. The one thing not a parameter.
|
|
214
|
+
|
|
215
|
+
Every value in this ORM travels parametrised; this one cannot, because no engine takes a
|
|
216
|
+
placeholder where an identifier goes. `call` and `execute_procedure` are the only doors where
|
|
217
|
+
something from outside reaches the SQL string, so the SHAPE is checked here — the same move
|
|
218
|
+
`SnakeValue.json_get` makes with its key path.
|
|
219
|
+
|
|
220
|
+
IT IS NOT QUOTED, and that is measured rather than assumed. On PostgreSQL a routine created with
|
|
221
|
+
a bare `CREATE FUNCTION CalculatePayroll` lands in the catalogue folded to `calculatepayroll`,
|
|
222
|
+
so `SELECT * FROM "CalculatePayroll"()` answers `function CalculatePayroll() does not exist`.
|
|
223
|
+
Quoting here would break every mixed-case routine that works today, and break it at the DRIVER.
|
|
224
|
+
The tables and columns the emitter quotes are the opposite case: those names come from the
|
|
225
|
+
metadata graph, and the ORM is what created them.
|
|
226
|
+
|
|
227
|
+
Qualified names are checked PART BY PART (`analytics.monthly_sales`), because the dot is the
|
|
228
|
+
separator and not a character of either name.
|
|
229
|
+
"""
|
|
230
|
+
if not all(_ROUTINE_PART.fullmatch(part) for part in name.split(".")):
|
|
231
|
+
raise SnakeValueError(
|
|
232
|
+
f"'{name}' is not a valid routine name. The arguments travel parametrised, but the name "
|
|
233
|
+
f"cannot — no engine takes a placeholder where an identifier goes — so it reaches the "
|
|
234
|
+
f"SQL as written and every dot-separated part has to be a plain identifier: a letter or "
|
|
235
|
+
f"'_', then letters, digits, '_' or '$'. It is not quoted for you either, on purpose: an "
|
|
236
|
+
f"unquoted CREATE FUNCTION CalculatePayroll lands in PostgreSQL's catalogue as "
|
|
237
|
+
f"'calculatepayroll', so quoting the call would stop finding it. For a name that "
|
|
238
|
+
f"genuinely needs quotes, write the statement yourself with raw(...)."
|
|
239
|
+
)
|
|
240
|
+
return name
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def plan_call(
|
|
244
|
+
name: str, args: Sequence[object], into: type[Any], dialect: SnakeDialect
|
|
245
|
+
) -> tuple[str, tuple[object, ...], Callable[[Rows], list[Any]]]:
|
|
246
|
+
"""SQL of a call to a database function (`SELECT * FROM f(...)`) and how to hydrate its rows.
|
|
247
|
+
|
|
248
|
+
The ARGS travel parametrised (user data); the NAME is an identifier, so it cannot — it is
|
|
249
|
+
checked by `routine_name` and emitted as written. `into` is `type[Any]` for the same reason
|
|
250
|
+
`plan_annotate`'s `result` is: it gets called with keywords, and the `Row: SnakeRow` bound of
|
|
251
|
+
both sessions is what rejects a class that is not a `@snake_row`.
|
|
252
|
+
"""
|
|
253
|
+
checked = routine_name(name)
|
|
254
|
+
placeholders = ", ".join(
|
|
255
|
+
dialect.placeholder(index + 1) for index in range(len(args))
|
|
256
|
+
)
|
|
257
|
+
return (
|
|
258
|
+
f"SELECT * FROM {checked}({placeholders})",
|
|
259
|
+
tuple(args),
|
|
260
|
+
plan_raw(into, source=f"Routine '{checked}'"),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def plan_raw(into: type[Any], source: str = "The query") -> Callable[[Rows], list[Any]]:
|
|
265
|
+
"""How to hydrate raw rows into a `@snake_row`, coercing each column to its declared type.
|
|
266
|
+
|
|
267
|
+
POSITIONAL mapping, with no shape check against the catalogue: you declare, I hydrate.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
info = snake_row_info(into)
|
|
271
|
+
|
|
272
|
+
@timed_mapping
|
|
273
|
+
def hydrate_rows(rows: Rows) -> list[Any]:
|
|
274
|
+
"""Builds one `into` per row, checking the width row by row."""
|
|
275
|
+
output: list[Any] = []
|
|
276
|
+
for row in rows:
|
|
277
|
+
if len(row) != len(info.columns):
|
|
278
|
+
raise SnakeEmitError(
|
|
279
|
+
f"{source} returns {len(row)} column(s) and {into.__name__} declares "
|
|
280
|
+
f"{len(info.columns)}: the mapping is positional, so they have to match."
|
|
281
|
+
)
|
|
282
|
+
output.append(
|
|
283
|
+
into(
|
|
284
|
+
**{
|
|
285
|
+
name: coerce(value, declared)
|
|
286
|
+
for (name, declared), value in zip(
|
|
287
|
+
info.columns, row, strict=True
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
return output
|
|
293
|
+
|
|
294
|
+
return hydrate_rows
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def plan_to_many_level(
|
|
298
|
+
parents: Sequence[object], hop: SnakePrefetchHop, dialect: SnakeDialect
|
|
299
|
+
) -> tuple[str, tuple[object, ...], Callable[[Rows], list[object]]] | None:
|
|
300
|
+
"""Plan of ONE to-many level by select-in, or `None` if there is nothing to query.
|
|
301
|
+
|
|
302
|
+
`None` when no parent can have children; in that case it has ALREADY assigned the empty list to
|
|
303
|
+
each parent, otherwise the anti-N+1 lock would fire on touching the relationship saying it was
|
|
304
|
+
not loaded.
|
|
305
|
+
"""
|
|
306
|
+
from collections import defaultdict
|
|
307
|
+
|
|
308
|
+
relationship = hop.relationship
|
|
309
|
+
pairs = relationship.foreign_key.pairs
|
|
310
|
+
child_columns = tuple(child_column for child_column, _ in pairs)
|
|
311
|
+
parent_pk_attrs = tuple(
|
|
312
|
+
_attr_for_column(hop.parent_table, parent_col) for _, parent_col in pairs
|
|
313
|
+
)
|
|
314
|
+
child_fk_attrs = tuple(
|
|
315
|
+
_attr_for_column(hop.child_table, child_col) for child_col in child_columns
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
parent_keys = [
|
|
319
|
+
key
|
|
320
|
+
for parent in parents
|
|
321
|
+
if None not in (key := tuple(getattr(parent, attr) for attr in parent_pk_attrs))
|
|
322
|
+
]
|
|
323
|
+
if not parents or not parent_keys:
|
|
324
|
+
for parent in parents:
|
|
325
|
+
attach_relationship(parent, relationship.name, [])
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
condition = _with_prefetch_filter(
|
|
329
|
+
_select_in_condition(child_columns, parent_keys), hop
|
|
330
|
+
)
|
|
331
|
+
sql, params = emit_select(hop.child_table, dialect, where=condition)
|
|
332
|
+
|
|
333
|
+
@timed_mapping
|
|
334
|
+
def attach(rows: Rows) -> list[object]:
|
|
335
|
+
"""Groups the children by their FK and assigns the list to each parent (empty if it has none)."""
|
|
336
|
+
children: list[object] = [
|
|
337
|
+
_instantiate(hop.child_model, hop.child_table, row) for row in rows
|
|
338
|
+
]
|
|
339
|
+
groups: dict[tuple[object, ...], list[object]] = defaultdict(list)
|
|
340
|
+
for child in children:
|
|
341
|
+
groups[tuple(getattr(child, attr) for attr in child_fk_attrs)].append(child)
|
|
342
|
+
for parent in parents:
|
|
343
|
+
key = tuple(getattr(parent, attr) for attr in parent_pk_attrs)
|
|
344
|
+
attach_relationship(parent, relationship.name, groups.get(key, []))
|
|
345
|
+
return children
|
|
346
|
+
|
|
347
|
+
return sql, params, attach
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def plan_to_one_level(
|
|
351
|
+
parents: Sequence[object], hop: SnakePrefetchHop, dialect: SnakeDialect
|
|
352
|
+
) -> tuple[str, tuple[object, ...], Callable[[Rows], list[object]]] | None:
|
|
353
|
+
"""Plan of ONE to-one level inside a prefetch, or `None` if there is nothing to query.
|
|
354
|
+
|
|
355
|
+
The pairs of a to-one are (parent_local_column, child_referenced_column), so the FK lives in the
|
|
356
|
+
PARENT and the keys of the `IN` are values of its own.
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
relationship = hop.relationship
|
|
360
|
+
pairs = relationship.foreign_key.pairs
|
|
361
|
+
parent_fk_attrs = tuple(
|
|
362
|
+
_attr_for_column(hop.parent_table, local_col) for local_col, _ in pairs
|
|
363
|
+
)
|
|
364
|
+
child_ref_columns = tuple(remote_col for _, remote_col in pairs)
|
|
365
|
+
child_ref_attrs = tuple(
|
|
366
|
+
_attr_for_column(hop.child_table, remote_col)
|
|
367
|
+
for remote_col in child_ref_columns
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
# ONE key per DISTINCT value, not per parent row: in a to-one the foreign key lives on the
|
|
371
|
+
# parent and it REPEATS. Measured, 50,000 trucks over 50 makers binds 50,000 placeholders and
|
|
372
|
+
# ~150 KB of SQL to ask what 50 placeholders ask; counted over repetitions the batch also
|
|
373
|
+
# crosses SQLite's 32,766-placeholder ceiling, so `parents_per_batch` splits the read into TWO
|
|
374
|
+
# statements where one was enough.
|
|
375
|
+
#
|
|
376
|
+
# `dict.fromkeys` and not a `set`: the order of the parents is the order of the placeholders,
|
|
377
|
+
# and an unordered one would make the emitted SQL differ between runs for no reason.
|
|
378
|
+
#
|
|
379
|
+
# `attach` is unaffected: it indexes the children by key and looks each parent up in that map.
|
|
380
|
+
parent_keys = list(
|
|
381
|
+
dict.fromkeys(
|
|
382
|
+
key
|
|
383
|
+
for parent in parents
|
|
384
|
+
if None
|
|
385
|
+
not in (key := tuple(getattr(parent, attr) for attr in parent_fk_attrs))
|
|
386
|
+
)
|
|
387
|
+
)
|
|
388
|
+
if not parents or not parent_keys:
|
|
389
|
+
for parent in parents:
|
|
390
|
+
attach_relationship(parent, relationship.name, None)
|
|
391
|
+
return None
|
|
392
|
+
|
|
393
|
+
condition = _with_prefetch_filter(
|
|
394
|
+
_select_in_condition(child_ref_columns, parent_keys), hop
|
|
395
|
+
)
|
|
396
|
+
sql, params = emit_select(hop.child_table, dialect, where=condition)
|
|
397
|
+
|
|
398
|
+
@timed_mapping
|
|
399
|
+
def attach(rows: Rows) -> list[object]:
|
|
400
|
+
"""Indexes the children by their referenced key and assigns them to whichever parent is due."""
|
|
401
|
+
children: list[object] = [
|
|
402
|
+
_instantiate(hop.child_model, hop.child_table, row) for row in rows
|
|
403
|
+
]
|
|
404
|
+
by_key: dict[tuple[object, ...], object] = {
|
|
405
|
+
tuple(getattr(child, attr) for attr in child_ref_attrs): child
|
|
406
|
+
for child in children
|
|
407
|
+
}
|
|
408
|
+
for parent in parents:
|
|
409
|
+
key = tuple(getattr(parent, attr) for attr in parent_fk_attrs)
|
|
410
|
+
attach_relationship(parent, relationship.name, by_key.get(key))
|
|
411
|
+
return list(by_key.values())
|
|
412
|
+
|
|
413
|
+
return sql, params, attach
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def plan_through_level(
|
|
417
|
+
parents: Sequence[object], hop: SnakePrefetchHop, dialect: SnakeDialect
|
|
418
|
+
) -> tuple[str, tuple[object, ...], Callable[[Rows], list[object]]] | None:
|
|
419
|
+
"""Plan of a MANY-TO-MANY level: one query with a JOIN against the bridge (one query per level).
|
|
420
|
+
|
|
421
|
+
It emits `SELECT target.*, bridge.<parent_fk> FROM target JOIN bridge ... WHERE
|
|
422
|
+
bridge.<parent_fk> IN (...)`. The bridge's extra column travels at the end so it can GROUP by
|
|
423
|
+
parent without a second query (which would be an N+1). `None` — after leaving the empty list on
|
|
424
|
+
each parent — if there are no keys.
|
|
425
|
+
"""
|
|
426
|
+
from collections import defaultdict
|
|
427
|
+
|
|
428
|
+
relationship = hop.relationship
|
|
429
|
+
bridge = relationship.through
|
|
430
|
+
assert bridge is not None, "plan_through_level only applies to a to_many_through"
|
|
431
|
+
|
|
432
|
+
parent_attrs = tuple(
|
|
433
|
+
_attr_for_column(hop.parent_table, parent_col)
|
|
434
|
+
for _, parent_col in bridge.to_parent
|
|
435
|
+
)
|
|
436
|
+
parent_keys = [
|
|
437
|
+
key
|
|
438
|
+
for parent in parents
|
|
439
|
+
if None not in (key := tuple(getattr(parent, attr) for attr in parent_attrs))
|
|
440
|
+
]
|
|
441
|
+
if not parents or not parent_keys:
|
|
442
|
+
for parent in parents:
|
|
443
|
+
attach_relationship(parent, relationship.name, [])
|
|
444
|
+
return None
|
|
445
|
+
|
|
446
|
+
quote = dialect.quote_ident
|
|
447
|
+
target = qualified(hop.child_table.schema, hop.child_table.name, dialect)
|
|
448
|
+
bridge_schema, _, bridge_name = bridge.table.rpartition(".")
|
|
449
|
+
bridge_table = qualified(bridge_schema, bridge_name, dialect)
|
|
450
|
+
|
|
451
|
+
columns = ", ".join(f"d.{quote(c.name)}" for c in hop.child_table.columns)
|
|
452
|
+
link = " AND ".join(
|
|
453
|
+
f"p.{quote(bridge_col)} = d.{quote(target_col)}"
|
|
454
|
+
for bridge_col, target_col in bridge.to_target
|
|
455
|
+
)
|
|
456
|
+
bridge_keys = [quote(col) for col, _ in bridge.to_parent]
|
|
457
|
+
filter_cols = ", ".join(f"p.{col}" for col in bridge_keys)
|
|
458
|
+
|
|
459
|
+
params: list[object] = []
|
|
460
|
+
sql_groups: list[str] = []
|
|
461
|
+
for key in parent_keys:
|
|
462
|
+
placeholders = ", ".join(
|
|
463
|
+
dialect.placeholder(len(params) + i + 1) for i in range(len(key))
|
|
464
|
+
)
|
|
465
|
+
params.extend(key)
|
|
466
|
+
sql_groups.append(f"({placeholders})" if len(key) > 1 else placeholders)
|
|
467
|
+
items = ", ".join(sql_groups)
|
|
468
|
+
left_side = f"({filter_cols})" if len(bridge_keys) > 1 else filter_cols
|
|
469
|
+
|
|
470
|
+
sql = (
|
|
471
|
+
f"SELECT {columns}, {filter_cols} FROM {target} AS d "
|
|
472
|
+
f"JOIN {bridge_table} AS p ON {link} WHERE {left_side} IN ({items})"
|
|
473
|
+
)
|
|
474
|
+
width = len(hop.child_table.columns)
|
|
475
|
+
|
|
476
|
+
@timed_mapping
|
|
477
|
+
def attach(rows: Rows) -> list[object]:
|
|
478
|
+
"""Groups by the parent key that travels at the end of each row."""
|
|
479
|
+
groups: dict[tuple[object, ...], list[object]] = defaultdict(list)
|
|
480
|
+
children: list[object] = []
|
|
481
|
+
for row in rows:
|
|
482
|
+
child = _instantiate(hop.child_model, hop.child_table, row[:width])
|
|
483
|
+
children.append(child)
|
|
484
|
+
groups[tuple(row[width:])].append(child)
|
|
485
|
+
for parent in parents:
|
|
486
|
+
key = tuple(getattr(parent, attr) for attr in parent_attrs)
|
|
487
|
+
attach_relationship(parent, relationship.name, groups.get(key, []))
|
|
488
|
+
return children
|
|
489
|
+
|
|
490
|
+
return sql, tuple(params), attach
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def parents_per_batch(hop: SnakePrefetchHop, dialect: SnakeDialect) -> int:
|
|
494
|
+
"""How many parents fit in ONE select-in without overshooting the engine's placeholder ceiling.
|
|
495
|
+
|
|
496
|
+
The read side used to bind one placeholder per parent and emit a single statement, while
|
|
497
|
+
`add_all` had been slicing by `max_bind_params` since it existed. Past 65,535 parents on Postgres
|
|
498
|
+
(32,766 on SQLite) the driver simply rejected it. There was no decision behind the asymmetry, so
|
|
499
|
+
the fix is the loop that was already there, not a new one.
|
|
500
|
+
|
|
501
|
+
Measured in PLACEHOLDERS, not in parents: a composite FK costs one per column, so a two-column
|
|
502
|
+
key halves the batch. And the prefetch filter binds its own out of the same budget, which is why
|
|
503
|
+
they are counted rather than guessed at with a fixed margin — a margin an `in_([...5000 ids])`
|
|
504
|
+
inside the filter walks straight through. Counting them costs one emission per LEVEL (pure string
|
|
505
|
+
building, no database), and levels come in twos and threes.
|
|
506
|
+
"""
|
|
507
|
+
# `foreign_key` is a required field of `SnakeRelationshipInfo`, so it is read and not `getattr`'d
|
|
508
|
+
# with a default: the guard that used to be here only looked like caution once the hop was typed.
|
|
509
|
+
per_parent = max(1, len(hop.relationship.foreign_key.pairs))
|
|
510
|
+
spent = 0
|
|
511
|
+
if hop.child_filter is not None:
|
|
512
|
+
_, filter_params = emit_condition(hop.child_filter, dialect)
|
|
513
|
+
spent = len(filter_params)
|
|
514
|
+
return max(1, (dialect.max_bind_params - spent) // per_parent)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def plan_level(
|
|
518
|
+
parents: Sequence[object], hop: SnakePrefetchHop, dialect: SnakeDialect
|
|
519
|
+
) -> tuple[str, tuple[object, ...], Callable[[Rows], list[object]]] | None:
|
|
520
|
+
"""The plan of whichever level is due, according to the kind of relationship. A single place
|
|
521
|
+
decides which of the three it is, so as not to have two copies of the dispatch table (the second
|
|
522
|
+
one ends up forgetting a row)."""
|
|
523
|
+
if hop.kind is SnakeRelationshipKind.TO_MANY_THROUGH:
|
|
524
|
+
return plan_through_level(parents, hop, dialect)
|
|
525
|
+
if hop.kind is SnakeRelationshipKind.TO_MANY:
|
|
526
|
+
return plan_to_many_level(parents, hop, dialect)
|
|
527
|
+
return plan_to_one_level(parents, hop, dialect)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
# -- From row to object: the other half of the plan ---------------------------------------------
|
|
531
|
+
# These functions used to live in `session.py` and were asked back for with lazy imports (the
|
|
532
|
+
# extracted core depended on its consumer). None of them uses the session: they are ROW logic, and
|
|
533
|
+
# this is where they belong.
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _validate_aggregate_names(
|
|
537
|
+
result: type, info: SnakeResultInfo, aggregates: dict[str, SnakeValue[Any]]
|
|
538
|
+
) -> None:
|
|
539
|
+
"""Checks that the aggregates match the `@snake_result`'s scalar fields EXACTLY, before emitting
|
|
540
|
+
any SQL (a `SnakeEmitError` saying which ones are spare/missing)."""
|
|
541
|
+
expected = {name for name, _ in info.scalars}
|
|
542
|
+
provided = set(aggregates)
|
|
543
|
+
missing = expected - provided
|
|
544
|
+
extra = provided - expected
|
|
545
|
+
if not missing and not extra:
|
|
546
|
+
return
|
|
547
|
+
parts: list[str] = []
|
|
548
|
+
if missing:
|
|
549
|
+
parts.append(f"missing {sorted(missing)}")
|
|
550
|
+
if extra:
|
|
551
|
+
parts.append(f"extra {sorted(extra)}")
|
|
552
|
+
raise SnakeEmitError(
|
|
553
|
+
f"The aggregates do not match the scalar fields of {result.__name__} "
|
|
554
|
+
f"({sorted(expected)}): {'; '.join(parts)}."
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _projected_python_type(
|
|
559
|
+
value: SnakeValue[Any], root: SnakeTableInfo | None, reg: SnakeRegistry
|
|
560
|
+
) -> type | None:
|
|
561
|
+
"""python_type of a value projected in `select()`, or None if it cannot be determined.
|
|
562
|
+
|
|
563
|
+
A column -> the type of its path; an aggregate -> its return type. The rest (arithmetic,
|
|
564
|
+
subqueries) has no declared type: None -> it passes through uncoerced (no type gets invented).
|
|
565
|
+
"""
|
|
566
|
+
if root is None:
|
|
567
|
+
return None
|
|
568
|
+
if isinstance(value, SnakeAggregate):
|
|
569
|
+
return _aggregate_python_type(value, root, reg)
|
|
570
|
+
if isinstance(value, SnakeExpr):
|
|
571
|
+
column = _resolve_column(value.path, root, reg)
|
|
572
|
+
return column.python_type if column is not None else None
|
|
573
|
+
return None
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _instantiate(model: type[T], table: SnakeTableInfo, row: Sequence[object]) -> T:
|
|
577
|
+
"""Builds an instance of the model with the row's values, in column order.
|
|
578
|
+
|
|
579
|
+
It delegates to `mapper.hydrate` (a plan compiled ONCE per table). It is also the ONLY point
|
|
580
|
+
where the concrete class of a polymorphic hierarchy gets decided (the row carries its
|
|
581
|
+
discriminator), and that is why `all`/`first`/`get`/`include` inherit it without noticing. The
|
|
582
|
+
dispatch is two lookups (an already-computed position + a `dict.get`): everything else is fixed
|
|
583
|
+
per table and lives in the plan.
|
|
584
|
+
"""
|
|
585
|
+
dispatch = dispatch_for(model, table)
|
|
586
|
+
if dispatch is not None:
|
|
587
|
+
position, by_value = dispatch
|
|
588
|
+
# `isinstance` (not `cast`): the compiler guarantees that the COLUMN is `str`, not that the
|
|
589
|
+
# driver returns one. If it is not, it hydrates as the base, just like an unknown discriminator.
|
|
590
|
+
value = row[position]
|
|
591
|
+
concrete_model = by_value.get(value) if isinstance(value, str) else None
|
|
592
|
+
if concrete_model is not None and concrete_model is not model:
|
|
593
|
+
return cast("T", hydrate(concrete_model, table, row, owner=model))
|
|
594
|
+
return hydrate(model, table, row)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _instantiate_all(
|
|
598
|
+
model: type[T], table: SnakeTableInfo, rows: Sequence[Sequence[object]]
|
|
599
|
+
) -> list[T]:
|
|
600
|
+
"""Instantiates ALL the rows resolving dispatch and plan ONCE, not per row.
|
|
601
|
+
|
|
602
|
+
For the common case (non-polymorphic) the plan is resolved once and the loop only writes
|
|
603
|
+
attributes. Polymorphism needs to decide the subclass per row, so it falls back to
|
|
604
|
+
`_instantiate`'s path.
|
|
605
|
+
"""
|
|
606
|
+
if dispatch_for(model, table) is not None:
|
|
607
|
+
return [_instantiate(model, table, row) for row in rows]
|
|
608
|
+
plan = plan_for(model, table, model)
|
|
609
|
+
return [_hydrate_with_plan(model, plan, row) for row in rows]
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
@timed_mapping
|
|
613
|
+
def instantiate_rows_with_includes(
|
|
614
|
+
segments: Sequence[tuple[tuple[str, ...], type, SnakeTableInfo]],
|
|
615
|
+
rows: Sequence[Sequence[object]],
|
|
616
|
+
) -> list[object]:
|
|
617
|
+
"""Rebuilds EVERY root of a wide result set (a SELECT with includes), rows already in hand.
|
|
618
|
+
|
|
619
|
+
Written once here rather than as the same comprehension in each session: the two sessions are
|
|
620
|
+
twins, and a decision written on one of two twins is a promise that the other will forget it.
|
|
621
|
+
"""
|
|
622
|
+
# Every plan is resolved ONCE per segment, before the row loop starts. `_instantiate` and
|
|
623
|
+
# `_pk_is_null` each go through `mapper._entry`, so calling them inside the loop cost three
|
|
624
|
+
# lookups per segment per ROW — 250 of them for 50 rows with one include. The flat path had
|
|
625
|
+
# already solved this (`_instantiate_all` hoists) and `mapper` promises it in writing: "Whoever
|
|
626
|
+
# maps N rows resolves the plan once". The include was the caller that did not keep it.
|
|
627
|
+
#
|
|
628
|
+
# Hoisting stops HERE, at the call. `mapper` invalidates by the table's REFERENCE, so caching
|
|
629
|
+
# these above the call would be a second cache with its own rule — which `_entry`'s docstring
|
|
630
|
+
# forbids, and which would be a worse bug than the one being fixed.
|
|
631
|
+
return [
|
|
632
|
+
_instantiate_with_compiled(compiled, row)
|
|
633
|
+
for compiled in (compile_segments(segments),)
|
|
634
|
+
for row in rows
|
|
635
|
+
]
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def compile_segments(
|
|
639
|
+
segments: Sequence[tuple[tuple[str, ...], type, SnakeTableInfo]],
|
|
640
|
+
) -> tuple[_CompiledSegment, ...]:
|
|
641
|
+
"""Resolves every segment's plan ONCE. Call it OUTSIDE the row loop; that is the whole point.
|
|
642
|
+
|
|
643
|
+
Public within the package because the STREAMING paths need it too: they hydrate row by row from
|
|
644
|
+
a generator, so a helper that compiles internally would resolve per row all over again — which
|
|
645
|
+
is the failure this exists to remove, hidden one layer deeper where a `.all()` test cannot see it.
|
|
646
|
+
"""
|
|
647
|
+
return tuple(
|
|
648
|
+
_CompiledSegment(
|
|
649
|
+
prefix=prefix,
|
|
650
|
+
model=model,
|
|
651
|
+
table=table,
|
|
652
|
+
width=len(table.columns),
|
|
653
|
+
dispatch=dispatch_for(model, table),
|
|
654
|
+
plan=plan_for(model, table, model),
|
|
655
|
+
pks=pk_positions(model, table),
|
|
656
|
+
)
|
|
657
|
+
for prefix, model, table in segments
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
@dataclass(frozen=True, slots=True)
|
|
662
|
+
class _CompiledSegment:
|
|
663
|
+
"""One segment of a wide row with everything about it already resolved.
|
|
664
|
+
|
|
665
|
+
Named `_CompiledSegment` and not `_IncludeSegment`: `query.py` already exports an
|
|
666
|
+
`IncludeSegment`, which is this one's INPUT, and two names one underscore apart for a thing and
|
|
667
|
+
its precompiled form is how a reader ends up debugging the wrong one.
|
|
668
|
+
"""
|
|
669
|
+
|
|
670
|
+
prefix: tuple[str, ...]
|
|
671
|
+
model: type
|
|
672
|
+
table: SnakeTableInfo
|
|
673
|
+
width: int
|
|
674
|
+
dispatch: _Dispatch
|
|
675
|
+
plan: tuple[_Instruction, ...]
|
|
676
|
+
pks: tuple[int, ...]
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _instantiate_with_compiled(
|
|
680
|
+
segments: Sequence[_CompiledSegment], row: Sequence[object]
|
|
681
|
+
) -> object:
|
|
682
|
+
"""Rebuilds the root object with its relatives, with every plan already in hand.
|
|
683
|
+
|
|
684
|
+
The loop only slices the row and writes: no lookups, no resolution. A relationship with no match
|
|
685
|
+
(a LEFT JOIN with a null PK) is left as None, not as a phantom object.
|
|
686
|
+
"""
|
|
687
|
+
objects: dict[tuple[str, ...], object | None] = {}
|
|
688
|
+
offset = 0
|
|
689
|
+
for segment in segments:
|
|
690
|
+
chunk = row[offset : offset + segment.width]
|
|
691
|
+
offset += segment.width
|
|
692
|
+
if segment.prefix and all(chunk[pos] is None for pos in segment.pks):
|
|
693
|
+
objects[segment.prefix] = None
|
|
694
|
+
elif segment.dispatch is not None:
|
|
695
|
+
# A polymorphic segment decides its class per ROW, so it goes the long way round —
|
|
696
|
+
# which is the same exception `_instantiate_all` makes on the flat path.
|
|
697
|
+
objects[segment.prefix] = _instantiate(segment.model, segment.table, chunk)
|
|
698
|
+
else:
|
|
699
|
+
objects[segment.prefix] = _hydrate_with_plan(
|
|
700
|
+
segment.model, segment.plan, chunk
|
|
701
|
+
)
|
|
702
|
+
for segment in segments:
|
|
703
|
+
parent = objects[segment.prefix[:-1]] if segment.prefix else None
|
|
704
|
+
if segment.prefix and parent is not None:
|
|
705
|
+
attach_relationship(parent, segment.prefix[-1], objects[segment.prefix])
|
|
706
|
+
return objects[()]
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _instantiate_with_includes(
|
|
710
|
+
segments: Sequence[tuple[tuple[str, ...], type, SnakeTableInfo]],
|
|
711
|
+
row: Sequence[object],
|
|
712
|
+
) -> object:
|
|
713
|
+
"""Rebuilds the root object with its relatives from a wide row (a SELECT with includes).
|
|
714
|
+
|
|
715
|
+
It slices the row up by segment, instantiates each one and hooks it onto its parent. A
|
|
716
|
+
relationship with no match (a LEFT JOIN with a null PK) is left as None, not as a phantom object.
|
|
717
|
+
"""
|
|
718
|
+
objects: dict[tuple[str, ...], object | None] = {}
|
|
719
|
+
offset = 0
|
|
720
|
+
for prefix, model, table in segments:
|
|
721
|
+
width = len(table.columns)
|
|
722
|
+
chunk = row[offset : offset + width]
|
|
723
|
+
offset += width
|
|
724
|
+
objects[prefix] = (
|
|
725
|
+
None
|
|
726
|
+
if prefix and _pk_is_null(model, table, chunk)
|
|
727
|
+
else _instantiate(model, table, chunk)
|
|
728
|
+
)
|
|
729
|
+
for prefix, _model, _table in segments:
|
|
730
|
+
parent = objects[prefix[:-1]] if prefix else None
|
|
731
|
+
if prefix and parent is not None:
|
|
732
|
+
attach_relationship(parent, prefix[-1], objects[prefix])
|
|
733
|
+
return objects[()]
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _select_in_condition(
|
|
737
|
+
child_columns: tuple[str, ...], parent_keys: list[tuple[object, ...]]
|
|
738
|
+
) -> SnakeCondition:
|
|
739
|
+
"""Filter of the select-in: a simple FK -> `col IN (...)`; a composite FK -> `(a, b) IN ((...),
|
|
740
|
+
...)` (`SnakeTupleIn`, one row per parent with the tuple of its PK values)."""
|
|
741
|
+
if len(child_columns) == 1:
|
|
742
|
+
column: SnakeExpr[object] = SnakeExpr(path=(child_columns[0],))
|
|
743
|
+
return column.in_([key[0] for key in parent_keys])
|
|
744
|
+
columns: tuple[SnakeValue[object], ...] = tuple(
|
|
745
|
+
SnakeExpr(path=(name,)) for name in child_columns
|
|
746
|
+
)
|
|
747
|
+
return SnakeTupleIn(columns=columns, rows=tuple(parent_keys))
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _attr_for_column(table: SnakeTableInfo, name: str) -> str:
|
|
751
|
+
"""Returns a column's Python attribute by its SQL name (to read/group by FK/PK)."""
|
|
752
|
+
for column in table.columns:
|
|
753
|
+
if column.name == name:
|
|
754
|
+
return column.attr_name or column.name
|
|
755
|
+
raise SnakeUnknownColumn(f"Column '{name}' does not exist in '{table.name}'.")
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _aggregate_python_type(
|
|
759
|
+
aggregate: SnakeAggregate[Any], root: SnakeTableInfo, reg: SnakeRegistry
|
|
760
|
+
) -> type | None:
|
|
761
|
+
"""Return type of an aggregate: COUNT -> int, AVG -> float; SUM/MIN/MAX preserve their column's."""
|
|
762
|
+
if aggregate.func is SnakeAggFunc.COUNT:
|
|
763
|
+
return int
|
|
764
|
+
if aggregate.func is SnakeAggFunc.AVG:
|
|
765
|
+
return float
|
|
766
|
+
if isinstance(aggregate.arg, SnakeExpr):
|
|
767
|
+
column = _resolve_column(aggregate.arg.path, root, reg)
|
|
768
|
+
return column.python_type if column is not None else None
|
|
769
|
+
return None
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _resolve_column(
|
|
773
|
+
path: tuple[str, ...], root: SnakeTableInfo, reg: SnakeRegistry
|
|
774
|
+
) -> SnakeColumnInfo | None:
|
|
775
|
+
"""Resolves a path (relationships... + column) to its SnakeColumnInfo, or None if unreachable.
|
|
776
|
+
|
|
777
|
+
It walks the graph along the path's hops; None on any link that does not match (the caller does
|
|
778
|
+
not coerce).
|
|
779
|
+
"""
|
|
780
|
+
table: SnakeTableInfo | None = root
|
|
781
|
+
for step in path[:-1]:
|
|
782
|
+
if table is None:
|
|
783
|
+
return None
|
|
784
|
+
relationship = next(
|
|
785
|
+
(rel for rel in table.relationships if rel.name == step), None
|
|
786
|
+
)
|
|
787
|
+
if relationship is None:
|
|
788
|
+
return None
|
|
789
|
+
# The caller's registry, threaded rather than defaulted. A default of the global one is
|
|
790
|
+
# exactly the shape of the bug: it works until two models share a class name, and then
|
|
791
|
+
# this walk reads a stranger's table and coerces the projection to the wrong type.
|
|
792
|
+
table = reg.resolve_relationship(relationship)[0]
|
|
793
|
+
return table.get_column(path[-1]) if table is not None else None
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _pk_is_null(model: type, table: SnakeTableInfo, chunk: Sequence[object]) -> bool:
|
|
797
|
+
"""Tells whether the chunk's PK is all NULL (a LEFT JOIN with no match -> no such relationship).
|
|
798
|
+
|
|
799
|
+
The positions come precomputed: resolving them per row was O(columns²) on the `include` path.
|
|
800
|
+
"""
|
|
801
|
+
return all(chunk[position] is None for position in pk_positions(model, table))
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _with_prefetch_filter(
|
|
805
|
+
condition: SnakeCondition, hop: SnakePrefetchHop
|
|
806
|
+
) -> SnakeCondition:
|
|
807
|
+
"""ANDs the hop's filter (if there is one) onto the `WHERE fk IN (...)` of that level's select-in.
|
|
808
|
+
|
|
809
|
+
The filter's paths are DIRECT columns of the child (the subquery is over a single table), so they
|
|
810
|
+
are emitted unqualified. It narrows WHICH CHILDREN come back; the grouping afterwards still
|
|
811
|
+
assigns [] to the parents with no matching children.
|
|
812
|
+
"""
|
|
813
|
+
if hop.child_filter is None:
|
|
814
|
+
return condition
|
|
815
|
+
return SnakeAnd(parts=(condition, hop.child_filter))
|