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,232 @@
|
|
|
1
|
+
"""Emission of aggregates (COUNT/EXISTS/projection) reusing a SELECT's FROM+JOINs+WHERE.
|
|
2
|
+
|
|
3
|
+
Same deep-navigation `plan`; they project an aggregate instead of columns.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Iterator, Sequence
|
|
9
|
+
from dataclasses import fields, is_dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from snakeorm.dialects import SnakeDialect
|
|
13
|
+
from snakeorm.core.exceptions import SnakeEmitError
|
|
14
|
+
from snakeorm.expressions import (
|
|
15
|
+
SnakeAggregate,
|
|
16
|
+
SnakeStringAgg,
|
|
17
|
+
SnakeCondition,
|
|
18
|
+
SnakeOrder,
|
|
19
|
+
SnakeValue,
|
|
20
|
+
count,
|
|
21
|
+
)
|
|
22
|
+
from snakeorm.expressions.window import has_window
|
|
23
|
+
from snakeorm.metadata import SnakeTableInfo
|
|
24
|
+
from snakeorm.sql.condition import emit_condition_into
|
|
25
|
+
from snakeorm.sql.joins import JoinPlan
|
|
26
|
+
from snakeorm.sql.refs import qualified
|
|
27
|
+
from snakeorm.sql.value import Correlation, ExistsAliases, emit_order_key, emit_value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def emit_count(
|
|
31
|
+
table: SnakeTableInfo,
|
|
32
|
+
dialect: SnakeDialect,
|
|
33
|
+
where: SnakeCondition | None = None,
|
|
34
|
+
plan: JoinPlan | None = None,
|
|
35
|
+
) -> tuple[str, tuple[object, ...]]:
|
|
36
|
+
"""Emits `SELECT COUNT(*) FROM ... [JOINs] [WHERE]`.
|
|
37
|
+
|
|
38
|
+
The COUNT(*) is emitted through the `count()` node (the only place that knows how to write it);
|
|
39
|
+
it consumes no params.
|
|
40
|
+
"""
|
|
41
|
+
params: list[object] = []
|
|
42
|
+
qualify = plan.alias_for if plan is not None else None
|
|
43
|
+
projected = emit_value(count(), dialect, params, qualify)
|
|
44
|
+
return (
|
|
45
|
+
f"SELECT {projected} {_from_where(table, dialect, params, where, plan)}",
|
|
46
|
+
tuple(params),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def emit_exists(
|
|
51
|
+
table: SnakeTableInfo,
|
|
52
|
+
dialect: SnakeDialect,
|
|
53
|
+
where: SnakeCondition | None = None,
|
|
54
|
+
plan: JoinPlan | None = None,
|
|
55
|
+
) -> tuple[str, tuple[object, ...]]:
|
|
56
|
+
"""Emits `SELECT EXISTS(SELECT 1 FROM ... [JOINs] [WHERE])`."""
|
|
57
|
+
params: list[object] = []
|
|
58
|
+
inner = _from_where(table, dialect, params, where, plan)
|
|
59
|
+
return f"SELECT EXISTS(SELECT 1 {inner})", tuple(params)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def emit_project(
|
|
63
|
+
table: SnakeTableInfo,
|
|
64
|
+
dialect: SnakeDialect,
|
|
65
|
+
columns: Sequence[SnakeValue[Any]],
|
|
66
|
+
where: SnakeCondition | None = None,
|
|
67
|
+
plan: JoinPlan | None = None,
|
|
68
|
+
group_by: Sequence[SnakeValue[Any]] = (),
|
|
69
|
+
having: SnakeCondition | None = None,
|
|
70
|
+
order_by: tuple[SnakeOrder, ...] = (),
|
|
71
|
+
limit: int | None = None,
|
|
72
|
+
offset: int | None = None,
|
|
73
|
+
functional_dependency: bool = False,
|
|
74
|
+
distinct: bool = False,
|
|
75
|
+
) -> tuple[str, tuple[object, ...]]:
|
|
76
|
+
"""Emits `SELECT [DISTINCT] <cols> FROM ... [JOINs] [WHERE] [GROUP BY] [HAVING] [ORDER BY] [LIMIT]`.
|
|
77
|
+
|
|
78
|
+
Params in clause order (= SQL evaluation order) for continuous numbering. `having` without
|
|
79
|
+
`group_by` is valid (a global aggregate). `functional_dependency=True` disables the GROUP BY
|
|
80
|
+
guard: it is used by `annotate()`, which projects every column while grouping only by the PK
|
|
81
|
+
(they all depend on it).
|
|
82
|
+
"""
|
|
83
|
+
if not functional_dependency:
|
|
84
|
+
_guard_grouped_projection(columns, group_by)
|
|
85
|
+
_guard_no_window_in_group_by(group_by)
|
|
86
|
+
params: list[object] = []
|
|
87
|
+
qualify = plan.alias_for if plan is not None else None
|
|
88
|
+
# One single correlation for the whole statement: aggregates that are correlated subqueries
|
|
89
|
+
# (e.g. a `collection.count()` projected by annotate) need to know how to reference the parent.
|
|
90
|
+
# Plain columns and aggregates ignore it.
|
|
91
|
+
correlate = _correlation(table, dialect, plan)
|
|
92
|
+
projected = ", ".join(
|
|
93
|
+
emit_value(column, dialect, params, qualify, correlate) for column in columns
|
|
94
|
+
)
|
|
95
|
+
keyword = "SELECT DISTINCT" if distinct else "SELECT"
|
|
96
|
+
sql = f"{keyword} {projected} {_from_where(table, dialect, params, where, plan)}"
|
|
97
|
+
if group_by:
|
|
98
|
+
keys = ", ".join(
|
|
99
|
+
emit_value(column, dialect, params, qualify, correlate)
|
|
100
|
+
for column in group_by
|
|
101
|
+
)
|
|
102
|
+
sql = f"{sql} GROUP BY {keys}"
|
|
103
|
+
if having is not None:
|
|
104
|
+
sql = f"{sql} HAVING {emit_condition_into(having, dialect, params, qualify, correlate)}"
|
|
105
|
+
if order_by:
|
|
106
|
+
keys = ", ".join(
|
|
107
|
+
emit_order_key(key, dialect, params, qualify, correlate) for key in order_by
|
|
108
|
+
)
|
|
109
|
+
sql = f"{sql} ORDER BY {keys}"
|
|
110
|
+
clause = dialect.limit_offset(limit, offset, params)
|
|
111
|
+
if clause:
|
|
112
|
+
sql = f"{sql} {clause}"
|
|
113
|
+
return sql, tuple(params)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _correlation(
|
|
117
|
+
table: SnakeTableInfo, dialect: SnakeDialect, plan: JoinPlan | None
|
|
118
|
+
) -> Correlation:
|
|
119
|
+
"""Correlation context for the HAVING (in case it carries correlated subqueries)."""
|
|
120
|
+
parent_ref = (
|
|
121
|
+
plan.root_alias if plan is not None else dialect.quote_ident(table.name)
|
|
122
|
+
)
|
|
123
|
+
return Correlation(parent_ref=parent_ref, aliases=ExistsAliases())
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _flattened(child: object) -> Iterator[object]:
|
|
127
|
+
"""One field's value as nodes, unwrapping sequences however deeply they nest.
|
|
128
|
+
|
|
129
|
+
A `CASE` keeps its branches as a tuple OF TUPLES, so unwrapping one level finds a tuple and
|
|
130
|
+
stops there — which is how the aggregate inside a branch stayed invisible.
|
|
131
|
+
"""
|
|
132
|
+
if isinstance(child, (tuple, list)):
|
|
133
|
+
for item in child:
|
|
134
|
+
yield from _flattened(item)
|
|
135
|
+
else:
|
|
136
|
+
yield child
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _subnodes(value: object) -> Iterator[object]:
|
|
140
|
+
"""The direct children of an expression node, with its sequences flattened out.
|
|
141
|
+
|
|
142
|
+
It reads the node's own fields instead of naming the node types, and that is deliberate: the set
|
|
143
|
+
of things that can wrap an aggregate is open — `COALESCE`, `NULLIF`, `CASE`, arithmetic, a cast,
|
|
144
|
+
and whatever comes next. A walk that lists them is a walk that has to be remembered.
|
|
145
|
+
"""
|
|
146
|
+
if not is_dataclass(value) or isinstance(value, type):
|
|
147
|
+
return
|
|
148
|
+
for field in fields(value):
|
|
149
|
+
yield from _flattened(getattr(value, field.name))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _aggregated_paths(value: object) -> set[tuple[str, ...]]:
|
|
153
|
+
"""Every path that sits INSIDE an aggregate, however deeply it is wrapped.
|
|
154
|
+
|
|
155
|
+
An aggregate's own paths are all of them: `SUM(x)` aggregates `x` wherever the `SUM` appears.
|
|
156
|
+
|
|
157
|
+
`SnakeStringAgg` is named alongside `SnakeAggregate` and not folded into it: it IS an aggregate
|
|
158
|
+
—it collapses a group— but it is a separate node because its separator has no fixed position
|
|
159
|
+
across the engines. A walk that only knew the one class would have refused `string_agg(name)`
|
|
160
|
+
beside a `GROUP BY` as an ungrouped column, which is the opposite of what it is.
|
|
161
|
+
"""
|
|
162
|
+
if isinstance(value, (SnakeAggregate, SnakeStringAgg)):
|
|
163
|
+
return set(value.paths())
|
|
164
|
+
found: set[tuple[str, ...]] = set()
|
|
165
|
+
for child in _subnodes(value):
|
|
166
|
+
found |= _aggregated_paths(child)
|
|
167
|
+
return found
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _guard_grouped_projection(
|
|
171
|
+
columns: Sequence[SnakeValue[Any]], group_by: Sequence[SnakeValue[Any]]
|
|
172
|
+
) -> None:
|
|
173
|
+
"""With GROUP BY, every non-aggregated projected column must be grouped (caught before Postgres).
|
|
174
|
+
|
|
175
|
+
The comparison goes through `paths()`. A path is fine when it is grouped OR when it sits inside
|
|
176
|
+
an aggregate — which is what the error message has always said, and what the check used to
|
|
177
|
+
decide by asking whether the projected node ITSELF was an aggregate. `COALESCE(SUM(x), 0)` is
|
|
178
|
+
valid on every engine and was being refused, because the outer node is a `COALESCE`.
|
|
179
|
+
|
|
180
|
+
The other direction is what the walk must not lose: `COALESCE(name, '')` aggregates nothing, and
|
|
181
|
+
a bare column laundered through a wrapper has to stay an error.
|
|
182
|
+
"""
|
|
183
|
+
if not group_by:
|
|
184
|
+
return
|
|
185
|
+
grouped = {path for column in group_by for path in column.paths()}
|
|
186
|
+
for column in columns:
|
|
187
|
+
aggregated = _aggregated_paths(column)
|
|
188
|
+
for path in column.paths():
|
|
189
|
+
if path not in grouped and path not in aggregated:
|
|
190
|
+
name = ".".join(path)
|
|
191
|
+
raise SnakeEmitError(
|
|
192
|
+
f"Column '{name}' appears in the projection but neither in the GROUP BY "
|
|
193
|
+
f"nor inside an aggregate. Add it to group_by(...) or aggregate it."
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _from_where(
|
|
198
|
+
table: SnakeTableInfo,
|
|
199
|
+
dialect: SnakeDialect,
|
|
200
|
+
params: list[object],
|
|
201
|
+
where: SnakeCondition | None,
|
|
202
|
+
plan: JoinPlan | None,
|
|
203
|
+
) -> str:
|
|
204
|
+
"""Builds `FROM <table> [AS t0] [JOINs] [WHERE <cond>]` (shared by count/exists)."""
|
|
205
|
+
qualify = plan.alias_for if plan is not None else None
|
|
206
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
207
|
+
if plan is not None:
|
|
208
|
+
table_ref = f"{table_ref} AS {plan.root_alias}"
|
|
209
|
+
sql = f"FROM {table_ref}"
|
|
210
|
+
if plan is not None and plan.has_joins:
|
|
211
|
+
sql = f"{sql} {' '.join(plan.joins)}"
|
|
212
|
+
if where is not None:
|
|
213
|
+
parent_ref = (
|
|
214
|
+
plan.root_alias if plan is not None else dialect.quote_ident(table.name)
|
|
215
|
+
)
|
|
216
|
+
correlate = Correlation(parent_ref=parent_ref, aliases=ExistsAliases())
|
|
217
|
+
sql = f"{sql} WHERE {emit_condition_into(where, dialect, params, qualify, correlate)}"
|
|
218
|
+
return sql
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _guard_no_window_in_group_by(group_by: Sequence[SnakeValue[Any]]) -> None:
|
|
222
|
+
"""A window cannot group: SQL evaluates windows AFTER the GROUP BY.
|
|
223
|
+
|
|
224
|
+
The condition guard covers WHERE/HAVING, but the GROUP BY is a list of values, not a condition.
|
|
225
|
+
"""
|
|
226
|
+
for value in group_by:
|
|
227
|
+
if has_window(value):
|
|
228
|
+
raise SnakeEmitError(
|
|
229
|
+
"A window function cannot go in a GROUP BY: SQL evaluates it AFTER grouping, so "
|
|
230
|
+
"there is nothing to group by yet. Group by the columns and project the window, "
|
|
231
|
+
"or wrap the query and group on the outside."
|
|
232
|
+
)
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Emission of a parametrised WHERE: SnakeCondition -> (sql, params).
|
|
2
|
+
|
|
3
|
+
Walks the boolean AST and produces SQL with the dialect's placeholders; the values travel in
|
|
4
|
+
`params` (kills injection and enables multi-engine). `qualify` qualifies columns with an alias (deep
|
|
5
|
+
JOINs).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from snakeorm.dialects import SnakeDialect
|
|
14
|
+
from snakeorm.core.exceptions import (
|
|
15
|
+
SnakeEmitError,
|
|
16
|
+
SnakeNodeError,
|
|
17
|
+
SnakeUnsupportedFeature,
|
|
18
|
+
)
|
|
19
|
+
from snakeorm.expressions import (
|
|
20
|
+
SnakeAnd,
|
|
21
|
+
SnakeComparison,
|
|
22
|
+
SnakeCondition,
|
|
23
|
+
SnakeExists,
|
|
24
|
+
SnakeExistsJoin,
|
|
25
|
+
SnakeInList,
|
|
26
|
+
SnakeInSubquery,
|
|
27
|
+
SnakeIsNotNull,
|
|
28
|
+
SnakeIsNull,
|
|
29
|
+
SnakeLike,
|
|
30
|
+
SnakeNot,
|
|
31
|
+
SnakeOr,
|
|
32
|
+
SnakeSubquery,
|
|
33
|
+
SnakeTupleIn,
|
|
34
|
+
)
|
|
35
|
+
from snakeorm.expressions.window import has_window
|
|
36
|
+
from snakeorm.sql.refs import qualified
|
|
37
|
+
from snakeorm.sql.value import (
|
|
38
|
+
Correlation,
|
|
39
|
+
ExistsAliases,
|
|
40
|
+
Qualify,
|
|
41
|
+
emit_expression_or_literal,
|
|
42
|
+
emit_operand,
|
|
43
|
+
emit_value,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def emit_condition(
|
|
48
|
+
condition: SnakeCondition,
|
|
49
|
+
dialect: SnakeDialect,
|
|
50
|
+
qualify: Qualify | None = None,
|
|
51
|
+
correlate: Correlation | None = None,
|
|
52
|
+
) -> tuple[str, tuple[object, ...]]:
|
|
53
|
+
"""Translates an AST condition into a parametrised `(sql, params)`."""
|
|
54
|
+
params: list[object] = []
|
|
55
|
+
sql = emit_condition_into(condition, dialect, params, qualify, correlate)
|
|
56
|
+
return sql, tuple(params)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def inline_params(sql: str, params: Sequence[object], dialect: SnakeDialect) -> str:
|
|
60
|
+
"""Replaces a compiled SQL's placeholders with their literals, for DDL (which takes no params).
|
|
61
|
+
|
|
62
|
+
A forward pass that never revisits: a literal containing the placeholder token is not
|
|
63
|
+
reinterpreted. The token is asked of the dialect by index (for numbered placeholders `$1`, `$2`).
|
|
64
|
+
"""
|
|
65
|
+
written: list[str] = []
|
|
66
|
+
rest = sql
|
|
67
|
+
for index, value in enumerate(params, start=1):
|
|
68
|
+
head, separator, rest = rest.partition(dialect.placeholder(index))
|
|
69
|
+
if not separator:
|
|
70
|
+
raise SnakeEmitError(
|
|
71
|
+
f"The compiled SQL is missing a placeholder for parameter {index} of "
|
|
72
|
+
f"{len(params)}: it cannot be inlined into DDL."
|
|
73
|
+
)
|
|
74
|
+
written.append(head)
|
|
75
|
+
written.append(dialect.literal(value))
|
|
76
|
+
written.append(rest)
|
|
77
|
+
return "".join(written)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def emit_condition_ddl(condition: SnakeCondition, dialect: SnakeDialect) -> str:
|
|
81
|
+
"""Emits a condition for DDL: reuses the parametrised walk and then inlines the literals.
|
|
82
|
+
|
|
83
|
+
It does not reopen injection: only the programmer's own literals get here, escaped by the
|
|
84
|
+
dialect.
|
|
85
|
+
"""
|
|
86
|
+
sql, params = emit_condition(condition, dialect)
|
|
87
|
+
return inline_params(sql, params, dialect)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _value_in_condition(
|
|
91
|
+
value: object,
|
|
92
|
+
dialect: SnakeDialect,
|
|
93
|
+
params: list[object],
|
|
94
|
+
qualify: Qualify | None = None,
|
|
95
|
+
correlate: Correlation | None = None,
|
|
96
|
+
) -> str:
|
|
97
|
+
"""Emits a value inside a condition, rejecting window functions.
|
|
98
|
+
|
|
99
|
+
SQL evaluates windows AFTER WHERE/GROUP BY/HAVING. The guard lives here (the single choke point
|
|
100
|
+
every condition goes through) instead of being repeated in every node.
|
|
101
|
+
"""
|
|
102
|
+
if has_window(value):
|
|
103
|
+
raise SnakeEmitError(
|
|
104
|
+
"A window function cannot be used in a WHERE, a GROUP BY or a HAVING: SQL evaluates "
|
|
105
|
+
"them AFTER those clauses. Project it with select(...) and filter on the outside, "
|
|
106
|
+
"or solve it with a subquery."
|
|
107
|
+
)
|
|
108
|
+
return emit_value(value, dialect, params, qualify, correlate)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def emit_condition_into(
|
|
112
|
+
node: SnakeCondition,
|
|
113
|
+
dialect: SnakeDialect,
|
|
114
|
+
params: list[object],
|
|
115
|
+
qualify: Qualify | None = None,
|
|
116
|
+
correlate: Correlation | None = None,
|
|
117
|
+
) -> str:
|
|
118
|
+
"""Emits a node accumulating values into `params` (a side effect) and returns the SQL.
|
|
119
|
+
|
|
120
|
+
The placeholders use `len(params)`, so the numbering carries on (this is what allows combining
|
|
121
|
+
SET + WHERE without a collision). `qualify` qualifies columns with an alias; `correlate` carries
|
|
122
|
+
the EXISTS/COUNT context.
|
|
123
|
+
"""
|
|
124
|
+
if isinstance(node, SnakeComparison):
|
|
125
|
+
left = _value_in_condition(node.left, dialect, params, qualify, correlate)
|
|
126
|
+
right = emit_expression_or_literal(
|
|
127
|
+
node.right, dialect, params, qualify, correlate
|
|
128
|
+
)
|
|
129
|
+
return f"{left} {node.op.value} {right}"
|
|
130
|
+
if isinstance(node, SnakeAnd):
|
|
131
|
+
joined = " AND ".join(
|
|
132
|
+
emit_condition_into(part, dialect, params, qualify, correlate)
|
|
133
|
+
for part in node.parts
|
|
134
|
+
)
|
|
135
|
+
return f"({joined})"
|
|
136
|
+
if isinstance(node, SnakeOr):
|
|
137
|
+
joined = " OR ".join(
|
|
138
|
+
emit_condition_into(part, dialect, params, qualify, correlate)
|
|
139
|
+
for part in node.parts
|
|
140
|
+
)
|
|
141
|
+
return f"({joined})"
|
|
142
|
+
if isinstance(node, SnakeInList):
|
|
143
|
+
if not node.values:
|
|
144
|
+
raise SnakeEmitError("An IN needs at least one value")
|
|
145
|
+
left = _value_in_condition(node.left, dialect, params, qualify, correlate)
|
|
146
|
+
items = ", ".join(
|
|
147
|
+
emit_expression_or_literal(value, dialect, params, qualify, correlate)
|
|
148
|
+
for value in node.values
|
|
149
|
+
)
|
|
150
|
+
return f"{left} IN ({items})"
|
|
151
|
+
if isinstance(node, SnakeTupleIn):
|
|
152
|
+
return _emit_tuple_in(node, dialect, params, qualify, correlate)
|
|
153
|
+
if isinstance(node, SnakeIsNull):
|
|
154
|
+
return f"{_value_in_condition(node.left, dialect, params, qualify, correlate)} IS NULL"
|
|
155
|
+
if isinstance(node, SnakeIsNotNull):
|
|
156
|
+
return f"{_value_in_condition(node.left, dialect, params, qualify, correlate)} IS NOT NULL"
|
|
157
|
+
if isinstance(node, SnakeLike):
|
|
158
|
+
left = _value_in_condition(node.left, dialect, params, qualify, correlate)
|
|
159
|
+
pattern = emit_expression_or_literal(
|
|
160
|
+
node.pattern, dialect, params, qualify, correlate
|
|
161
|
+
)
|
|
162
|
+
# An ESCAPE clause only if the value's wildcards were escaped (startswith/...): without it
|
|
163
|
+
# SQLite treats the `\` as a character and silently filters wrong. A raw `.like()` respects
|
|
164
|
+
# the default.
|
|
165
|
+
#
|
|
166
|
+
# The character is the backslash on all three engines — that is decided in `_escape_like`,
|
|
167
|
+
# which is engine-agnostic — but how to WRITE it inside a literal is the dialect's job, so
|
|
168
|
+
# it goes through `literal()`. Hardcoded as `'\'` this clause was ERROR 1064 on MySQL, where
|
|
169
|
+
# the backslash escapes the quote and the literal never closes: `startswith`, `contains` and
|
|
170
|
+
# `endswith` did not work on that engine at all.
|
|
171
|
+
escape = f" ESCAPE {dialect.literal(chr(92))}" if node.escaped else ""
|
|
172
|
+
if node.case_insensitive and not dialect.supports_ilike:
|
|
173
|
+
# Portable fallback: SQLite has no ILIKE, so it folds both sides to
|
|
174
|
+
# lowercase.
|
|
175
|
+
return f"LOWER({left}) LIKE LOWER({pattern}){escape}"
|
|
176
|
+
operator = "ILIKE" if node.case_insensitive else "LIKE"
|
|
177
|
+
return f"{left} {operator} {pattern}{escape}"
|
|
178
|
+
if isinstance(node, SnakeNot):
|
|
179
|
+
return f"NOT ({emit_condition_into(node.operand, dialect, params, qualify, correlate)})"
|
|
180
|
+
if isinstance(node, SnakeExists):
|
|
181
|
+
return _emit_exists(node, dialect, params, correlate)
|
|
182
|
+
if isinstance(node, SnakeInSubquery):
|
|
183
|
+
left = _value_in_condition(node.left, dialect, params, qualify, correlate)
|
|
184
|
+
return f"{left} IN ({_emit_in_subquery(node.subquery, dialect, params)})"
|
|
185
|
+
raise SnakeNodeError(f"Condition node cannot be emitted: {type(node).__name__}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _emit_in_subquery(
|
|
189
|
+
sub: SnakeSubquery[Any], dialect: SnakeDialect, params: list[object]
|
|
190
|
+
) -> str:
|
|
191
|
+
"""Emits `SELECT <column> FROM <table> [WHERE ...]` of a scalar subquery.
|
|
192
|
+
|
|
193
|
+
Its params accumulate in the shared list (continuous numbering, positional).
|
|
194
|
+
Single-table (no alias); it gets its own correlation in case its WHERE nests
|
|
195
|
+
an EXISTS.
|
|
196
|
+
"""
|
|
197
|
+
quote = dialect.quote_ident
|
|
198
|
+
table_ref = qualified(sub.schema, sub.name, dialect)
|
|
199
|
+
sql = f"SELECT {quote(sub.column)} FROM {table_ref}"
|
|
200
|
+
if sub.where is not None:
|
|
201
|
+
correlate = Correlation(parent_ref=quote(sub.name), aliases=ExistsAliases())
|
|
202
|
+
where_sql = emit_condition_into(sub.where, dialect, params, None, correlate)
|
|
203
|
+
sql = f"{sql} WHERE {where_sql}"
|
|
204
|
+
return sql
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _guard_placeholder_ceiling(node: SnakeTupleIn, dialect: SnakeDialect) -> None:
|
|
208
|
+
"""Refuses a row constructor that would bind more placeholders than the engine accepts.
|
|
209
|
+
|
|
210
|
+
The number is the engine's own declared `bind_params`, which `add_all` and the prefetch already
|
|
211
|
+
slice by; this is the one path where the caller supplies the rows by hand.
|
|
212
|
+
|
|
213
|
+
MEASURED, at two widths, because two laws were candidates. On SQLite 3.53, 16.383 keys of two
|
|
214
|
+
columns and 8.191 of four both stop at the SAME placeholder count: what governs it is the
|
|
215
|
+
placeholders. Without this the engine answers `too many SQL variables`, which names neither the
|
|
216
|
+
query nor the way out.
|
|
217
|
+
|
|
218
|
+
WHAT IS DELIBERATELY NOT GUARDED is PostgreSQL's other ceiling. Measured on 17: it refuses at
|
|
219
|
+
8.184 keys with `stack depth limit exceeded` at BOTH widths — so there the law is the number of
|
|
220
|
+
keys and not the placeholders, and it is the PARSER's recursion, well under the 65.535 of the
|
|
221
|
+
protocol. That number moves with `max_stack_depth`, which is a server setting, so the ORM cannot
|
|
222
|
+
know it: refusing at a number taken from one server's configuration would forbid on a tuned
|
|
223
|
+
server what the database there allows. The limit is written down in the reference instead of
|
|
224
|
+
invented here.
|
|
225
|
+
"""
|
|
226
|
+
placeholders = len(node.rows) * len(node.columns)
|
|
227
|
+
if placeholders <= dialect.max_bind_params:
|
|
228
|
+
return
|
|
229
|
+
raise SnakeEmitError(
|
|
230
|
+
f"This composite IN binds {placeholders} placeholders — {len(node.rows)} keys of "
|
|
231
|
+
f"{len(node.columns)} columns — and {type(dialect).__name__} accepts "
|
|
232
|
+
f"{dialect.max_bind_params} per statement. Slice the list of keys and combine the results; "
|
|
233
|
+
f"the ORM will not split it for you, because the halves are separate queries and only the "
|
|
234
|
+
f"caller knows whether that is acceptable here."
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _emit_tuple_in(
|
|
239
|
+
node: SnakeTupleIn,
|
|
240
|
+
dialect: SnakeDialect,
|
|
241
|
+
params: list[object],
|
|
242
|
+
qualify: Qualify | None,
|
|
243
|
+
correlate: Correlation | None,
|
|
244
|
+
) -> str:
|
|
245
|
+
"""Emits `(c1, c2) IN ((%s, %s), ...)`, or its OR-of-ANDs equivalent without `supports_row_constructor`.
|
|
246
|
+
|
|
247
|
+
No rows is unemittable (there is no `IN ()`). Columns with `emit_value` (no params),
|
|
248
|
+
values with `emit_operand` (parametrised, continuous numbering).
|
|
249
|
+
"""
|
|
250
|
+
if not node.rows:
|
|
251
|
+
raise SnakeEmitError("A tuple IN needs at least one row")
|
|
252
|
+
_guard_placeholder_ceiling(node, dialect)
|
|
253
|
+
if dialect.supports_row_constructor:
|
|
254
|
+
columns = ", ".join(
|
|
255
|
+
emit_value(column, dialect, params, qualify, correlate)
|
|
256
|
+
for column in node.columns
|
|
257
|
+
)
|
|
258
|
+
rows = ", ".join(
|
|
259
|
+
"(" + ", ".join(emit_operand(value, dialect, params) for value in row) + ")"
|
|
260
|
+
for row in node.rows
|
|
261
|
+
)
|
|
262
|
+
return f"({columns}) IN ({rows})"
|
|
263
|
+
clauses: list[str] = []
|
|
264
|
+
for row in node.rows:
|
|
265
|
+
conjunction = " AND ".join(
|
|
266
|
+
f"{emit_value(column, dialect, params, qualify, correlate)} "
|
|
267
|
+
f"= {emit_operand(value, dialect, params)}"
|
|
268
|
+
for column, value in zip(node.columns, row, strict=True)
|
|
269
|
+
)
|
|
270
|
+
clauses.append(f"({conjunction})")
|
|
271
|
+
return "(" + " OR ".join(clauses) + ")"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _emit_exists(
|
|
275
|
+
node: SnakeExists,
|
|
276
|
+
dialect: SnakeDialect,
|
|
277
|
+
params: list[object],
|
|
278
|
+
correlate: Correlation | None,
|
|
279
|
+
) -> str:
|
|
280
|
+
"""Emits `EXISTS (SELECT 1 FROM child AS alias [JOINs] WHERE child.fk = parent.pk [AND cond])`.
|
|
281
|
+
|
|
282
|
+
The alias (e0, e1...) comes from its own counter shared with the nested ones (it does not
|
|
283
|
+
collide with the outer t0...). The child's condition is re-anchored to the subquery's
|
|
284
|
+
alias; its to-one navigation JOINs (`node.joins`) are emitted inside, with aliases from the
|
|
285
|
+
same counter. A composite FK ANDs every pair. Everything parametrised.
|
|
286
|
+
"""
|
|
287
|
+
if correlate is None:
|
|
288
|
+
raise SnakeNodeError(
|
|
289
|
+
"SnakeExists without a correlation context: the parent cannot be referenced"
|
|
290
|
+
)
|
|
291
|
+
quote = dialect.quote_ident
|
|
292
|
+
alias = correlate.aliases.allocate()
|
|
293
|
+
# `qualified()`, not a hand-written `f"{schema}.{name}"`: an engine without schemas
|
|
294
|
+
# (SQLite) does not accept `"public".` and gave `no such table: public.x`, breaking
|
|
295
|
+
# `.any()`, `.count()` and the aggregates.
|
|
296
|
+
child_ref = qualified(node.child_schema, node.child_name, dialect)
|
|
297
|
+
on = " AND ".join(
|
|
298
|
+
f"{alias}.{quote(child_col)} = {correlate.parent_ref}.{quote(parent_col)}"
|
|
299
|
+
for child_col, parent_col in node.pairs
|
|
300
|
+
)
|
|
301
|
+
alias_map, joins_sql = _build_exists_joins(
|
|
302
|
+
node.joins, alias, correlate.aliases, dialect
|
|
303
|
+
)
|
|
304
|
+
from_clause = f"{child_ref} AS {alias}{joins_sql}"
|
|
305
|
+
if node.condition is None:
|
|
306
|
+
return f"EXISTS (SELECT 1 FROM {from_clause} WHERE {on})"
|
|
307
|
+
inner = Correlation(parent_ref=alias, aliases=correlate.aliases)
|
|
308
|
+
cond_sql = emit_condition_into(
|
|
309
|
+
node.condition, dialect, params, _exists_qualify(alias_map), inner
|
|
310
|
+
)
|
|
311
|
+
return f"EXISTS (SELECT 1 FROM {from_clause} WHERE {on} AND {cond_sql})"
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _build_exists_joins(
|
|
315
|
+
joins: tuple[SnakeExistsJoin, ...],
|
|
316
|
+
child_alias: str,
|
|
317
|
+
aliases: ExistsAliases,
|
|
318
|
+
dialect: SnakeDialect,
|
|
319
|
+
) -> tuple[dict[tuple[str, ...], str], str]:
|
|
320
|
+
"""Assigns an alias to each navigated JOIN of the EXISTS and emits its clauses; returns the prefix→alias map.
|
|
321
|
+
|
|
322
|
+
JOINs ordered from parent to child (the parent is already in `alias_map`; the base child
|
|
323
|
+
takes the empty prefix). Aliases from the same shared `ExistsAliases`, so they never
|
|
324
|
+
collide. to-one ON: `parent.local = alias.remote`; a composite FK ANDs the pairs.
|
|
325
|
+
"""
|
|
326
|
+
quote = dialect.quote_ident
|
|
327
|
+
alias_map: dict[tuple[str, ...], str] = {(): child_alias}
|
|
328
|
+
clauses: list[str] = []
|
|
329
|
+
for join in joins:
|
|
330
|
+
alias = aliases.allocate()
|
|
331
|
+
alias_map[join.prefix] = alias
|
|
332
|
+
parent_alias = alias_map[join.prefix[:-1]]
|
|
333
|
+
on = " AND ".join(
|
|
334
|
+
f"{parent_alias}.{quote(local)} = {alias}.{quote(remote)}"
|
|
335
|
+
for local, remote in join.pairs
|
|
336
|
+
)
|
|
337
|
+
table_ref = qualified(join.schema, join.name, dialect)
|
|
338
|
+
clauses.append(f" JOIN {table_ref} AS {alias} ON {on}")
|
|
339
|
+
return alias_map, "".join(clauses)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _exists_qualify(alias_map: dict[tuple[str, ...], str]) -> Qualify:
|
|
343
|
+
"""Qualifies a child column (or navigated target) with the alias of its relation prefix.
|
|
344
|
+
|
|
345
|
+
`Maker.name` → `()` → the child's alias; `Maker.nation.name` → `("nation",)` → the JOIN's
|
|
346
|
+
alias. A missing prefix would be a JOIN that was never built (it should not happen after
|
|
347
|
+
validating `.any()`).
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
def qualify(prefix: tuple[str, ...]) -> str:
|
|
351
|
+
alias = alias_map.get(prefix)
|
|
352
|
+
if alias is None:
|
|
353
|
+
raise SnakeUnsupportedFeature(
|
|
354
|
+
"navigation inside an EXISTS without a JOIN built for prefix "
|
|
355
|
+
f"'{'.'.join(prefix)}'; it should not happen after the .any() validation."
|
|
356
|
+
)
|
|
357
|
+
return alias
|
|
358
|
+
|
|
359
|
+
return qualify
|
snakeorm/sql/delete.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Emission of a parametrised DELETE: `DELETE FROM <schema.table> [WHERE <cond>]`.
|
|
2
|
+
|
|
3
|
+
`where=None` deletes the whole table (valid SQL): the guard-rail lives in query/session, not here.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from snakeorm.dialects import SnakeDialect
|
|
9
|
+
from snakeorm.expressions import SnakeCondition
|
|
10
|
+
from snakeorm.metadata import SnakeTableInfo
|
|
11
|
+
from snakeorm.sql.condition import emit_condition
|
|
12
|
+
from snakeorm.sql.joins import JoinPlan
|
|
13
|
+
from snakeorm.sql.pk_subquery import emit_pk_in, emit_pk_subquery
|
|
14
|
+
from snakeorm.sql.refs import qualified
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def emit_delete(
|
|
18
|
+
table: SnakeTableInfo,
|
|
19
|
+
dialect: SnakeDialect,
|
|
20
|
+
where: SnakeCondition | None = None,
|
|
21
|
+
) -> tuple[str, tuple[object, ...]]:
|
|
22
|
+
"""Emits `DELETE FROM ... [WHERE ...]` with the params kept apart."""
|
|
23
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
24
|
+
sql = f"DELETE FROM {table_ref}"
|
|
25
|
+
if where is None:
|
|
26
|
+
return sql, ()
|
|
27
|
+
where_sql, params = emit_condition(where, dialect)
|
|
28
|
+
return f"{sql} WHERE {where_sql}", params
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def emit_delete_pk_in_subquery(
|
|
32
|
+
table: SnakeTableInfo,
|
|
33
|
+
dialect: SnakeDialect,
|
|
34
|
+
plan: JoinPlan,
|
|
35
|
+
where: SnakeCondition,
|
|
36
|
+
) -> tuple[str, tuple[object, ...]]:
|
|
37
|
+
"""Emits `DELETE FROM ... WHERE <pk> IN (SELECT <pk> FROM ... [JOINs] WHERE <deep cond>)`.
|
|
38
|
+
|
|
39
|
+
For a WHERE that navigates a relationship: it is rewritten into a subquery over the PK (avoids
|
|
40
|
+
`DELETE ... FROM`, Postgres jargon). A composite PK uses a row constructor. Parametrised.
|
|
41
|
+
"""
|
|
42
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
43
|
+
params: list[object] = []
|
|
44
|
+
subquery = emit_pk_subquery(table, dialect, plan, where, params)
|
|
45
|
+
pk_in = emit_pk_in(table, dialect, subquery)
|
|
46
|
+
return f"DELETE FROM {table_ref} WHERE {pk_in}", tuple(params)
|