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
snakeorm/query/query.py
ADDED
|
@@ -0,0 +1,1001 @@
|
|
|
1
|
+
"""SnakeQuery: an IMMUTABLE builder of queries over a compiled model.
|
|
2
|
+
|
|
3
|
+
`.filter()`/`.order_by()`/... accumulate state by returning a NEW query; `.to_sql(dialect)` compiles
|
|
4
|
+
to `(sql, params)`. EXECUTION does not live here (that is `session/`).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping, Sequence
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
11
|
+
|
|
12
|
+
from snakeorm.dialects import SnakeDialect
|
|
13
|
+
from snakeorm.core.exceptions import (
|
|
14
|
+
SnakeEmitError,
|
|
15
|
+
SnakeRegistryError,
|
|
16
|
+
SnakeUnknownColumn,
|
|
17
|
+
SnakeUnknownRelationship,
|
|
18
|
+
SnakeUnsupportedFeature,
|
|
19
|
+
)
|
|
20
|
+
from snakeorm.expressions import (
|
|
21
|
+
SnakeAnd,
|
|
22
|
+
SnakeCondition,
|
|
23
|
+
SnakeExpr,
|
|
24
|
+
SnakeLock,
|
|
25
|
+
SnakeOrder,
|
|
26
|
+
SnakeSubquery,
|
|
27
|
+
SnakeValue,
|
|
28
|
+
condition_paths,
|
|
29
|
+
)
|
|
30
|
+
from snakeorm.fields.relationship import SnakeCollection, SnakePathProxy, SnakePrefetch
|
|
31
|
+
from snakeorm.helpers.inheritance import is_abstract
|
|
32
|
+
from snakeorm.metadata import (
|
|
33
|
+
SnakeRelationshipKind,
|
|
34
|
+
SnakeRelationshipInfo,
|
|
35
|
+
SnakeTableInfo,
|
|
36
|
+
)
|
|
37
|
+
from snakeorm.query.compound import (
|
|
38
|
+
SnakeCompoundBranch,
|
|
39
|
+
SnakeCompound,
|
|
40
|
+
SnakeSetOp,
|
|
41
|
+
_compose,
|
|
42
|
+
)
|
|
43
|
+
from snakeorm.query.join_kind import SnakeJoin
|
|
44
|
+
from snakeorm.query.recursive import SnakeRecursive, build_recursive
|
|
45
|
+
from snakeorm.registry import SnakeRegistry, registry, registry_of
|
|
46
|
+
from snakeorm.sql import (
|
|
47
|
+
JoinPlan,
|
|
48
|
+
emit_count,
|
|
49
|
+
emit_delete,
|
|
50
|
+
emit_delete_pk_in_subquery,
|
|
51
|
+
emit_exists,
|
|
52
|
+
emit_project,
|
|
53
|
+
emit_select,
|
|
54
|
+
emit_select_with_includes,
|
|
55
|
+
emit_update,
|
|
56
|
+
emit_update_pk_in_subquery,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if TYPE_CHECKING:
|
|
60
|
+
# Import for the checker only: `compound` imports this module (it needs the type of the
|
|
61
|
+
# branches), so at module level it would be a cycle. The composition methods import it inside.
|
|
62
|
+
from snakeorm.query.joined import SnakeJoinedQuery
|
|
63
|
+
|
|
64
|
+
T = TypeVar("T")
|
|
65
|
+
V = TypeVar("V")
|
|
66
|
+
M = TypeVar("M")
|
|
67
|
+
|
|
68
|
+
IncludeSegment = tuple[tuple[str, ...], type, SnakeTableInfo]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _include_path(relation: type[Any] | SnakeCollection[Any]) -> tuple[str, ...]:
|
|
72
|
+
"""Extracts the path of a class navigation for `.include()`.
|
|
73
|
+
|
|
74
|
+
A to-many arrives as a `SnakeCollection` (`.path`); a to-one as a `SnakePathProxy` (even though
|
|
75
|
+
the checker sees it as `type[M]`), with the path in `._path`. A column is not navigable: it gets
|
|
76
|
+
rejected in plain words.
|
|
77
|
+
"""
|
|
78
|
+
if isinstance(relation, SnakeCollection):
|
|
79
|
+
return relation.path
|
|
80
|
+
if isinstance(relation, SnakePathProxy):
|
|
81
|
+
return relation._path
|
|
82
|
+
# A column (`SnakeExpr`) is not navigable: `.include(Model.column)` makes no sense. The checker
|
|
83
|
+
# already rejects it (`[arg-type]`), but whoever skips the types deserves a clear message and not
|
|
84
|
+
# an `AttributeError: 'SnakeExpr' has no '_path'` from the builder's guts.
|
|
85
|
+
detail = (
|
|
86
|
+
f" (got {'.'.join(relation.path)})" if isinstance(relation, SnakeExpr) else ""
|
|
87
|
+
)
|
|
88
|
+
raise SnakeUnknownRelationship(
|
|
89
|
+
f"`.include()` expects a RELATIONSHIP (a `SnakeToOne`/`SnakeToMany`), not a column and "
|
|
90
|
+
f"not an expression{detail}. To bring related data in, navigate to the relationship, "
|
|
91
|
+
f"e.g. `.include(Car.brand)`."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _relationship(table: SnakeTableInfo, name: str) -> SnakeRelationshipInfo:
|
|
96
|
+
"""Looks a relationship up by name in the table; fails in plain words if it does not exist."""
|
|
97
|
+
for relationship in table.relationships:
|
|
98
|
+
if relationship.name == name:
|
|
99
|
+
return relationship
|
|
100
|
+
raise SnakeUnknownRelationship(
|
|
101
|
+
f"'{table.name}' does not have a relationship named '{name}'."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _discriminator_filter(table: SnakeTableInfo) -> tuple[SnakeCondition, ...]:
|
|
106
|
+
"""The `WHERE kind = 'dog'` of a polymorphic child; empty for any other table.
|
|
107
|
+
|
|
108
|
+
The BASE carries no filter: querying `Animal` must see the whole hierarchy.
|
|
109
|
+
"""
|
|
110
|
+
if not table.is_polymorphic_child:
|
|
111
|
+
return ()
|
|
112
|
+
assert table.polymorphic is not None # is_polymorphic_child guarantees it
|
|
113
|
+
column: SnakeExpr[str] = SnakeExpr(path=(table.polymorphic.column,))
|
|
114
|
+
return (column == table.polymorphic.value,)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# Slots that are NOT a knob: the query's identity and its WHERE, which every emitter honours.
|
|
118
|
+
# Everything else in `__slots__` is something the caller ASKED for, and `_guard_unsupported` derives
|
|
119
|
+
# the list from there so a new knob cannot be left out of the guard.
|
|
120
|
+
_NOT_A_KNOB = frozenset({"_model", "_table", "_filters", "_registry"})
|
|
121
|
+
"""Slots that are NOT something the caller asked the emitter to write.
|
|
122
|
+
|
|
123
|
+
`_registry` belongs here and forgetting it breaks EVERY select: the guards derive their list
|
|
124
|
+
from `__slots__` by comparing against a pristine query, and a query built on a custom registry
|
|
125
|
+
differs from the default one in exactly that slot — so every plain SELECT would refuse itself
|
|
126
|
+
with "does not emit registry()"."""
|
|
127
|
+
|
|
128
|
+
# `_prefetches` and `_includes` are honoured by the plain SELECT even though it writes NEITHER: the
|
|
129
|
+
# SESSION resolves them afterwards, one query per level, and `session.py:274` takes exactly this
|
|
130
|
+
# route when every include is a to-many. They are DEFERRED, not dropped, and the difference is the
|
|
131
|
+
# whole point of this guard — refusing them here turned eighteen green tests red, which is how the
|
|
132
|
+
# distinction got measured rather than assumed.
|
|
133
|
+
_HONOURED_BY_SELECT = frozenset(
|
|
134
|
+
{
|
|
135
|
+
"_order",
|
|
136
|
+
"_limit",
|
|
137
|
+
"_offset",
|
|
138
|
+
"_distinct",
|
|
139
|
+
"_lock",
|
|
140
|
+
"_includes",
|
|
141
|
+
"_prefetches",
|
|
142
|
+
"_columns",
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# A COUNT ignores order/limit/offset by design (they do not change how many rows match) and the
|
|
147
|
+
# includes do not change the number of ROOT rows either. What it cannot ignore is `group_by`,
|
|
148
|
+
# `having` and `distinct`: each of those makes the answer a different number.
|
|
149
|
+
_HONOURED_BY_COUNT = frozenset(
|
|
150
|
+
{"_order", "_limit", "_offset", "_includes", "_prefetches"}
|
|
151
|
+
)
|
|
152
|
+
_HONOURED_BY_INCLUDES = frozenset(
|
|
153
|
+
{"_order", "_limit", "_offset", "_includes", "_prefetches"}
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# How a knob is SPELT by the person who set it. A message that says `lock` sends the reader looking
|
|
157
|
+
# for something they never typed; they typed `for_update()`. Only the knobs whose slot name differs
|
|
158
|
+
# from their method need an entry — the rest fall back to the slot, so this cannot go stale into a
|
|
159
|
+
# wrong answer, only into a duller one.
|
|
160
|
+
_KNOB_SPELLING = {
|
|
161
|
+
"_lock": "for_update()",
|
|
162
|
+
"_includes": "include()",
|
|
163
|
+
"_prefetches": "include()",
|
|
164
|
+
"_order": "order_by()",
|
|
165
|
+
"_columns": "only()/defer()",
|
|
166
|
+
}
|
|
167
|
+
"""How each knob is SPELLED to the user. The fallback —the slot without its underscore— only works
|
|
168
|
+
where the two happen to match, and where they do not the message names a method that does not
|
|
169
|
+
exist: `_order` came out as `order()`, `_prefetches` as `prefetches()`. Somebody reading that goes
|
|
170
|
+
looking for a call they never made."""
|
|
171
|
+
|
|
172
|
+
# What to do INSTEAD, per knob rather than per path. The remedy each caller passes describes its own
|
|
173
|
+
# emitter —"count the projection", "lock in a separate query"— and that is right until two different
|
|
174
|
+
# knobs are dropped at once: the message then names `columns()` and advises about locking, which
|
|
175
|
+
# reads like the ORM answering a question nobody asked. Measured on the first `only().include()`.
|
|
176
|
+
#
|
|
177
|
+
# Only the knobs whose remedy does not depend on the path get an entry, which is why this is small.
|
|
178
|
+
_KNOB_REMEDY = {
|
|
179
|
+
"_columns": (
|
|
180
|
+
"Ask for the columns with select(...) if what you want is the values, or drop the "
|
|
181
|
+
"only()/defer()."
|
|
182
|
+
),
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class SnakeQuery(Generic[T]):
|
|
187
|
+
"""Typed query over a model. Immutable: every method returns a new query."""
|
|
188
|
+
|
|
189
|
+
__slots__ = (
|
|
190
|
+
"_model",
|
|
191
|
+
"_table",
|
|
192
|
+
"_filters",
|
|
193
|
+
"_order",
|
|
194
|
+
"_limit",
|
|
195
|
+
"_offset",
|
|
196
|
+
"_includes",
|
|
197
|
+
"_prefetches",
|
|
198
|
+
"_group_by",
|
|
199
|
+
"_having",
|
|
200
|
+
"_distinct",
|
|
201
|
+
"_lock",
|
|
202
|
+
"_columns",
|
|
203
|
+
"_registry",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def __init__(
|
|
207
|
+
self, model: type[T], *, registry: SnakeRegistry | None = None
|
|
208
|
+
) -> None:
|
|
209
|
+
"""The query and the REGISTRY it resolves against, which is the model's unless told otherwise.
|
|
210
|
+
|
|
211
|
+
It used to ask the global registry unconditionally, so `@snake_model(registry=reg)`
|
|
212
|
+
produced a model that could not be queried — and said so with a message that MISDIRECTED:
|
|
213
|
+
"is it missing @snake_model?", with the decorator right there. The answer was one `getattr`
|
|
214
|
+
away the whole time.
|
|
215
|
+
|
|
216
|
+
`registry=` is for the case where the model itself does not say: a `SnakeRow`, or a query
|
|
217
|
+
built before the decorator ran. Otherwise the model's own answer wins, which is what makes
|
|
218
|
+
navigation, the session and the emitter agree without anybody threading it through.
|
|
219
|
+
"""
|
|
220
|
+
registry = registry_of(model) if registry is None else registry
|
|
221
|
+
table = registry.table_of(model)
|
|
222
|
+
if table is None:
|
|
223
|
+
# An abstract base is NOT an oversight, and suggesting @snake_model would be sending
|
|
224
|
+
# them off to turn into a table something that exists precisely so as not to be one.
|
|
225
|
+
if is_abstract(model):
|
|
226
|
+
raise SnakeRegistryError(
|
|
227
|
+
f"{model.__name__} is an abstract base (@snake_abstract): it contributes its "
|
|
228
|
+
f"columns to the tables that inherit from it, but it is not a table and "
|
|
229
|
+
f"cannot be queried. Query the concrete model that inherits it."
|
|
230
|
+
)
|
|
231
|
+
raise SnakeRegistryError(
|
|
232
|
+
f"{model.__name__} is not registered: is it missing @snake_model?"
|
|
233
|
+
)
|
|
234
|
+
self._model = model
|
|
235
|
+
self._table = table
|
|
236
|
+
self._registry = registry
|
|
237
|
+
# A polymorphic CHILD starts out with its discriminator filtered: it shares a table with
|
|
238
|
+
# its siblings, so `session.all(Dog)` would otherwise also return cats hydrated as dogs.
|
|
239
|
+
# It goes in the constructor (not in the emitter) so every path inherits it for free (all,
|
|
240
|
+
# first, count, exists, delete_where...).
|
|
241
|
+
self._filters: tuple[SnakeCondition, ...] = _discriminator_filter(table)
|
|
242
|
+
self._order: tuple[SnakeOrder, ...] = ()
|
|
243
|
+
self._limit: int | None = None
|
|
244
|
+
self._offset: int | None = None
|
|
245
|
+
self._includes: tuple[tuple[str, ...], ...] = ()
|
|
246
|
+
self._prefetches: tuple[SnakePrefetch[Any], ...] = ()
|
|
247
|
+
self._group_by: tuple[SnakeValue[Any], ...] = ()
|
|
248
|
+
self._having: tuple[SnakeCondition, ...] = ()
|
|
249
|
+
self._distinct: bool = False
|
|
250
|
+
self._lock: SnakeLock | None = None
|
|
251
|
+
# The columns to bring, by NAME, or `None` for "all of them". It is the resolved set and not
|
|
252
|
+
# the user's two verbs, because `only` and `defer` are one question asked from two sides and
|
|
253
|
+
# keeping both would mean every reader downstream doing the subtraction again.
|
|
254
|
+
self._columns: frozenset[str] | None = None
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def projected_columns(self) -> frozenset[str] | None:
|
|
258
|
+
"""The columns `only()`/`defer()` asked for, or `None` for every one of them.
|
|
259
|
+
|
|
260
|
+
The session asks for this rather than counting the row's width: two columns of one table can
|
|
261
|
+
be projected two ways, and lining a plan up by counting is how a value lands on the wrong
|
|
262
|
+
attribute without anybody noticing.
|
|
263
|
+
"""
|
|
264
|
+
return self._columns
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def model(self) -> type[T]:
|
|
268
|
+
"""The model being queried. The session uses it to map the rows back to T."""
|
|
269
|
+
return self._model
|
|
270
|
+
|
|
271
|
+
def filter(self, *conditions: SnakeCondition) -> SnakeQuery[T]:
|
|
272
|
+
"""Adds conditions (AND). Returns a NEW query; the current one is untouched."""
|
|
273
|
+
clone = self._clone()
|
|
274
|
+
clone._filters = (*self._filters, *conditions)
|
|
275
|
+
return clone
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def registry(self) -> SnakeRegistry:
|
|
279
|
+
"""The registry this query resolves against: the model's own unless one was passed.
|
|
280
|
+
|
|
281
|
+
Public because the SESSION has to ask it. Reaching for the global registry there is the same
|
|
282
|
+
defect one layer up: it works until two models share a class name, or until somebody uses
|
|
283
|
+
`@snake_model(registry=...)`, and then it is wrong in silence.
|
|
284
|
+
"""
|
|
285
|
+
return self._registry
|
|
286
|
+
|
|
287
|
+
def order_by(self, *keys: SnakeExpr[Any] | SnakeOrder) -> SnakeQuery[T]:
|
|
288
|
+
"""Adds ordering keys. A bare column orders ascending."""
|
|
289
|
+
normalized = tuple(
|
|
290
|
+
key if isinstance(key, SnakeOrder) else key.asc() for key in keys
|
|
291
|
+
)
|
|
292
|
+
clone = self._clone()
|
|
293
|
+
clone._order = (*self._order, *normalized)
|
|
294
|
+
return clone
|
|
295
|
+
|
|
296
|
+
def limit(self, limit: int) -> SnakeQuery[T]:
|
|
297
|
+
"""Sets the LIMIT (it replaces the previous one). Returns a new query."""
|
|
298
|
+
clone = self._clone()
|
|
299
|
+
clone._limit = limit
|
|
300
|
+
return clone
|
|
301
|
+
|
|
302
|
+
def offset(self, offset: int) -> SnakeQuery[T]:
|
|
303
|
+
"""Sets the OFFSET (it replaces the previous one). Returns a new query."""
|
|
304
|
+
clone = self._clone()
|
|
305
|
+
clone._offset = offset
|
|
306
|
+
return clone
|
|
307
|
+
|
|
308
|
+
def group_by(self, *columns: SnakeValue[Any]) -> SnakeQuery[T]:
|
|
309
|
+
"""Groups by the given columns. Returns a NEW query; the current one is untouched."""
|
|
310
|
+
clone = self._clone()
|
|
311
|
+
clone._group_by = (*self._group_by, *columns)
|
|
312
|
+
return clone
|
|
313
|
+
|
|
314
|
+
def having(self, condition: SnakeCondition) -> SnakeQuery[T]:
|
|
315
|
+
"""Filters over the groups (cumulative with AND, just like `.filter()`)."""
|
|
316
|
+
clone = self._clone()
|
|
317
|
+
clone._having = (*self._having, condition)
|
|
318
|
+
return clone
|
|
319
|
+
|
|
320
|
+
def distinct(self) -> SnakeQuery[T]:
|
|
321
|
+
"""Marks the query as `SELECT DISTINCT`. Returns a new query.
|
|
322
|
+
|
|
323
|
+
It applies to the full SELECT and to the projection. Standard `DISTINCT` only (no `DISTINCT ON`).
|
|
324
|
+
"""
|
|
325
|
+
clone = self._clone()
|
|
326
|
+
clone._distinct = True
|
|
327
|
+
return clone
|
|
328
|
+
|
|
329
|
+
def only(self, *columns: SnakeValue[Any]) -> SnakeQuery[T]:
|
|
330
|
+
"""Brings ONLY these columns (plus the primary key). Returns a new query.
|
|
331
|
+
|
|
332
|
+
What it buys is bytes on a wide table; what it costs is an instance that is not whole, and
|
|
333
|
+
reading a column left out RAISES rather than handing back the column's default. That refusal
|
|
334
|
+
is the feature: without it a deferred name reads as `None` and the caller never learns.
|
|
335
|
+
|
|
336
|
+
The PRIMARY KEY comes back whether it was named or not, and that is not a convenience. An
|
|
337
|
+
instance with no identity cannot be updated, deleted, or matched to the children of a
|
|
338
|
+
prefetch — it would be a tuple with methods.
|
|
339
|
+
|
|
340
|
+
When what you want is the VALUES rather than the model, `session.select(query, a, b)` is the
|
|
341
|
+
better tool: typed tuples, no half-built instance, and nothing that can raise later.
|
|
342
|
+
"""
|
|
343
|
+
return self._with_columns(self._named(columns), verb="only")
|
|
344
|
+
|
|
345
|
+
def defer(self, *columns: SnakeValue[Any]) -> SnakeQuery[T]:
|
|
346
|
+
"""Brings everything EXCEPT these columns. Returns a new query.
|
|
347
|
+
|
|
348
|
+
The other side of `only()`, and the same warning applies to what comes back. Deferring the
|
|
349
|
+
primary key is refused: see `only()`.
|
|
350
|
+
"""
|
|
351
|
+
deferred = self._named(columns)
|
|
352
|
+
keys = {column.name for column in self._table.primary_key.columns}
|
|
353
|
+
if deferred & keys:
|
|
354
|
+
raise SnakeUnsupportedFeature(
|
|
355
|
+
f"the primary key cannot be deferred ({', '.join(sorted(deferred & keys))}): a row "
|
|
356
|
+
f"with no identity cannot be written back nor matched to its relations. Deferring "
|
|
357
|
+
f"is for the wide columns a page does not print."
|
|
358
|
+
)
|
|
359
|
+
every = {column.name for column in self._table.columns}
|
|
360
|
+
return self._with_columns(every - deferred, verb="defer")
|
|
361
|
+
|
|
362
|
+
def _named(self, columns: tuple[SnakeValue[Any], ...]) -> set[str]:
|
|
363
|
+
"""The column NAMES behind the given expressions, refusing anything this table lacks."""
|
|
364
|
+
known = {column.name for column in self._table.columns}
|
|
365
|
+
names: set[str] = set()
|
|
366
|
+
for column in columns:
|
|
367
|
+
path = getattr(column, "path", None)
|
|
368
|
+
if path is None or len(path) != 1 or path[0] not in known:
|
|
369
|
+
raise SnakeUnknownColumn(
|
|
370
|
+
f"only()/defer() take columns of {self._model.__name__} itself; got "
|
|
371
|
+
f"{column!r}. A column of a related model cannot be deferred: it arrives "
|
|
372
|
+
f"through its own include()."
|
|
373
|
+
)
|
|
374
|
+
names.add(path[0])
|
|
375
|
+
return names
|
|
376
|
+
|
|
377
|
+
def _with_columns(self, columns: set[str], *, verb: str) -> SnakeQuery[T]:
|
|
378
|
+
"""Records the resolved column set, refusing a second, contradictory answer."""
|
|
379
|
+
if self._columns is not None:
|
|
380
|
+
raise SnakeUnsupportedFeature(
|
|
381
|
+
f"this query already names the columns to bring; a second {verb}() would be a "
|
|
382
|
+
f"second answer to one question. Say it once, with every column it needs."
|
|
383
|
+
)
|
|
384
|
+
if not columns:
|
|
385
|
+
raise SnakeUnsupportedFeature(
|
|
386
|
+
f"{verb}() would leave the row with no columns at all"
|
|
387
|
+
)
|
|
388
|
+
clone = self._clone()
|
|
389
|
+
# The PK always travels, whether or not it was named: an instance without identity cannot be
|
|
390
|
+
# updated, deleted, or matched to the children of a prefetch.
|
|
391
|
+
clone._columns = frozenset(
|
|
392
|
+
columns | {column.name for column in self._table.primary_key.columns}
|
|
393
|
+
)
|
|
394
|
+
return clone
|
|
395
|
+
|
|
396
|
+
def for_update(
|
|
397
|
+
self, *, nowait: bool = False, skip_locked: bool = False
|
|
398
|
+
) -> SnakeQuery[T]:
|
|
399
|
+
"""Locks the selected rows until the end of the transaction (`SELECT ... FOR UPDATE`).
|
|
400
|
+
|
|
401
|
+
It is HALF of concurrency control (the other half is the isolation level). `nowait` fails if
|
|
402
|
+
the row is already locked; `skip_locked` ignores it (the queue pattern): they are opposites,
|
|
403
|
+
asking for both is an error.
|
|
404
|
+
"""
|
|
405
|
+
if nowait and skip_locked:
|
|
406
|
+
raise SnakeUnsupportedFeature(
|
|
407
|
+
'nowait and skip_locked are mutually exclusive: one says "if it is locked, fail" '
|
|
408
|
+
'and the other "if it is locked, skip it". Choose the behaviour you want.'
|
|
409
|
+
)
|
|
410
|
+
if self._table.is_view:
|
|
411
|
+
raise SnakeUnsupportedFeature(
|
|
412
|
+
f"'{self._table.name}' is a READ-ONLY view: there is nothing of its own to lock. "
|
|
413
|
+
f"Lock the rows of the base table."
|
|
414
|
+
)
|
|
415
|
+
clone = self._clone()
|
|
416
|
+
if skip_locked:
|
|
417
|
+
clone._lock = SnakeLock.SKIP_LOCKED
|
|
418
|
+
elif nowait:
|
|
419
|
+
clone._lock = SnakeLock.NOWAIT
|
|
420
|
+
else:
|
|
421
|
+
clone._lock = SnakeLock.WAIT
|
|
422
|
+
return clone
|
|
423
|
+
|
|
424
|
+
def join(
|
|
425
|
+
self, collection: SnakeCollection[M], how: SnakeJoin = SnakeJoin.INNER
|
|
426
|
+
) -> SnakeJoinedQuery[T, M]:
|
|
427
|
+
"""EXPLICITLY joins a collection (to-many) to get THE CHILD'S ROWS into the projection.
|
|
428
|
+
|
|
429
|
+
It returns a `SnakeJoinedQuery`, a different type that only projects: a JOIN onto a
|
|
430
|
+
collection multiplies rows (a disaster for models), so it has no `.all()`/`.first()`. `how`
|
|
431
|
+
chooses whether childless parents show up.
|
|
432
|
+
"""
|
|
433
|
+
from snakeorm.query.joined import SnakeJoinedQuery, _resolve_join
|
|
434
|
+
|
|
435
|
+
spec = _resolve_join(self._model, collection, how)
|
|
436
|
+
return SnakeJoinedQuery(self, (spec,))
|
|
437
|
+
|
|
438
|
+
def as_scalar(self, column: SnakeExpr[V]) -> SnakeSubquery[V]:
|
|
439
|
+
"""Wraps the query as a scalar subquery of ONE column, for `.in_(...)`.
|
|
440
|
+
|
|
441
|
+
DIRECT column and flat WHERE only: navigating a relationship would demand a JOIN inside the
|
|
442
|
+
subquery, which is not built yet, so it gets rejected in plain words.
|
|
443
|
+
"""
|
|
444
|
+
if len(column.path) != 1:
|
|
445
|
+
raise SnakeUnsupportedFeature(
|
|
446
|
+
"a scalar subquery projects a direct column of its table, not a deep navigation "
|
|
447
|
+
"of relationships."
|
|
448
|
+
)
|
|
449
|
+
where = self._combined_where()
|
|
450
|
+
if where is not None and any(len(path) > 1 for path in condition_paths(where)):
|
|
451
|
+
raise SnakeUnsupportedFeature(
|
|
452
|
+
"the WHERE of a scalar subquery cannot navigate relationships (the JOIN inside the "
|
|
453
|
+
"subquery is not built yet); filter by direct columns."
|
|
454
|
+
)
|
|
455
|
+
return SnakeSubquery(
|
|
456
|
+
schema=self._table.schema,
|
|
457
|
+
name=self._table.name,
|
|
458
|
+
column=column.path[0],
|
|
459
|
+
where=where,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
def include(
|
|
463
|
+
self, *relations: type[Any] | SnakeCollection[Any] | SnakePrefetch[Any]
|
|
464
|
+
) -> SnakeQuery[T]:
|
|
465
|
+
"""Asks for relationships to be loaded (eagerly). Returns a new query.
|
|
466
|
+
|
|
467
|
+
To-one (`User.car`, proxies carrying a path) -> LEFT JOIN; to-many (`SnakeCollection`) ->
|
|
468
|
+
select-in. To nest to-many you pass a `SnakePrefetch(...).then(...)` (the collection does not
|
|
469
|
+
expose the child's relationships). Without `.include()`, touching the relationship blows up
|
|
470
|
+
with `SnakeRelationshipNotLoaded` (there is no silent N+1).
|
|
471
|
+
"""
|
|
472
|
+
prefetches = tuple(r for r in relations if isinstance(r, SnakePrefetch))
|
|
473
|
+
paths = tuple(
|
|
474
|
+
_include_path(r) for r in relations if not isinstance(r, SnakePrefetch)
|
|
475
|
+
)
|
|
476
|
+
clone = self._clone()
|
|
477
|
+
clone._includes = (*self._includes, *paths)
|
|
478
|
+
clone._prefetches = (*self._prefetches, *prefetches)
|
|
479
|
+
return clone
|
|
480
|
+
|
|
481
|
+
@property
|
|
482
|
+
def has_includes(self) -> bool:
|
|
483
|
+
"""Tells whether the query asks for relationships to be loaded (it picks the session's route)."""
|
|
484
|
+
return bool(self._includes) or bool(self._prefetches)
|
|
485
|
+
|
|
486
|
+
@property
|
|
487
|
+
def has_lock(self) -> bool:
|
|
488
|
+
"""Tells whether the query asks for rows to be locked (`for_update`). The compound looks at this."""
|
|
489
|
+
return self._lock is not None
|
|
490
|
+
|
|
491
|
+
@property
|
|
492
|
+
def has_bounds(self) -> bool:
|
|
493
|
+
"""Tells whether the query carries `limit`/`offset` of its own. The compound looks at this."""
|
|
494
|
+
return self._limit is not None or self._offset is not None
|
|
495
|
+
|
|
496
|
+
@property
|
|
497
|
+
def has_order(self) -> bool:
|
|
498
|
+
"""Tells whether the query carries `order_by` of its own. The compound looks at this.
|
|
499
|
+
|
|
500
|
+
The same question as `has_bounds` and for the same reason: both need the branch's
|
|
501
|
+
parentheses to stay inside the branch, and only one of the two was being asked.
|
|
502
|
+
"""
|
|
503
|
+
return bool(self._order)
|
|
504
|
+
|
|
505
|
+
@property
|
|
506
|
+
def has_cte(self) -> bool:
|
|
507
|
+
"""Whether the emitted SQL opens with a `WITH`. A plain query never does."""
|
|
508
|
+
return False
|
|
509
|
+
|
|
510
|
+
def prefetches(self) -> tuple[SnakePrefetch[Any], ...]:
|
|
511
|
+
"""Nested prefetch chains (deep to-many); the session resolves them level by level."""
|
|
512
|
+
return self._prefetches
|
|
513
|
+
|
|
514
|
+
def to_one_includes(self) -> tuple[tuple[str, ...], ...]:
|
|
515
|
+
"""Includes that are to-one chains (loaded with a JOIN in the same query)."""
|
|
516
|
+
return self._classify_includes()[0]
|
|
517
|
+
|
|
518
|
+
def to_many_includes(self) -> tuple[SnakeRelationshipInfo, ...]:
|
|
519
|
+
"""To-many relationships to load (resolved with a separate select-in, in the session)."""
|
|
520
|
+
return self._classify_includes()[1]
|
|
521
|
+
|
|
522
|
+
def _classify_includes(
|
|
523
|
+
self,
|
|
524
|
+
) -> tuple[tuple[tuple[str, ...], ...], tuple[SnakeRelationshipInfo, ...]]:
|
|
525
|
+
"""Splits the includes into to-one chains (JOIN) and to-many relationships (select-in).
|
|
526
|
+
|
|
527
|
+
By direct navigation only these are admitted: to-one chains and a to-many of ONE hop.
|
|
528
|
+
Nesting to-many or mixing them is done with `SnakePrefetch(...)`.
|
|
529
|
+
"""
|
|
530
|
+
to_one: list[tuple[str, ...]] = []
|
|
531
|
+
to_many: list[SnakeRelationshipInfo] = []
|
|
532
|
+
for path in self._includes:
|
|
533
|
+
kinds = self._hop_kinds(path)
|
|
534
|
+
# A SINGLE to-many hop (direct or through a bridge) -> select-in. It is compared against
|
|
535
|
+
# the enum, not against tuples of strings (that comparison stayed False forever and
|
|
536
|
+
# disabled to-many loading; mypy caught it).
|
|
537
|
+
if kinds in (
|
|
538
|
+
(SnakeRelationshipKind.TO_MANY,),
|
|
539
|
+
(SnakeRelationshipKind.TO_MANY_THROUGH,),
|
|
540
|
+
):
|
|
541
|
+
to_many.append(_relationship(self._table, path[0]))
|
|
542
|
+
elif all(kind is SnakeRelationshipKind.TO_ONE for kind in kinds):
|
|
543
|
+
to_one.append(path)
|
|
544
|
+
else:
|
|
545
|
+
raise SnakeUnsupportedFeature(
|
|
546
|
+
f"an include with mixed navigation or a deep to-many is not navigable: {path}. "
|
|
547
|
+
f"To nest to-many use SnakePrefetch(<collection>).then(<relationship>)."
|
|
548
|
+
)
|
|
549
|
+
return tuple(to_one), tuple(to_many)
|
|
550
|
+
|
|
551
|
+
def _hop_kinds(self, path: tuple[str, ...]) -> tuple[SnakeRelationshipKind, ...]:
|
|
552
|
+
"""The cardinality of each hop of the path (walking the linked graph)."""
|
|
553
|
+
kinds: list[SnakeRelationshipKind] = []
|
|
554
|
+
table: SnakeTableInfo | None = self._table
|
|
555
|
+
for step in path:
|
|
556
|
+
if table is None:
|
|
557
|
+
break
|
|
558
|
+
relationship = _relationship(table, step)
|
|
559
|
+
kinds.append(relationship.kind)
|
|
560
|
+
table = registry.resolve_relationship(relationship)[0]
|
|
561
|
+
return tuple(kinds)
|
|
562
|
+
|
|
563
|
+
def include_segments(self) -> tuple[IncludeSegment, ...]:
|
|
564
|
+
"""Segments to load in order: root + each relationship prefix (parent before child).
|
|
565
|
+
|
|
566
|
+
Each segment is `(prefix, model, table)`. It includes ALL the prefixes
|
|
567
|
+
(`.include(User.car.brand)` also loads `car`, so brand can be nested inside car).
|
|
568
|
+
"""
|
|
569
|
+
segments: list[IncludeSegment] = [((), self._model, self._table)]
|
|
570
|
+
for prefix in self._include_prefixes():
|
|
571
|
+
model: type = self._model
|
|
572
|
+
table = self._table
|
|
573
|
+
for step in prefix:
|
|
574
|
+
relationship = _relationship(table, step)
|
|
575
|
+
resolved_table, resolved_model = registry.resolve_relationship(
|
|
576
|
+
relationship
|
|
577
|
+
)
|
|
578
|
+
if resolved_model is None or resolved_table is None:
|
|
579
|
+
raise SnakeRegistryError(
|
|
580
|
+
f"The target '{relationship.target}' of '{step}' is not registered."
|
|
581
|
+
)
|
|
582
|
+
model, table = resolved_model, resolved_table
|
|
583
|
+
segments.append((prefix, model, table))
|
|
584
|
+
return tuple(segments)
|
|
585
|
+
|
|
586
|
+
def to_include_sql(self, dialect: SnakeDialect) -> tuple[str, tuple[object, ...]]:
|
|
587
|
+
"""Compiles the SELECT that loads the root + the included relationships (with their LEFT JOINs)."""
|
|
588
|
+
self._guard_unsupported(
|
|
589
|
+
"A SELECT with include(...)",
|
|
590
|
+
_HONOURED_BY_INCLUDES,
|
|
591
|
+
"Lock the rows in a separate query on the table you want to reserve, then load the "
|
|
592
|
+
"relationships.",
|
|
593
|
+
)
|
|
594
|
+
where = self._combined_where()
|
|
595
|
+
column_paths = condition_paths(where) if where is not None else []
|
|
596
|
+
column_paths = [
|
|
597
|
+
*column_paths,
|
|
598
|
+
*(p for key in self._order for p in key.expr.paths()),
|
|
599
|
+
]
|
|
600
|
+
plan = JoinPlan(
|
|
601
|
+
self._table,
|
|
602
|
+
column_paths,
|
|
603
|
+
dialect,
|
|
604
|
+
self._registry,
|
|
605
|
+
relationship_paths=self.to_one_includes(),
|
|
606
|
+
)
|
|
607
|
+
segments = [
|
|
608
|
+
(prefix, table) for prefix, _model, table in self.include_segments()
|
|
609
|
+
]
|
|
610
|
+
return emit_select_with_includes(
|
|
611
|
+
dialect,
|
|
612
|
+
segments,
|
|
613
|
+
plan,
|
|
614
|
+
where=where,
|
|
615
|
+
order_by=self._order,
|
|
616
|
+
limit=self._limit,
|
|
617
|
+
offset=self._offset,
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
def _include_prefixes(self) -> list[tuple[str, ...]]:
|
|
621
|
+
"""Unique prefixes of the to-one includes, sorted parent-before-child (for the JOIN)."""
|
|
622
|
+
prefixes: set[tuple[str, ...]] = set()
|
|
623
|
+
for path in self.to_one_includes():
|
|
624
|
+
for length in range(1, len(path) + 1):
|
|
625
|
+
prefixes.add(path[:length])
|
|
626
|
+
return sorted(prefixes, key=lambda prefix: (len(prefix), prefix))
|
|
627
|
+
|
|
628
|
+
def union(self, other: SnakeCompoundBranch[T]) -> SnakeCompound[T]:
|
|
629
|
+
"""`UNION`: the rows of both, WITHOUT duplicates.
|
|
630
|
+
|
|
631
|
+
The type demands the SAME model, a guarantee SQL does not give (it is happy if the columns line up).
|
|
632
|
+
"""
|
|
633
|
+
|
|
634
|
+
return _compose(SnakeSetOp.UNION, self, other)
|
|
635
|
+
|
|
636
|
+
def union_all(self, other: SnakeCompoundBranch[T]) -> SnakeCompound[T]:
|
|
637
|
+
"""`UNION ALL`: the rows of both, KEEPING duplicates.
|
|
638
|
+
|
|
639
|
+
It is not an optimised `UNION`: a plain `UNION` deduplicates the whole result. Both exist so
|
|
640
|
+
the choice is made consciously.
|
|
641
|
+
"""
|
|
642
|
+
|
|
643
|
+
return _compose(SnakeSetOp.UNION_ALL, self, other)
|
|
644
|
+
|
|
645
|
+
def except_(self, other: SnakeCompoundBranch[T]) -> SnakeCompound[T]:
|
|
646
|
+
"""`EXCEPT`: the ones of this query that are NOT in the other. Trailing underscore: `except` is a reserved word."""
|
|
647
|
+
|
|
648
|
+
return _compose(SnakeSetOp.EXCEPT, self, other)
|
|
649
|
+
|
|
650
|
+
def intersect(self, other: SnakeCompoundBranch[T]) -> SnakeCompound[T]:
|
|
651
|
+
"""`INTERSECT`: only the rows that are in BOTH queries."""
|
|
652
|
+
|
|
653
|
+
return _compose(SnakeSetOp.INTERSECT, self, other)
|
|
654
|
+
|
|
655
|
+
def recursive(
|
|
656
|
+
self, *, on: tuple[SnakeExpr[Any], SnakeExpr[Any]], distinct: bool = False
|
|
657
|
+
) -> SnakeRecursive[T]:
|
|
658
|
+
"""Expands this query by following a hop onto ITSELF (`WITH RECURSIVE`).
|
|
659
|
+
|
|
660
|
+
This query is the ANCHOR and `on` is the pair of columns that chains each level: first the
|
|
661
|
+
one pointing upwards, then the one identifying the row reached. Swapping it walks the
|
|
662
|
+
ANCESTORS (perfectly legitimate).
|
|
663
|
+
|
|
664
|
+
SnakeQuery(Category).filter(Category.id == 1).recursive(on=(Category.parent_id, Category.id))
|
|
665
|
+
|
|
666
|
+
`distinct` picks the operator joining each step: `False` (default) emits `UNION ALL`, what a
|
|
667
|
+
TREE wants; `True` emits `UNION`, so each step drops the rows already seen.
|
|
668
|
+
|
|
669
|
+
**Pass `distinct=True` if the data may have CYCLES**, which only the caller knows — a
|
|
670
|
+
self-referencing FK admits one. With `UNION ALL` a cyclic walk never ends: every lap yields
|
|
671
|
+
rows the engine counts as new, so it hangs instead of failing. `limit()` is NOT a
|
|
672
|
+
substitute — it bounds what comes back, not the walk. Measured on Postgres, a cyclic walk
|
|
673
|
+
with `order_by()` and `LIMIT 3` never returns.
|
|
674
|
+
"""
|
|
675
|
+
|
|
676
|
+
return build_recursive(self, self._table, on[0], on[1], distinct)
|
|
677
|
+
|
|
678
|
+
def _guard_unsupported(
|
|
679
|
+
self, path: str, honoured: frozenset[str], remedy: str
|
|
680
|
+
) -> None:
|
|
681
|
+
"""Refuses the knobs this emitter carries but does NOT emit.
|
|
682
|
+
|
|
683
|
+
A knob that is set and not emitted is a question the caller asked and got no answer to,
|
|
684
|
+
while still getting AN answer — which is the worst shape a failure can take. Three read
|
|
685
|
+
paths did exactly that: the plain SELECT swallowed `group_by`/`having`, the COUNT swallowed
|
|
686
|
+
those plus `distinct` (so a paginator counted rows instead of groups), and the SELECT with
|
|
687
|
+
includes swallowed `for_update` (so a lock the caller asked for was never taken).
|
|
688
|
+
|
|
689
|
+
`honoured` is what the emitter DOES write; everything else is derived from `__slots__`, so a
|
|
690
|
+
tenth knob added to this class is guarded the day it appears instead of the day someone
|
|
691
|
+
remembers. That is the difference between this and `_guard_bulk_write`, which lists its
|
|
692
|
+
knobs by hand and is one `or` away from the same hole.
|
|
693
|
+
"""
|
|
694
|
+
carried = {
|
|
695
|
+
slot
|
|
696
|
+
for slot in self.__slots__
|
|
697
|
+
if slot not in _NOT_A_KNOB and getattr(self, slot)
|
|
698
|
+
}
|
|
699
|
+
missing = carried - honoured
|
|
700
|
+
dropped = sorted(
|
|
701
|
+
_KNOB_SPELLING.get(slot, f"{slot.lstrip('_')}()") for slot in missing
|
|
702
|
+
)
|
|
703
|
+
if dropped:
|
|
704
|
+
# The per-knob remedies come first, and the path's own only if some dropped knob has
|
|
705
|
+
# none of its own. Appending it always is what produced "does not emit only()/defer()
|
|
706
|
+
# ... lock the rows in a separate query" — advice about a knob nobody had touched.
|
|
707
|
+
advice = [
|
|
708
|
+
_KNOB_REMEDY[slot] for slot in sorted(missing) if slot in _KNOB_REMEDY
|
|
709
|
+
]
|
|
710
|
+
if not missing <= _KNOB_REMEDY.keys():
|
|
711
|
+
advice.append(remedy)
|
|
712
|
+
raise SnakeUnsupportedFeature(
|
|
713
|
+
f"{path} does not emit {', '.join(dropped)}, and dropping what you asked for "
|
|
714
|
+
f"would answer a different question without saying so. {' '.join(advice)}"
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
def to_sql(self, dialect: SnakeDialect) -> tuple[str, tuple[object, ...]]:
|
|
718
|
+
"""Compiles the query to `(sql, params)`, generating JOINs if there is deep navigation."""
|
|
719
|
+
self._guard_unsupported(
|
|
720
|
+
"A plain SELECT",
|
|
721
|
+
_HONOURED_BY_SELECT,
|
|
722
|
+
"Use select(...) + group_by(...) to project a grouping.",
|
|
723
|
+
)
|
|
724
|
+
where = self._combined_where()
|
|
725
|
+
return emit_select(
|
|
726
|
+
self._table,
|
|
727
|
+
dialect,
|
|
728
|
+
where=where,
|
|
729
|
+
order_by=self._order,
|
|
730
|
+
limit=self._limit,
|
|
731
|
+
offset=self._offset,
|
|
732
|
+
plan=self._plan(where, dialect, include_order=True),
|
|
733
|
+
distinct=self._distinct,
|
|
734
|
+
lock=self._lock,
|
|
735
|
+
columns=self._columns,
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
def to_count_sql(self, dialect: SnakeDialect) -> tuple[str, tuple[object, ...]]:
|
|
739
|
+
"""Compiles a `SELECT COUNT(*)` with the same filters/JOINs (it ignores order and limit)."""
|
|
740
|
+
self._guard_unsupported(
|
|
741
|
+
"A COUNT(*)",
|
|
742
|
+
_HONOURED_BY_COUNT,
|
|
743
|
+
"Count the projection with select(...) if you need the number of groups or of "
|
|
744
|
+
"distinct rows.",
|
|
745
|
+
)
|
|
746
|
+
where = self._combined_where()
|
|
747
|
+
return emit_count(
|
|
748
|
+
self._table, dialect, where=where, plan=self._plan(where, dialect)
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
def to_exists_sql(self, dialect: SnakeDialect) -> tuple[str, tuple[object, ...]]:
|
|
752
|
+
"""Compiles a `SELECT EXISTS(...)` with the same filters/JOINs.
|
|
753
|
+
|
|
754
|
+
IT GUARDS THE SAME KNOBS AS THE COUNT, and it did not until this line was written. `exists`
|
|
755
|
+
was the one read path that called no guard at all, so it swallowed `group_by`, `having`,
|
|
756
|
+
`distinct` and `lock` in silence — an EXISTS over groups is a different question from an
|
|
757
|
+
EXISTS over rows, and the caller who asked got the second one with no word about it.
|
|
758
|
+
|
|
759
|
+
It is the same shape as entry #18 of the bug journal, which is about `count()` being fixed
|
|
760
|
+
and its two brothers being left. This is the third brother, found by adding a tenth knob and
|
|
761
|
+
noticing that one path did not refuse it.
|
|
762
|
+
|
|
763
|
+
What it honours is what a COUNT honours, for the same reasons: order, limit and offset do not
|
|
764
|
+
change whether a row exists, and the includes are resolved afterwards by the session.
|
|
765
|
+
"""
|
|
766
|
+
self._guard_unsupported(
|
|
767
|
+
"A SELECT EXISTS",
|
|
768
|
+
_HONOURED_BY_COUNT,
|
|
769
|
+
"Ask it of the projection with select(...) if the question is about groups.",
|
|
770
|
+
)
|
|
771
|
+
where = self._combined_where()
|
|
772
|
+
return emit_exists(
|
|
773
|
+
self._table, dialect, where=where, plan=self._plan(where, dialect)
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
def to_update_sql(
|
|
777
|
+
self, dialect: SnakeDialect, values: Mapping[str, object]
|
|
778
|
+
) -> tuple[str, tuple[object, ...]]:
|
|
779
|
+
"""Compiles a BULK UPDATE with the query's WHERE (EXECUTION lives in the session).
|
|
780
|
+
|
|
781
|
+
The values arrive already as SQL column names. If the WHERE navigates a relationship it is
|
|
782
|
+
rewritten to `<pk> IN (subquery)` (no `UPDATE ... FROM`). The guards are put in place by
|
|
783
|
+
`_guard_bulk_write`.
|
|
784
|
+
"""
|
|
785
|
+
self._guard_bulk_write("UPDATE")
|
|
786
|
+
where = self._combined_where()
|
|
787
|
+
deep_plan = self._deep_write_plan(where, dialect)
|
|
788
|
+
if deep_plan is not None:
|
|
789
|
+
assert where is not None # a deep plan exists => a WHERE exists
|
|
790
|
+
return emit_update_pk_in_subquery(
|
|
791
|
+
self._table, dialect, values, deep_plan, where
|
|
792
|
+
)
|
|
793
|
+
return emit_update(self._table, dialect, values, where=where)
|
|
794
|
+
|
|
795
|
+
def to_delete_sql(self, dialect: SnakeDialect) -> tuple[str, tuple[object, ...]]:
|
|
796
|
+
"""Compiles a BULK DELETE with the query's WHERE (EXECUTION lives in the session).
|
|
797
|
+
|
|
798
|
+
Like `to_update_sql`: a WHERE that navigates is rewritten to `<pk> IN (subquery)` (no `DELETE ... FROM`).
|
|
799
|
+
"""
|
|
800
|
+
self._guard_bulk_write("DELETE")
|
|
801
|
+
where = self._combined_where()
|
|
802
|
+
deep_plan = self._deep_write_plan(where, dialect)
|
|
803
|
+
if deep_plan is not None:
|
|
804
|
+
assert where is not None # a deep plan exists => a WHERE exists
|
|
805
|
+
return emit_delete_pk_in_subquery(self._table, dialect, deep_plan, where)
|
|
806
|
+
return emit_delete(self._table, dialect, where=where)
|
|
807
|
+
|
|
808
|
+
def _deep_write_plan(
|
|
809
|
+
self, where: SnakeCondition | None, dialect: SnakeDialect
|
|
810
|
+
) -> JoinPlan | None:
|
|
811
|
+
"""JoinPlan for the subquery of a deep bulk write, or None if the WHERE is flat.
|
|
812
|
+
|
|
813
|
+
It returns the plan only when some path navigates a relationship.
|
|
814
|
+
"""
|
|
815
|
+
if where is None:
|
|
816
|
+
return None
|
|
817
|
+
paths = condition_paths(where)
|
|
818
|
+
if not any(len(path) > 1 for path in paths):
|
|
819
|
+
return None
|
|
820
|
+
return JoinPlan(self._table, paths, dialect, self._registry)
|
|
821
|
+
|
|
822
|
+
def _guard_bulk_write(self, verb: str) -> None:
|
|
823
|
+
"""Guards the bulk write: WHERE only, with a filter, and not over a view.
|
|
824
|
+
|
|
825
|
+
- It only uses the filter: limit/offset/order_by/group_by/having/include are rejected (better
|
|
826
|
+
an error than ignoring them in silence).
|
|
827
|
+
- With no filter it would hit the WHOLE table: an explicit WHERE is demanded.
|
|
828
|
+
- A WHERE that navigates IS fine (it is rewritten to `<pk> IN (subquery)`).
|
|
829
|
+
- Over a VIEW there is no writing (a runtime reinforcement).
|
|
830
|
+
"""
|
|
831
|
+
if self._table.is_view:
|
|
832
|
+
raise SnakeUnsupportedFeature(
|
|
833
|
+
f"'{self._table.name}' is a READ-ONLY view: it does not accept a bulk {verb}."
|
|
834
|
+
)
|
|
835
|
+
# DERIVED from `__slots__`, not listed by hand: a knob added to this class is guarded the
|
|
836
|
+
# day it appears rather than the day someone remembers. The hand-written chain named six of
|
|
837
|
+
# the ten and dropped four in SILENCE — `distinct()`, `for_update()`, `only()`/`defer()` and
|
|
838
|
+
# the `_prefetches` half of `include()`. A `delete_where(q.distinct())` emitted a plain
|
|
839
|
+
# DELETE and answered a different question.
|
|
840
|
+
#
|
|
841
|
+
# Compared against a PRISTINE query rather than by truthiness, and that is not style:
|
|
842
|
+
# `limit(0)`/`offset(0)` are legal and FALSY, so `getattr(self, slot)` would read `limit(0)`
|
|
843
|
+
# as "no limit set" and emit a DELETE with no limit at all — destroying every matching row
|
|
844
|
+
# for a caller who asked for at most zero.
|
|
845
|
+
pristine = type(self)(self._model)
|
|
846
|
+
carried = sorted(
|
|
847
|
+
slot
|
|
848
|
+
for slot in self.__slots__
|
|
849
|
+
if slot not in _NOT_A_KNOB
|
|
850
|
+
and getattr(self, slot) != getattr(pristine, slot)
|
|
851
|
+
)
|
|
852
|
+
if carried:
|
|
853
|
+
dropped = ", ".join(
|
|
854
|
+
_KNOB_SPELLING.get(slot, f"{slot.lstrip('_')}()") for slot in carried
|
|
855
|
+
)
|
|
856
|
+
raise SnakeUnsupportedFeature(
|
|
857
|
+
f"a bulk {verb} only uses the filter (WHERE), and it does not emit {dropped}. "
|
|
858
|
+
f"Dropping what you asked for would answer a different question without saying so: "
|
|
859
|
+
f"select the rows first if you need those, then write by primary key."
|
|
860
|
+
)
|
|
861
|
+
if self._combined_where() is None:
|
|
862
|
+
raise SnakeUnsupportedFeature(
|
|
863
|
+
f"a {verb} without a WHERE would affect the WHOLE table; if that is what you want, "
|
|
864
|
+
f"pass it an explicit filter with .filter(...)."
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
def to_project_sql(
|
|
868
|
+
self,
|
|
869
|
+
dialect: SnakeDialect,
|
|
870
|
+
columns: Sequence[SnakeValue[Any]],
|
|
871
|
+
explicit_joins: Sequence[tuple[tuple[str, ...], bool]] = (),
|
|
872
|
+
) -> tuple[str, tuple[object, ...]]:
|
|
873
|
+
"""Compiles a `SELECT <columns>` (projection) with filters, GROUP BY/HAVING and their JOINs.
|
|
874
|
+
|
|
875
|
+
The paths of columns, WHERE, GROUP BY and HAVING all go into the `JoinPlan` (that is how a
|
|
876
|
+
`group_by(Truck.maker.name)` generates its JOIN); `paths()` of a `COUNT(*)` is empty.
|
|
877
|
+
`explicit_joins` are the `.join()`s onto collections: if there are any, the plan is ALWAYS
|
|
878
|
+
built (the JOIN already demands aliases).
|
|
879
|
+
"""
|
|
880
|
+
where = self._combined_where()
|
|
881
|
+
having = self._combined_having()
|
|
882
|
+
paths = condition_paths(where) if where is not None else []
|
|
883
|
+
paths = [
|
|
884
|
+
*paths,
|
|
885
|
+
*(p for column in columns for p in column.paths()),
|
|
886
|
+
*(p for key in self._order for p in key.expr.paths()),
|
|
887
|
+
*(p for column in self._group_by for p in column.paths()),
|
|
888
|
+
*(condition_paths(having) if having is not None else []),
|
|
889
|
+
]
|
|
890
|
+
plan = None
|
|
891
|
+
if explicit_joins or any(len(path) > 1 for path in paths):
|
|
892
|
+
plan = JoinPlan(
|
|
893
|
+
self._table,
|
|
894
|
+
paths,
|
|
895
|
+
dialect,
|
|
896
|
+
self._registry,
|
|
897
|
+
explicit_joins=explicit_joins,
|
|
898
|
+
)
|
|
899
|
+
return emit_project(
|
|
900
|
+
self._table,
|
|
901
|
+
dialect,
|
|
902
|
+
columns,
|
|
903
|
+
where=where,
|
|
904
|
+
plan=plan,
|
|
905
|
+
group_by=self._group_by,
|
|
906
|
+
having=having,
|
|
907
|
+
order_by=self._order,
|
|
908
|
+
limit=self._limit,
|
|
909
|
+
offset=self._offset,
|
|
910
|
+
distinct=self._distinct,
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
def to_annotate_sql(
|
|
914
|
+
self, dialect: SnakeDialect, aggregates: Sequence[SnakeValue[Any]]
|
|
915
|
+
) -> tuple[str, tuple[object, ...]]:
|
|
916
|
+
"""Compiles the SELECT of `annotate()`: base model columns + aggregates, grouping by the PK.
|
|
917
|
+
|
|
918
|
+
The remaining columns depend functionally on the PK (Postgres accepts that). An explicit
|
|
919
|
+
`group_by` is an error: to group by something else there is `select()` + `group_by()`.
|
|
920
|
+
"""
|
|
921
|
+
if self._group_by:
|
|
922
|
+
raise SnakeEmitError(
|
|
923
|
+
"annotate() groups by the PK of the base model and does not accept an explicit "
|
|
924
|
+
"group_by(). To group by another column use select() + group_by()."
|
|
925
|
+
)
|
|
926
|
+
base_columns: tuple[SnakeValue[Any], ...] = tuple(
|
|
927
|
+
SnakeExpr(path=(column.name,)) for column in self._table.columns
|
|
928
|
+
)
|
|
929
|
+
group_by: tuple[SnakeValue[Any], ...] = tuple(
|
|
930
|
+
SnakeExpr(path=(column.name,)) for column in self._table.primary_key.columns
|
|
931
|
+
)
|
|
932
|
+
columns: tuple[SnakeValue[Any], ...] = (*base_columns, *aggregates)
|
|
933
|
+
where = self._combined_where()
|
|
934
|
+
paths = condition_paths(where) if where is not None else []
|
|
935
|
+
paths = [
|
|
936
|
+
*paths,
|
|
937
|
+
*(p for column in columns for p in column.paths()),
|
|
938
|
+
*(p for key in self._order for p in key.expr.paths()),
|
|
939
|
+
]
|
|
940
|
+
plan = None
|
|
941
|
+
if any(len(path) > 1 for path in paths):
|
|
942
|
+
plan = JoinPlan(self._table, paths, dialect, self._registry)
|
|
943
|
+
return emit_project(
|
|
944
|
+
self._table,
|
|
945
|
+
dialect,
|
|
946
|
+
columns,
|
|
947
|
+
where=where,
|
|
948
|
+
plan=plan,
|
|
949
|
+
group_by=group_by,
|
|
950
|
+
order_by=self._order,
|
|
951
|
+
limit=self._limit,
|
|
952
|
+
offset=self._offset,
|
|
953
|
+
functional_dependency=True,
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
def _plan(
|
|
957
|
+
self,
|
|
958
|
+
where: SnakeCondition | None,
|
|
959
|
+
dialect: SnakeDialect,
|
|
960
|
+
include_order: bool = False,
|
|
961
|
+
) -> JoinPlan | None:
|
|
962
|
+
"""Builds the JoinPlan if some path is deep; None if everything belongs to the root table."""
|
|
963
|
+
paths = condition_paths(where) if where is not None else []
|
|
964
|
+
if include_order:
|
|
965
|
+
paths = [*paths, *(p for key in self._order for p in key.expr.paths())]
|
|
966
|
+
if any(len(path) > 1 for path in paths):
|
|
967
|
+
return JoinPlan(self._table, paths, dialect, self._registry)
|
|
968
|
+
return None
|
|
969
|
+
|
|
970
|
+
def _clone(self) -> SnakeQuery[T]:
|
|
971
|
+
"""Copies the current state into a new query (the basis of the immutability)."""
|
|
972
|
+
clone = SnakeQuery(self._model)
|
|
973
|
+
clone._filters = self._filters
|
|
974
|
+
clone._order = self._order
|
|
975
|
+
clone._limit = self._limit
|
|
976
|
+
clone._offset = self._offset
|
|
977
|
+
clone._includes = self._includes
|
|
978
|
+
clone._prefetches = self._prefetches
|
|
979
|
+
clone._group_by = self._group_by
|
|
980
|
+
clone._having = self._having
|
|
981
|
+
clone._distinct = self._distinct
|
|
982
|
+
clone._lock = self._lock
|
|
983
|
+
clone._columns = self._columns
|
|
984
|
+
return clone
|
|
985
|
+
|
|
986
|
+
def _combined_where(self) -> SnakeCondition | None:
|
|
987
|
+
"""Combines every filter into a single condition (a flat AND), or None if there are none."""
|
|
988
|
+
return self._combine(self._filters)
|
|
989
|
+
|
|
990
|
+
def _combined_having(self) -> SnakeCondition | None:
|
|
991
|
+
"""Combines the HAVING conditions into a single one (a flat AND), or None if there are none."""
|
|
992
|
+
return self._combine(self._having)
|
|
993
|
+
|
|
994
|
+
@staticmethod
|
|
995
|
+
def _combine(conditions: tuple[SnakeCondition, ...]) -> SnakeCondition | None:
|
|
996
|
+
"""Reduces a tuple of conditions to a flat AND (or the only one, or None)."""
|
|
997
|
+
if not conditions:
|
|
998
|
+
return None
|
|
999
|
+
if len(conditions) == 1:
|
|
1000
|
+
return conditions[0]
|
|
1001
|
+
return SnakeAnd(parts=conditions)
|