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/sql/insert.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Emission of parametrised INSERTs (single row, bulk, upsert): `INSERT INTO ... (cols) VALUES (...)`.
|
|
2
|
+
|
|
3
|
+
They delegate to the dialect the quoting, the placeholders, the conflict clause and `RETURNING`,
|
|
4
|
+
which returns ALL the columns so as to recover whatever the server puts there (autoincrement PK,
|
|
5
|
+
DEFAULT, triggers). Values go into `params`; never interpolated.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
|
|
12
|
+
from snakeorm.dialects import SnakeDialect
|
|
13
|
+
from snakeorm.core.exceptions import SnakeEmitError
|
|
14
|
+
from snakeorm.dialects.capabilities import EmptyInsertStyle
|
|
15
|
+
from snakeorm.metadata import SnakeTableInfo
|
|
16
|
+
from snakeorm.sql.refs import qualified
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def emit_insert(
|
|
20
|
+
table: SnakeTableInfo,
|
|
21
|
+
dialect: SnakeDialect,
|
|
22
|
+
values: Mapping[str, object],
|
|
23
|
+
) -> tuple[str, tuple[object, ...]]:
|
|
24
|
+
"""Emits `INSERT INTO ... VALUES ...` with RETURNING where applicable.
|
|
25
|
+
|
|
26
|
+
With no values the row is all defaults, and there each engine writes something different:
|
|
27
|
+
`DEFAULT VALUES` is the standard and MySQL does NOT have it —it wants `() VALUES ()`—, so the
|
|
28
|
+
shape is decided by `dialect.syntax`. A PK-only table is legitimate and must be insertable on all
|
|
29
|
+
three.
|
|
30
|
+
"""
|
|
31
|
+
if not values:
|
|
32
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
33
|
+
empty_row = (
|
|
34
|
+
"DEFAULT VALUES"
|
|
35
|
+
if dialect.syntax.empty_insert_style is EmptyInsertStyle.DEFAULT_VALUES
|
|
36
|
+
else "() VALUES ()"
|
|
37
|
+
)
|
|
38
|
+
return f"INSERT INTO {table_ref} {empty_row}{_returning(table, dialect)}", ()
|
|
39
|
+
|
|
40
|
+
columns = list(values)
|
|
41
|
+
params: list[object] = []
|
|
42
|
+
row_sql = _emit_row(columns, values, dialect, params)
|
|
43
|
+
sql = f"{_insert_head(table, dialect, columns)} VALUES {row_sql}"
|
|
44
|
+
return f"{sql}{_returning(table, dialect)}", tuple(params)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def emit_insert_many(
|
|
48
|
+
table: SnakeTableInfo,
|
|
49
|
+
dialect: SnakeDialect,
|
|
50
|
+
rows: Sequence[Mapping[str, object]],
|
|
51
|
+
) -> tuple[str, tuple[object, ...]]:
|
|
52
|
+
"""Emits a single `INSERT ... VALUES (...), (...), ...` for several rows.
|
|
53
|
+
|
|
54
|
+
One single INSERT (not executemany) so that `RETURNING` recovers every row in one go. They must
|
|
55
|
+
all have the same columns in the same order, or `SnakeEmitError`. The chunking lives in the
|
|
56
|
+
session.
|
|
57
|
+
"""
|
|
58
|
+
if not rows:
|
|
59
|
+
raise SnakeEmitError("A bulk INSERT needs at least one row")
|
|
60
|
+
columns = list(rows[0])
|
|
61
|
+
if not columns:
|
|
62
|
+
raise SnakeEmitError("An INSERT needs at least one column with a value")
|
|
63
|
+
expected = set(columns)
|
|
64
|
+
for row in rows:
|
|
65
|
+
if set(row) != expected:
|
|
66
|
+
raise SnakeEmitError(
|
|
67
|
+
"Every row of a bulk INSERT must have the same columns; "
|
|
68
|
+
f"{sorted(expected)} were expected."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
params: list[object] = []
|
|
72
|
+
tuples = ", ".join(_emit_row(columns, row, dialect, params) for row in rows)
|
|
73
|
+
sql = f"{_insert_head(table, dialect, columns)} VALUES {tuples}"
|
|
74
|
+
return f"{sql}{_returning(table, dialect)}", tuple(params)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def emit_upsert(
|
|
78
|
+
table: SnakeTableInfo,
|
|
79
|
+
dialect: SnakeDialect,
|
|
80
|
+
values: Mapping[str, object],
|
|
81
|
+
*,
|
|
82
|
+
conflict_columns: Sequence[str],
|
|
83
|
+
update_columns: Sequence[str] = (),
|
|
84
|
+
) -> tuple[str, tuple[object, ...]]:
|
|
85
|
+
"""Emits an INSERT idempotent under conflict: `INSERT ... <conflict clause> [RETURNING]`.
|
|
86
|
+
|
|
87
|
+
The clause (`ON CONFLICT`) is supplied by the dialect. Without `update_columns` it does not touch
|
|
88
|
+
the existing row. An empty `conflict_columns` is an error. Checking upsert support is the
|
|
89
|
+
session's business.
|
|
90
|
+
"""
|
|
91
|
+
if not conflict_columns:
|
|
92
|
+
raise SnakeEmitError(
|
|
93
|
+
"An upsert needs at least one conflict column (on_conflict)"
|
|
94
|
+
)
|
|
95
|
+
if not values:
|
|
96
|
+
# Here it does NOT fall back to `DEFAULT VALUES`: SQLite forbids combining it with `ON
|
|
97
|
+
# CONFLICT`, and an upsert with no values makes no sense (the conflict would be on the
|
|
98
|
+
# autogenerated PK, which never collides).
|
|
99
|
+
raise SnakeEmitError(
|
|
100
|
+
"An upsert needs at least one value to insert: conflict resolution is meaningless "
|
|
101
|
+
"for a row without columns (the conflict would be on the autogenerated PK, which "
|
|
102
|
+
"never collides). Use `add` if you only want to insert."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
conflict = dialect.on_conflict_clause(conflict_columns, update_columns)
|
|
106
|
+
columns = list(values)
|
|
107
|
+
params: list[object] = []
|
|
108
|
+
row_sql = _emit_row(columns, values, dialect, params)
|
|
109
|
+
sql = f"{_insert_head(table, dialect, columns)} VALUES {row_sql} {conflict}"
|
|
110
|
+
return f"{sql}{_returning(table, dialect)}", tuple(params)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _insert_head(
|
|
114
|
+
table: SnakeTableInfo, dialect: SnakeDialect, columns: Sequence[str]
|
|
115
|
+
) -> str:
|
|
116
|
+
"""`INSERT INTO <schema.table> (<quoted cols>)`: header shared by the three emitters."""
|
|
117
|
+
quoted_columns = ", ".join(dialect.quote_ident(column) for column in columns)
|
|
118
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
119
|
+
return f"INSERT INTO {table_ref} ({quoted_columns})"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _emit_row(
|
|
123
|
+
columns: Sequence[str],
|
|
124
|
+
values: Mapping[str, object],
|
|
125
|
+
dialect: SnakeDialect,
|
|
126
|
+
params: list[object],
|
|
127
|
+
) -> str:
|
|
128
|
+
"""`(ph, ph, ...)` for one row: every value enters `params` and numbers its placeholder.
|
|
129
|
+
|
|
130
|
+
It walks `columns` (not `values`) to fix the order shared by every row; the numbering runs
|
|
131
|
+
continuously across rows (which matters on positional dialects, `$1`, `$2`).
|
|
132
|
+
"""
|
|
133
|
+
placeholders: list[str] = []
|
|
134
|
+
for column in columns:
|
|
135
|
+
params.append(values[column])
|
|
136
|
+
placeholders.append(dialect.placeholder(len(params)))
|
|
137
|
+
return f"({', '.join(placeholders)})"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _returning(table: SnakeTableInfo, dialect: SnakeDialect) -> str:
|
|
141
|
+
"""` RETURNING <every column>` if the engine supports it, otherwise empty.
|
|
142
|
+
|
|
143
|
+
ALL of them (not just the PK): it recovers whatever the server puts there (autoincrement,
|
|
144
|
+
DEFAULT).
|
|
145
|
+
"""
|
|
146
|
+
if not dialect.supports_returning:
|
|
147
|
+
return ""
|
|
148
|
+
columns = ", ".join(dialect.quote_ident(column.name) for column in table.columns)
|
|
149
|
+
return f" RETURNING {columns}"
|
snakeorm/sql/joins.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""JOIN planning for deep navigation: relationship paths → aliases + JOIN clauses.
|
|
2
|
+
|
|
3
|
+
A path is `("rel1", ..., "column")`. It assigns an alias to the root (t0) and to every unique prefix
|
|
4
|
+
(t1...), one JOIN per prefix, ordered from parent to child; shared prefixes reuse an alias. It lives
|
|
5
|
+
in `sql/` because it emits SQL; each relationship's target is resolved by an injected
|
|
6
|
+
`resolve_target`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
|
|
13
|
+
from snakeorm.dialects import SnakeDialect
|
|
14
|
+
from snakeorm.sql.resolver import SnakeRelationshipResolver
|
|
15
|
+
from snakeorm.core.exceptions import (
|
|
16
|
+
SnakeRegistryError,
|
|
17
|
+
SnakeUnknownRelationship,
|
|
18
|
+
SnakeUnsupportedFeature,
|
|
19
|
+
)
|
|
20
|
+
from snakeorm.metadata import (
|
|
21
|
+
SnakeRelationshipKind,
|
|
22
|
+
SnakeRelationshipInfo,
|
|
23
|
+
SnakeTableInfo,
|
|
24
|
+
)
|
|
25
|
+
from snakeorm.sql.refs import qualified
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class JoinPlan:
|
|
29
|
+
"""Resolves a set of relationship paths against the graph → aliases + JOIN clauses."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
root: SnakeTableInfo,
|
|
34
|
+
paths: Iterable[tuple[str, ...]],
|
|
35
|
+
dialect: SnakeDialect,
|
|
36
|
+
resolver: SnakeRelationshipResolver,
|
|
37
|
+
relationship_paths: Iterable[tuple[str, ...]] = (),
|
|
38
|
+
explicit_joins: Iterable[tuple[tuple[str, ...], bool]] = (),
|
|
39
|
+
) -> None:
|
|
40
|
+
self._dialect = dialect
|
|
41
|
+
self._resolver = resolver
|
|
42
|
+
self._alias: dict[tuple[str, ...], str] = {(): "t0"}
|
|
43
|
+
self._table: dict[tuple[str, ...], SnakeTableInfo] = {(): root}
|
|
44
|
+
self._joins: list[str] = []
|
|
45
|
+
|
|
46
|
+
# FILTER/order prefixes: paths ending in a column → INNER JOIN.
|
|
47
|
+
filter_prefixes: set[tuple[str, ...]] = set()
|
|
48
|
+
for path in paths:
|
|
49
|
+
steps = path[:-1] # the last element is the column, not a relationship
|
|
50
|
+
for length in range(1, len(steps) + 1):
|
|
51
|
+
filter_prefixes.add(steps[:length])
|
|
52
|
+
# INCLUDE prefixes: these are relationships to load → LEFT JOIN (does not lose rows with a
|
|
53
|
+
# null FK).
|
|
54
|
+
include_prefixes: set[tuple[str, ...]] = set()
|
|
55
|
+
for path in relationship_paths:
|
|
56
|
+
for length in range(1, len(path) + 1):
|
|
57
|
+
include_prefixes.add(path[:length])
|
|
58
|
+
# EXPLICIT JOINs (`.join()`): a to-many IS allowed (it multiplies rows on purpose) and its
|
|
59
|
+
# `how` overrides the inference. It is the ONLY route by which a to-many gets joined.
|
|
60
|
+
explicit: dict[tuple[str, ...], bool] = dict(explicit_joins)
|
|
61
|
+
# Sort by length (parent before child) and then lexicographically (deterministic).
|
|
62
|
+
every = filter_prefixes | include_prefixes | set(explicit)
|
|
63
|
+
for prefix in sorted(every, key=lambda p: (len(p), p)):
|
|
64
|
+
if prefix in explicit:
|
|
65
|
+
# The explicit `how` wins; the to-many is allowed (it is what was asked for).
|
|
66
|
+
self._add_join(prefix, left=explicit[prefix], allow_to_many=True)
|
|
67
|
+
continue
|
|
68
|
+
# LEFT only if it is a pure include; if it is also filtered, INNER (the filter already
|
|
69
|
+
# demands a match).
|
|
70
|
+
left = prefix in include_prefixes and prefix not in filter_prefixes
|
|
71
|
+
self._add_join(prefix, left=left)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def root_alias(self) -> str:
|
|
75
|
+
"""Alias of the query's root table."""
|
|
76
|
+
return "t0"
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def has_joins(self) -> bool:
|
|
80
|
+
"""Tells whether the plan generated any JOIN (there is relationship navigation)."""
|
|
81
|
+
return bool(self._joins)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def joins(self) -> tuple[str, ...]:
|
|
85
|
+
"""The JOIN clauses in parent-to-child order."""
|
|
86
|
+
return tuple(self._joins)
|
|
87
|
+
|
|
88
|
+
def alias_for(self, prefix: tuple[str, ...]) -> str:
|
|
89
|
+
"""Alias of the table reached by a relationship prefix (`()` = root)."""
|
|
90
|
+
return self._alias[prefix]
|
|
91
|
+
|
|
92
|
+
def table_for(self, prefix: tuple[str, ...]) -> SnakeTableInfo:
|
|
93
|
+
"""Table reached by a relationship prefix (`()` = root). For the SELECT of includes."""
|
|
94
|
+
return self._table[prefix]
|
|
95
|
+
|
|
96
|
+
def _add_join(
|
|
97
|
+
self, prefix: tuple[str, ...], left: bool = False, allow_to_many: bool = False
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Registers the prefix's JOIN leaning on its parent (already present thanks to the order).
|
|
100
|
+
|
|
101
|
+
A to-many is only joined with `allow_to_many` (an explicit `.join()`): in any other case
|
|
102
|
+
duplicating rows would be a silent bug, so it is rejected.
|
|
103
|
+
"""
|
|
104
|
+
parent = prefix[:-1]
|
|
105
|
+
relationship = self._relationship(self._table[parent], prefix[-1])
|
|
106
|
+
if relationship.kind is SnakeRelationshipKind.TO_MANY and not allow_to_many:
|
|
107
|
+
# A to-many would duplicate rows and its ON would go the other way round (the FK lives
|
|
108
|
+
# in the child). The type net already forbids it; this is the runtime one.
|
|
109
|
+
raise SnakeUnsupportedFeature(
|
|
110
|
+
f"'{prefix[-1]}' is a to-many relationship and changes the number of rows. "
|
|
111
|
+
f"Use <Model>.{prefix[-1]}.any(...) to filter by existence, "
|
|
112
|
+
f"or .join(...) if you want the child rows."
|
|
113
|
+
)
|
|
114
|
+
# Through the RELATIONSHIP, not through its class name. `.target` is the class name, and
|
|
115
|
+
# the qualified name the linker computed —`relationship.target_table`— sits on this very
|
|
116
|
+
# object; asking by name went through the index `register()` overwrites in silence, so two
|
|
117
|
+
# apps with their own `Customer` in one process produced a JOIN onto the wrong table. Valid
|
|
118
|
+
# SQL, real rows, no warning. `resolve_relationship` already preferred the qualified name
|
|
119
|
+
# and its docstring already said it existed "so that fixing the wrong target is ONE change
|
|
120
|
+
# and not twelve copies" — twelve places used it and these six did not.
|
|
121
|
+
target, _ = self._resolver.resolve_relationship(relationship)
|
|
122
|
+
if target is None:
|
|
123
|
+
raise SnakeRegistryError(
|
|
124
|
+
f"The target '{relationship.target}' of relationship "
|
|
125
|
+
f"'{prefix[-1]}' is not registered."
|
|
126
|
+
)
|
|
127
|
+
alias = f"t{len(self._alias)}"
|
|
128
|
+
self._alias[prefix] = alias
|
|
129
|
+
self._table[prefix] = target
|
|
130
|
+
|
|
131
|
+
quote = self._dialect.quote_ident
|
|
132
|
+
parent_alias = self._alias[parent]
|
|
133
|
+
if relationship.kind is SnakeRelationshipKind.TO_MANY:
|
|
134
|
+
# In a to-many the FK lives in the CHILD, so the ON is written the other way round:
|
|
135
|
+
# `child.fk = parent.pk`. Its pairs are (child_column, parent_column), precisely so the
|
|
136
|
+
# direction is not flipped.
|
|
137
|
+
on = " AND ".join(
|
|
138
|
+
f"{alias}.{quote(child_col)} = {parent_alias}.{quote(parent_col)}"
|
|
139
|
+
for child_col, parent_col in relationship.foreign_key.pairs
|
|
140
|
+
)
|
|
141
|
+
else:
|
|
142
|
+
on = " AND ".join(
|
|
143
|
+
f"{parent_alias}.{quote(local)} = {alias}.{quote(remote)}"
|
|
144
|
+
for local, remote in relationship.foreign_key.pairs
|
|
145
|
+
)
|
|
146
|
+
table_ref = qualified(target.schema, target.name, self._dialect)
|
|
147
|
+
keyword = "LEFT JOIN" if left else "JOIN"
|
|
148
|
+
self._joins.append(f"{keyword} {table_ref} AS {alias} ON {on}")
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _relationship(table: SnakeTableInfo, name: str) -> SnakeRelationshipInfo:
|
|
152
|
+
"""Looks the relationship up by name on the table; fails clearly if it does not exist."""
|
|
153
|
+
for relationship in table.relationships:
|
|
154
|
+
if relationship.name == name:
|
|
155
|
+
return relationship
|
|
156
|
+
raise SnakeUnknownRelationship(
|
|
157
|
+
f"'{table.name}' does not have a relationship named '{name}'."
|
|
158
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Rewriting a bulk write with a deep WHERE into `pk IN (subquery)`.
|
|
2
|
+
|
|
3
|
+
An UPDATE/DELETE whose filter navigates a relationship does not fit in a flat WHERE. Instead of
|
|
4
|
+
`... FROM` (Postgres jargon, not portable), it is rewritten into a subquery that projects the PK
|
|
5
|
+
using the `JoinPlan` of the deep paths. A composite PK uses a row constructor `(a, b) IN (...)`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from snakeorm.dialects import SnakeDialect
|
|
11
|
+
from snakeorm.expressions import SnakeCondition
|
|
12
|
+
from snakeorm.metadata import SnakeTableInfo
|
|
13
|
+
from snakeorm.sql.condition import emit_condition_into
|
|
14
|
+
from snakeorm.sql.joins import JoinPlan
|
|
15
|
+
from snakeorm.sql.refs import qualified
|
|
16
|
+
from snakeorm.sql.value import Correlation, ExistsAliases
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def emit_pk_subquery(
|
|
20
|
+
table: SnakeTableInfo,
|
|
21
|
+
dialect: SnakeDialect,
|
|
22
|
+
plan: JoinPlan,
|
|
23
|
+
where: SnakeCondition,
|
|
24
|
+
params: list[object],
|
|
25
|
+
) -> str:
|
|
26
|
+
"""Emits `SELECT t0."pk"... FROM <table> AS t0 [JOINs] WHERE <deep cond>` (the PK ONLY).
|
|
27
|
+
|
|
28
|
+
Reuses the `JoinPlan` and `emit_condition_into` with alias qualification. The WHERE params are
|
|
29
|
+
accumulated (numbering continues from the UPDATE's SET, positional).
|
|
30
|
+
"""
|
|
31
|
+
quote = dialect.quote_ident
|
|
32
|
+
root_alias = plan.root_alias
|
|
33
|
+
pk_columns = ", ".join(
|
|
34
|
+
f"{root_alias}.{quote(column.name)}" for column in table.primary_key.columns
|
|
35
|
+
)
|
|
36
|
+
table_ref = f"{qualified(table.schema, table.name, dialect)} AS {root_alias}"
|
|
37
|
+
sql = f"SELECT {pk_columns} FROM {table_ref}"
|
|
38
|
+
if plan.has_joins:
|
|
39
|
+
sql = f"{sql} {' '.join(plan.joins)}"
|
|
40
|
+
correlate = Correlation(parent_ref=root_alias, aliases=ExistsAliases())
|
|
41
|
+
where_sql = emit_condition_into(where, dialect, params, plan.alias_for, correlate)
|
|
42
|
+
return f"{sql} WHERE {where_sql}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def emit_pk_in(table: SnakeTableInfo, dialect: SnakeDialect, subquery: str) -> str:
|
|
46
|
+
"""Emits `<pk> IN (subquery)` over the base table (NO alias).
|
|
47
|
+
|
|
48
|
+
Simple PK → `"id" IN (...)`; composite → row constructor `("a","b") IN (...)`. Unqualified: the
|
|
49
|
+
outer UPDATE/DELETE operates on the base table, which carries no alias.
|
|
50
|
+
"""
|
|
51
|
+
quote = dialect.quote_ident
|
|
52
|
+
columns = [quote(column.name) for column in table.primary_key.columns]
|
|
53
|
+
left = columns[0] if len(columns) == 1 else f"({', '.join(columns)})"
|
|
54
|
+
return f"{left} IN ({subquery})"
|
snakeorm/sql/refs.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""How a table is named in the SQL: `"schema"."table"` or just `"table"`.
|
|
2
|
+
|
|
3
|
+
Centralised because twenty-five call sites build it. SQLite has no named schemas (a `"public"."x"`
|
|
4
|
+
gives `unknown database "public"`), so qualifying per engine has to live in ONE place.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from snakeorm.dialects import SnakeDialect
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def qualified(schema: str, name: str, dialect: SnakeDialect) -> str:
|
|
13
|
+
"""Table reference, qualified by schema ONLY if the engine has schemas.
|
|
14
|
+
|
|
15
|
+
On an engine without schemas, qualifying is not cosmetic: it is invalid SQL.
|
|
16
|
+
"""
|
|
17
|
+
if dialect.supports_schemas:
|
|
18
|
+
return f"{dialect.quote_ident(schema)}.{dialect.quote_ident(name)}"
|
|
19
|
+
return dialect.quote_ident(name)
|
snakeorm/sql/resolver.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""What the SQL emitter needs from a registry: resolving a relationship to its target.
|
|
2
|
+
|
|
3
|
+
A Protocol and not `SnakeRegistry` itself, for the reason the whole `sql/` package exists: emitting
|
|
4
|
+
must not depend on the thing that stores models, only on the one question it has to ask. It also
|
|
5
|
+
keeps the tests honest — a double can answer this without standing up a registry, which is what lets
|
|
6
|
+
the negative test re-run the OLD resolution and demand the wrong answer.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
from snakeorm.metadata import SnakeRelationshipInfo, SnakeTableInfo
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SnakeRelationshipResolver(Protocol):
|
|
17
|
+
"""Answers WHERE a relationship points, unambiguously."""
|
|
18
|
+
|
|
19
|
+
def resolve_relationship(
|
|
20
|
+
self, relationship: SnakeRelationshipInfo
|
|
21
|
+
) -> tuple[SnakeTableInfo | None, type | None]:
|
|
22
|
+
"""The target's `(table, class)`, preferring the qualified name over the class name.
|
|
23
|
+
|
|
24
|
+
The class name is NOT unique —two apps can each declare a `Customer`— and the index keyed by
|
|
25
|
+
it is kept by whichever registered last. The qualified name is protected by the collision
|
|
26
|
+
guard, which is why it is the one that answers.
|
|
27
|
+
"""
|
|
28
|
+
...
|
snakeorm/sql/select.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Emission of a parametrised SELECT: orchestrates the grammar, delegates engine bits to the dialect.
|
|
2
|
+
|
|
3
|
+
Params in a single list (WHERE before LIMIT/OFFSET) for continuous numbering. Without a `plan` →
|
|
4
|
+
single-table, no alias (backwards compatible). With a `plan` → deep navigation: root aliased as t0,
|
|
5
|
+
JOINs and qualified columns. The result is still the root (the JOINs only filter/order).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
|
|
12
|
+
from snakeorm.dialects import SnakeDialect
|
|
13
|
+
from snakeorm.core.exceptions import SnakeUnsupportedFeature
|
|
14
|
+
from snakeorm.expressions import SnakeCondition, SnakeLock, SnakeOrder
|
|
15
|
+
from snakeorm.metadata import SnakeColumnInfo, SnakeTableInfo
|
|
16
|
+
from snakeorm.sql.condition import emit_condition_into
|
|
17
|
+
from snakeorm.sql.joins import JoinPlan
|
|
18
|
+
from snakeorm.sql.refs import qualified
|
|
19
|
+
from snakeorm.sql.value import (
|
|
20
|
+
Correlation,
|
|
21
|
+
ExistsAliases,
|
|
22
|
+
Qualify,
|
|
23
|
+
emit_order_key,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _correlation(
|
|
28
|
+
table: SnakeTableInfo, dialect: SnakeDialect, plan: JoinPlan | None
|
|
29
|
+
) -> Correlation:
|
|
30
|
+
"""Correlation context for the WHERE's subqueries (how to reference the root).
|
|
31
|
+
|
|
32
|
+
With JOINs the root has an alias (`t0`); without them, the table name (the FROM's implicit
|
|
33
|
+
correlation).
|
|
34
|
+
"""
|
|
35
|
+
parent_ref = (
|
|
36
|
+
plan.root_alias if plan is not None else dialect.quote_ident(table.name)
|
|
37
|
+
)
|
|
38
|
+
return Correlation(parent_ref=parent_ref, aliases=ExistsAliases())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def selected_columns(
|
|
42
|
+
table: SnakeTableInfo, columns: frozenset[str] | None
|
|
43
|
+
) -> tuple[SnakeColumnInfo, ...]:
|
|
44
|
+
"""The columns to project, in the TABLE's declaration order and never the caller's.
|
|
45
|
+
|
|
46
|
+
The order is not a detail: hydration walks the same declaration to build its plan, so a
|
|
47
|
+
projection sorted by what the user typed would line the row up against the wrong attributes. Two
|
|
48
|
+
orders is two chances to be wrong, and one of them is silent.
|
|
49
|
+
|
|
50
|
+
`None` means every column, which is what every path but `only()`/`defer()` passes.
|
|
51
|
+
"""
|
|
52
|
+
if columns is None:
|
|
53
|
+
return table.columns
|
|
54
|
+
return tuple(column for column in table.columns if column.name in columns)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def emit_select(
|
|
58
|
+
table: SnakeTableInfo,
|
|
59
|
+
dialect: SnakeDialect,
|
|
60
|
+
where: SnakeCondition | None = None,
|
|
61
|
+
order_by: tuple[SnakeOrder, ...] = (),
|
|
62
|
+
limit: int | None = None,
|
|
63
|
+
offset: int | None = None,
|
|
64
|
+
plan: JoinPlan | None = None,
|
|
65
|
+
distinct: bool = False,
|
|
66
|
+
lock: SnakeLock | None = None,
|
|
67
|
+
columns: frozenset[str] | None = None,
|
|
68
|
+
) -> tuple[str, tuple[object, ...]]:
|
|
69
|
+
"""Emits a parametrised `SELECT [DISTINCT] ... FROM ... [JOINs] [WHERE] [ORDER BY] [LIMIT/OFFSET]`.
|
|
70
|
+
|
|
71
|
+
`columns` is the subset `only()`/`defer()` asked for, or `None` for every column. The order is
|
|
72
|
+
the TABLE's, never the caller's: the hydration plan is built by walking the same declaration, so
|
|
73
|
+
two orders would be two chances to line the row up wrong.
|
|
74
|
+
"""
|
|
75
|
+
params: list[object] = []
|
|
76
|
+
qualify: Qualify | None = plan.alias_for if plan is not None else None
|
|
77
|
+
root_alias = plan.root_alias if plan is not None else None
|
|
78
|
+
|
|
79
|
+
projected = selected_columns(table, columns)
|
|
80
|
+
columns_sql = ", ".join(
|
|
81
|
+
_column_ref(root_alias, column.name, dialect) for column in projected
|
|
82
|
+
)
|
|
83
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
84
|
+
if root_alias is not None:
|
|
85
|
+
table_ref = f"{table_ref} AS {root_alias}"
|
|
86
|
+
keyword = "SELECT DISTINCT" if distinct else "SELECT"
|
|
87
|
+
sql = f"{keyword} {columns_sql} FROM {table_ref}"
|
|
88
|
+
|
|
89
|
+
if plan is not None and plan.has_joins:
|
|
90
|
+
sql = f"{sql} {' '.join(plan.joins)}"
|
|
91
|
+
|
|
92
|
+
if where is not None:
|
|
93
|
+
correlate = _correlation(table, dialect, plan)
|
|
94
|
+
sql = f"{sql} WHERE {emit_condition_into(where, dialect, params, qualify, correlate)}"
|
|
95
|
+
|
|
96
|
+
if order_by:
|
|
97
|
+
keys = ", ".join(
|
|
98
|
+
emit_order_key(key, dialect, params, qualify) for key in order_by
|
|
99
|
+
)
|
|
100
|
+
sql = f"{sql} ORDER BY {keys}"
|
|
101
|
+
|
|
102
|
+
clause = dialect.limit_offset(limit, offset, params)
|
|
103
|
+
if clause:
|
|
104
|
+
sql = f"{sql} {clause}"
|
|
105
|
+
|
|
106
|
+
# The lock goes AT THE END of the grammar, after LIMIT/OFFSET: it locks the rows the SELECT
|
|
107
|
+
# really returns, not the ones it looked at to get to them.
|
|
108
|
+
if lock is not None and not dialect.supports_row_locking:
|
|
109
|
+
# It is cut off at COMPILE time (before touching the DB) and the alternative is given,
|
|
110
|
+
# rather than emitting a `FOR UPDATE` the engine does not understand so that it blows up.
|
|
111
|
+
raise SnakeUnsupportedFeature(
|
|
112
|
+
"This engine does not support row locking (`SELECT ... FOR UPDATE`): SQLite locks the "
|
|
113
|
+
"whole file, not individual rows. Use an exclusive transaction on this engine, or "
|
|
114
|
+
"PostgreSQL if you need to reserve specific rows."
|
|
115
|
+
)
|
|
116
|
+
if lock is not None:
|
|
117
|
+
sql = f"{sql} {_LOCK_CLAUSES[lock]}"
|
|
118
|
+
|
|
119
|
+
return sql, tuple(params)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# Lock clause per mode. `WAIT` is the plain `FOR UPDATE` (waiting is its default behaviour), so it
|
|
123
|
+
# carries no suffix.
|
|
124
|
+
_LOCK_CLAUSES: dict[SnakeLock, str] = {
|
|
125
|
+
SnakeLock.WAIT: "FOR UPDATE",
|
|
126
|
+
SnakeLock.NOWAIT: "FOR UPDATE NOWAIT",
|
|
127
|
+
SnakeLock.SKIP_LOCKED: "FOR UPDATE SKIP LOCKED",
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def emit_select_with_includes(
|
|
132
|
+
dialect: SnakeDialect,
|
|
133
|
+
segments: Sequence[tuple[tuple[str, ...], SnakeTableInfo]],
|
|
134
|
+
plan: JoinPlan,
|
|
135
|
+
where: SnakeCondition | None = None,
|
|
136
|
+
order_by: tuple[SnakeOrder, ...] = (),
|
|
137
|
+
limit: int | None = None,
|
|
138
|
+
offset: int | None = None,
|
|
139
|
+
) -> tuple[str, tuple[object, ...]]:
|
|
140
|
+
"""Emits the SELECT loading the root + included relationships (columns qualified by alias).
|
|
141
|
+
|
|
142
|
+
`segments` in order: root `((), table)` and then each relationship `(prefix, table)`. All their
|
|
143
|
+
columns enter in that order, so the session slices each row by segment and rebuilds the object.
|
|
144
|
+
"""
|
|
145
|
+
params: list[object] = []
|
|
146
|
+
qualify: Qualify = plan.alias_for
|
|
147
|
+
root = segments[0][1]
|
|
148
|
+
|
|
149
|
+
columns = ", ".join(
|
|
150
|
+
f"{plan.alias_for(prefix)}.{dialect.quote_ident(column.name)}"
|
|
151
|
+
for prefix, segment_table in segments
|
|
152
|
+
for column in segment_table.columns
|
|
153
|
+
)
|
|
154
|
+
table_ref = qualified(root.schema, root.name, dialect)
|
|
155
|
+
sql = f"SELECT {columns} FROM {table_ref} AS {plan.root_alias}"
|
|
156
|
+
if plan.has_joins:
|
|
157
|
+
sql = f"{sql} {' '.join(plan.joins)}"
|
|
158
|
+
if where is not None:
|
|
159
|
+
correlate = _correlation(root, dialect, plan)
|
|
160
|
+
sql = f"{sql} WHERE {emit_condition_into(where, dialect, params, qualify, correlate)}"
|
|
161
|
+
if order_by:
|
|
162
|
+
keys = ", ".join(
|
|
163
|
+
emit_order_key(key, dialect, params, qualify) for key in order_by
|
|
164
|
+
)
|
|
165
|
+
sql = f"{sql} ORDER BY {keys}"
|
|
166
|
+
clause = dialect.limit_offset(limit, offset, params)
|
|
167
|
+
if clause:
|
|
168
|
+
sql = f"{sql} {clause}"
|
|
169
|
+
return sql, tuple(params)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _column_ref(root_alias: str | None, name: str, dialect: SnakeDialect) -> str:
|
|
173
|
+
"""Quotes a column of the root table, with an alias if the query carries JOINs."""
|
|
174
|
+
column = dialect.quote_ident(name)
|
|
175
|
+
return f"{root_alias}.{column}" if root_alias is not None else column
|
snakeorm/sql/update.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Emission of a parametrised UPDATE: `UPDATE ... SET ... [WHERE ...]`.
|
|
2
|
+
|
|
3
|
+
The SET consumes the first params; the WHERE continues the numbering over the same list
|
|
4
|
+
(`emit_condition_into`). `where=None` affects the whole table: the guard-rail lives in query/session.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
|
|
11
|
+
from snakeorm.dialects import SnakeDialect
|
|
12
|
+
from snakeorm.core.exceptions import SnakeEmitError
|
|
13
|
+
from snakeorm.expressions import SnakeCondition, SnakeValue
|
|
14
|
+
from snakeorm.metadata import SnakeTableInfo
|
|
15
|
+
from snakeorm.sql.condition import emit_condition_into
|
|
16
|
+
from snakeorm.sql.joins import JoinPlan
|
|
17
|
+
from snakeorm.sql.pk_subquery import emit_pk_in, emit_pk_subquery
|
|
18
|
+
from snakeorm.sql.refs import qualified
|
|
19
|
+
from snakeorm.sql.value import emit_operand, emit_value
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _emit_set(
|
|
23
|
+
values: Mapping[str, object], dialect: SnakeDialect, params: list[object]
|
|
24
|
+
) -> str:
|
|
25
|
+
"""Emits the SET's `col = <value>` assignments, accumulating literals into `params`.
|
|
26
|
+
|
|
27
|
+
A `SnakeValue` (e.g. `views + 1`) is emitted as an unqualified expression; a literal, as a
|
|
28
|
+
placeholder. This is what enables `SET views = views + 1`.
|
|
29
|
+
"""
|
|
30
|
+
assignments = []
|
|
31
|
+
for column, value in values.items():
|
|
32
|
+
if isinstance(value, SnakeValue):
|
|
33
|
+
rendered = emit_value(value, dialect, params, None)
|
|
34
|
+
else:
|
|
35
|
+
rendered = emit_operand(value, dialect, params)
|
|
36
|
+
assignments.append(f"{dialect.quote_ident(column)} = {rendered}")
|
|
37
|
+
return ", ".join(assignments)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def emit_update(
|
|
41
|
+
table: SnakeTableInfo,
|
|
42
|
+
dialect: SnakeDialect,
|
|
43
|
+
values: Mapping[str, object],
|
|
44
|
+
where: SnakeCondition | None = None,
|
|
45
|
+
) -> tuple[str, tuple[object, ...]]:
|
|
46
|
+
"""Emits `UPDATE ... SET ... [WHERE ...]` with params apart (SET first, then WHERE)."""
|
|
47
|
+
if not values:
|
|
48
|
+
raise SnakeEmitError("An UPDATE needs at least one column in the SET")
|
|
49
|
+
|
|
50
|
+
params: list[object] = []
|
|
51
|
+
set_sql = _emit_set(values, dialect, params)
|
|
52
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
53
|
+
sql = f"UPDATE {table_ref} SET {set_sql}"
|
|
54
|
+
|
|
55
|
+
if where is not None:
|
|
56
|
+
where_sql = emit_condition_into(where, dialect, params)
|
|
57
|
+
sql = f"{sql} WHERE {where_sql}"
|
|
58
|
+
|
|
59
|
+
return sql, tuple(params)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def emit_update_pk_in_subquery(
|
|
63
|
+
table: SnakeTableInfo,
|
|
64
|
+
dialect: SnakeDialect,
|
|
65
|
+
values: Mapping[str, object],
|
|
66
|
+
plan: JoinPlan,
|
|
67
|
+
where: SnakeCondition,
|
|
68
|
+
) -> tuple[str, tuple[object, ...]]:
|
|
69
|
+
"""Emits `UPDATE ... SET ... WHERE <pk> IN (SELECT <pk> FROM ... [JOINs] WHERE <deep cond>)`.
|
|
70
|
+
|
|
71
|
+
For a WHERE that navigates a relationship (avoids `UPDATE ... FROM`). The SET consumes the first
|
|
72
|
+
params and the subquery continues from there; the SET still targets the base table.
|
|
73
|
+
"""
|
|
74
|
+
if not values:
|
|
75
|
+
raise SnakeEmitError("An UPDATE needs at least one column in the SET")
|
|
76
|
+
|
|
77
|
+
params: list[object] = []
|
|
78
|
+
set_sql = _emit_set(values, dialect, params)
|
|
79
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
80
|
+
subquery = emit_pk_subquery(table, dialect, plan, where, params)
|
|
81
|
+
pk_in = emit_pk_in(table, dialect, subquery)
|
|
82
|
+
return f"UPDATE {table_ref} SET {set_sql} WHERE {pk_in}", tuple(params)
|