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,1172 @@
|
|
|
1
|
+
"""DDL generation from the metadata: SnakeTableInfo -> CREATE TABLE.
|
|
2
|
+
|
|
3
|
+
Each column gets its SQL type from the dialect (`map_type`): engine-specific DDL without
|
|
4
|
+
the metadata knowing any SQL. DDL is NOT parameterized (it describes the schema, not
|
|
5
|
+
user values).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from dataclasses import replace
|
|
12
|
+
from typing import TYPE_CHECKING, cast
|
|
13
|
+
|
|
14
|
+
from snakeorm.dialects import SnakeDialect
|
|
15
|
+
from snakeorm.dialects.capabilities import AlterColumnStyle, Cap, CommentStyle, Nope
|
|
16
|
+
from snakeorm.dialects.postgres import PostgresDialect
|
|
17
|
+
from snakeorm.core.exceptions import SnakeMigrationError, SnakeUnsupportedFeature
|
|
18
|
+
from snakeorm.metadata.type_params import SnakeStrParams
|
|
19
|
+
from snakeorm.metadata import (
|
|
20
|
+
SnakeCheckInfo,
|
|
21
|
+
SnakeColumnInfo,
|
|
22
|
+
SnakeFkAction,
|
|
23
|
+
SnakeIndexInfo,
|
|
24
|
+
SnakeIndexMethod,
|
|
25
|
+
SnakeRelationshipKind,
|
|
26
|
+
SnakeRelationshipInfo,
|
|
27
|
+
SnakeRoutineInfo,
|
|
28
|
+
SnakeTableInfo,
|
|
29
|
+
SnakeTriggerInfo,
|
|
30
|
+
)
|
|
31
|
+
from snakeorm.sql.condition import emit_condition_ddl, inline_params
|
|
32
|
+
from snakeorm.sql.refs import qualified
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from snakeorm.query import SnakeCompound, SnakeQuery
|
|
36
|
+
|
|
37
|
+
_CANONICAL_DIALECT: SnakeDialect = PostgresDialect()
|
|
38
|
+
"""The dialect a view FINGERPRINT is computed with. Nothing it emits is EVER executed.
|
|
39
|
+
|
|
40
|
+
It is Postgres because that is the reference engine of the project, and the choice does
|
|
41
|
+
not matter as long as it is FIXED: the only thing that counts about a fingerprint is
|
|
42
|
+
that it be the same on every machine. If it depended on the local engine, two devs with
|
|
43
|
+
different databases would generate different migrations out of the same model.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def foreign_key_name(table: SnakeTableInfo, relationship: SnakeRelationshipInfo) -> str:
|
|
48
|
+
"""Deterministic name for the FK constraint (the same one in ADD and DROP).
|
|
49
|
+
|
|
50
|
+
PUBLIC, which it did not need to be until `realize` had to NAME the constraint that stops a
|
|
51
|
+
column from being dropped. It is DERIVED from the metadata and never read back out of the
|
|
52
|
+
server's catalogue, so the plan can name it without a connection — the same property that lets
|
|
53
|
+
`emit_drop_foreign_key` find again what `emit_add_foreign_key` wrote.
|
|
54
|
+
"""
|
|
55
|
+
return f"fk_{table.name}_{relationship.name}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _uq_name(table: SnakeTableInfo, *column_names: str) -> str:
|
|
59
|
+
"""Deterministic name for a UNIQUE constraint (the SAME when adding and dropping).
|
|
60
|
+
|
|
61
|
+
One single function for the two ways of asking for uniqueness
|
|
62
|
+
(`snake_column(unique=True)` and `SnakeIndex(..., unique=True)`): they produce the
|
|
63
|
+
SAME database object, so adding and dropping have to agree.
|
|
64
|
+
"""
|
|
65
|
+
return f"uq_{table.name}_{'_'.join(column_names)}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _check_constraint_def(
|
|
69
|
+
table: SnakeTableInfo, check: SnakeCheckInfo, dialect: SnakeDialect
|
|
70
|
+
) -> str:
|
|
71
|
+
"""`CONSTRAINT "ck_..." CHECK (...)` clause, emitted WITHOUT placeholders."""
|
|
72
|
+
constraint = dialect.quote_ident(check.resolved_name(table.name))
|
|
73
|
+
return f"CONSTRAINT {constraint} CHECK ({emit_condition_ddl(check.condition, dialect)})"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def emit_add_check(
|
|
77
|
+
table: SnakeTableInfo, check: SnakeCheckInfo, dialect: SnakeDialect
|
|
78
|
+
) -> str:
|
|
79
|
+
"""Emits `ALTER TABLE ... ADD CONSTRAINT ... CHECK (...)` for an existing table."""
|
|
80
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
81
|
+
return f"ALTER TABLE {table_ref} ADD {_check_constraint_def(table, check, dialect)}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def emit_drop_check(
|
|
85
|
+
table: SnakeTableInfo, check: SnakeCheckInfo, dialect: SnakeDialect
|
|
86
|
+
) -> str:
|
|
87
|
+
"""The reverse: `ALTER TABLE ... DROP CONSTRAINT ...`, by the SAME resolved name."""
|
|
88
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
89
|
+
constraint = dialect.quote_ident(check.resolved_name(table.name))
|
|
90
|
+
return f"ALTER TABLE {table_ref} DROP CONSTRAINT {constraint}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _unique_constraint_def(
|
|
94
|
+
table: SnakeTableInfo,
|
|
95
|
+
columns: tuple[str, ...],
|
|
96
|
+
dialect: SnakeDialect,
|
|
97
|
+
name: str | None = None,
|
|
98
|
+
) -> str:
|
|
99
|
+
"""`CONSTRAINT "uq_..." UNIQUE (...)` clause, ALWAYS with an explicit name."""
|
|
100
|
+
constraint = dialect.quote_ident(name or _uq_name(table, *columns))
|
|
101
|
+
quoted = ", ".join(dialect.quote_ident(column) for column in columns)
|
|
102
|
+
return f"CONSTRAINT {constraint} UNIQUE ({quoted})"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def inlined_foreign_keys(
|
|
106
|
+
table: SnakeTableInfo, dialect: SnakeDialect
|
|
107
|
+
) -> tuple[SnakeRelationshipInfo, ...]:
|
|
108
|
+
"""The FKs this engine forces you to declare INSIDE the `CREATE TABLE`.
|
|
109
|
+
|
|
110
|
+
With `supports_add_constraint` (Postgres) none of them: they go at the end. Without
|
|
111
|
+
it (SQLite) the `CREATE TABLE` is the only window (`ADD CONSTRAINT` is a syntax
|
|
112
|
+
error). A resolved `target_table` is required (the name of the table, not of the
|
|
113
|
+
model); unresolved it is not inlined, and `realize` catches that. It exists as a
|
|
114
|
+
function of its own (not an `if` inside the emitter) so that `realize` knows exactly
|
|
115
|
+
which ones were inlined and can drop their redundant `AddForeignKey` without falling
|
|
116
|
+
out of sync.
|
|
117
|
+
"""
|
|
118
|
+
if dialect.supports_add_constraint:
|
|
119
|
+
return ()
|
|
120
|
+
return tuple(
|
|
121
|
+
relationship
|
|
122
|
+
for relationship in table.relationships
|
|
123
|
+
if relationship.kind is SnakeRelationshipKind.TO_ONE
|
|
124
|
+
and relationship.target_table
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _inline_foreign_key_def(
|
|
129
|
+
table: SnakeTableInfo, relationship: SnakeRelationshipInfo, dialect: SnakeDialect
|
|
130
|
+
) -> str:
|
|
131
|
+
"""`CONSTRAINT "fk_..." FOREIGN KEY (...) REFERENCES ...` clause for `CREATE TABLE`.
|
|
132
|
+
|
|
133
|
+
Same name and same actions as the `ALTER` version: the key is the same, only the way
|
|
134
|
+
it gets in changes.
|
|
135
|
+
"""
|
|
136
|
+
quote = dialect.quote_ident
|
|
137
|
+
fk = relationship.foreign_key
|
|
138
|
+
schema, _, name = relationship.target_table.rpartition(".")
|
|
139
|
+
local = ", ".join(quote(local_col) for local_col, _ in fk.pairs)
|
|
140
|
+
remote = ", ".join(quote(remote_col) for _, remote_col in fk.pairs)
|
|
141
|
+
sql = (
|
|
142
|
+
f"CONSTRAINT {quote(foreign_key_name(table, relationship))} FOREIGN KEY ({local}) "
|
|
143
|
+
f"REFERENCES {qualified(schema, name, dialect)} ({remote})"
|
|
144
|
+
)
|
|
145
|
+
if fk.on_delete is not SnakeFkAction.NO_ACTION:
|
|
146
|
+
sql = f"{sql} ON DELETE {fk.on_delete.value}"
|
|
147
|
+
if fk.on_update is not SnakeFkAction.NO_ACTION:
|
|
148
|
+
sql = f"{sql} ON UPDATE {fk.on_update.value}"
|
|
149
|
+
return sql
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _guard_text_primary_key(table: SnakeTableInfo, dialect: SnakeDialect) -> None:
|
|
153
|
+
"""Refuse a key column whose type has no length, on an engine that needs one.
|
|
154
|
+
|
|
155
|
+
MySQL and MariaDB answer error 1170 to a `TEXT` column in a primary key and the whole
|
|
156
|
+
`CREATE TABLE` dies. The ORM does NOT pick a length to make it work: inventing a `VARCHAR(255)`
|
|
157
|
+
nobody asked for would truncate data the day a value outgrew it, and it would do so silently,
|
|
158
|
+
which is worse than the refusal.
|
|
159
|
+
|
|
160
|
+
It raises rather than warns because the outcome it prevents is not a degraded table but no table
|
|
161
|
+
at all. The capability answers WHETHER the engine minds; this names WHICH column, which is the
|
|
162
|
+
half a catalogue entry cannot carry.
|
|
163
|
+
"""
|
|
164
|
+
support = dialect.capabilities.support_for(Cap.TEXT_IN_PRIMARY_KEY)
|
|
165
|
+
if not isinstance(support, Nope):
|
|
166
|
+
return
|
|
167
|
+
for column in table.primary_key.columns:
|
|
168
|
+
if column.python_type is not str:
|
|
169
|
+
continue
|
|
170
|
+
# Narrowed to the string params on purpose: `type_params` is a union and only that member
|
|
171
|
+
# carries a length, so asking any other for one would be asking the wrong question.
|
|
172
|
+
params = column.type_params
|
|
173
|
+
if isinstance(params, SnakeStrParams) and params.max_length is not None:
|
|
174
|
+
continue
|
|
175
|
+
raise SnakeUnsupportedFeature(
|
|
176
|
+
f"{type(dialect).__name__} cannot use '{column.name}' in the primary key of "
|
|
177
|
+
f"'{table.name}': {support.reason}. Declare it with `max_length=` — the ORM will not "
|
|
178
|
+
f"choose a length for you, because the one it made up would be the one that truncates."
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def emit_create_table(
|
|
183
|
+
table: SnakeTableInfo, dialect: SnakeDialect, *, as_name: str | None = None
|
|
184
|
+
) -> str:
|
|
185
|
+
"""Emits the `CREATE TABLE` of a compiled table.
|
|
186
|
+
|
|
187
|
+
Unique constraints are emitted with A NAME OF THEIR OWN (not a bare inline
|
|
188
|
+
`UNIQUE`): the name Postgres would make up would not be found afterwards by the
|
|
189
|
+
`DROP CONSTRAINT`. FKs only come in here on engines that cannot add them later (see
|
|
190
|
+
`inlined_foreign_keys`).
|
|
191
|
+
|
|
192
|
+
`as_name` creates it under ANOTHER name while every constraint keeps the one derived
|
|
193
|
+
from `table.name`. It exists for the SQLite rebuild, which has to build the new table
|
|
194
|
+
beside the old one and rename it into place: SQLite stores a constraint's name inside
|
|
195
|
+
the table's own DDL, so a name taken from the temporary one would survive the rename
|
|
196
|
+
and stay in the schema for ever, findable by nothing the metadata can spell.
|
|
197
|
+
"""
|
|
198
|
+
_guard_text_primary_key(table, dialect)
|
|
199
|
+
definitions = [_column_def(column, dialect) for column in table.columns]
|
|
200
|
+
pk_columns = ", ".join(
|
|
201
|
+
dialect.quote_ident(column.name) for column in table.primary_key.columns
|
|
202
|
+
)
|
|
203
|
+
definitions.append(f"PRIMARY KEY ({pk_columns})")
|
|
204
|
+
definitions.extend(
|
|
205
|
+
_unique_constraint_def(table, (column.name,), dialect)
|
|
206
|
+
for column in table.columns
|
|
207
|
+
if column.unique
|
|
208
|
+
)
|
|
209
|
+
definitions.extend(
|
|
210
|
+
_check_constraint_def(table, check, dialect) for check in table.checks
|
|
211
|
+
)
|
|
212
|
+
definitions.extend(
|
|
213
|
+
_inline_foreign_key_def(table, relationship, dialect)
|
|
214
|
+
for relationship in inlined_foreign_keys(table, dialect)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
table_ref = qualified(table.schema, as_name or table.name, dialect)
|
|
218
|
+
create = f"CREATE TABLE {table_ref} ({', '.join(definitions)})"
|
|
219
|
+
if _comments_are_inline(dialect) and table.db_comment is not None:
|
|
220
|
+
# On this engine the table comment is a CLAUSE, so it travels with the CREATE and there is
|
|
221
|
+
# no second statement. `emit_comments` returns nothing here for exactly that reason.
|
|
222
|
+
create += f" COMMENT = {dialect.literal(table.db_comment)}"
|
|
223
|
+
return create
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _comments_are_inline(dialect: SnakeDialect) -> bool:
|
|
227
|
+
"""Whether this engine spells a comment as a CLAUSE rather than a statement of its own."""
|
|
228
|
+
return dialect.syntax.comment_style is CommentStyle.INLINE
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _inline_comment_literal(comment: str | None, dialect: SnakeDialect) -> str:
|
|
232
|
+
"""The text of an inline `COMMENT` clause, with REMOVAL spelled as the empty string.
|
|
233
|
+
|
|
234
|
+
Not `dialect.literal(comment)`, and the difference only shows up on the rollback path.
|
|
235
|
+
`literal(None)` returns `NULL` on every dialect —which is right for `COMMENT ON ... IS NULL`—
|
|
236
|
+
and measured against MariaDB 11.8.8, `ALTER TABLE t COMMENT = NULL` is `ERROR 1064`. This engine
|
|
237
|
+
clears a comment by assigning the empty string, which is also how it stores "no comment": its
|
|
238
|
+
`information_schema` returns `''`, never NULL.
|
|
239
|
+
"""
|
|
240
|
+
return dialect.literal(comment if comment is not None else "")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def emit_table_comment(table: SnakeTableInfo, dialect: SnakeDialect) -> str:
|
|
244
|
+
"""The statement that sets (or clears) a TABLE's comment, in the grammar of the engine.
|
|
245
|
+
|
|
246
|
+
Two spellings for one intention, which is what a dialect is for. Postgres has a statement of its
|
|
247
|
+
own (`COMMENT ON TABLE ... IS ...`, and `IS NULL` removes it); MySQL has a CLAUSE
|
|
248
|
+
(`ALTER TABLE ... COMMENT = ...`, and the empty string removes it). The text goes through
|
|
249
|
+
`dialect.literal` in both, like DEFAULT and CHECK, so a quote inside it breaks nothing.
|
|
250
|
+
|
|
251
|
+
`CommentStyle.UNSUPPORTED` (SQLite) falls into the first branch and never runs: `Cap.COMMENTS`
|
|
252
|
+
is `Nope` there, so `realize` stops the operation in the PLAN — the same arrangement
|
|
253
|
+
`_alter_column_by_alter` already relies on.
|
|
254
|
+
"""
|
|
255
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
256
|
+
if _comments_are_inline(dialect):
|
|
257
|
+
return (
|
|
258
|
+
f"ALTER TABLE {table_ref} "
|
|
259
|
+
f"COMMENT = {_inline_comment_literal(table.db_comment, dialect)}"
|
|
260
|
+
)
|
|
261
|
+
return f"COMMENT ON TABLE {table_ref} IS {dialect.literal(table.db_comment)}"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def emit_column_comment(
|
|
265
|
+
table: SnakeTableInfo, column: SnakeColumnInfo, dialect: SnakeDialect
|
|
266
|
+
) -> str:
|
|
267
|
+
"""The statement that sets (or clears) a COLUMN's comment. This is the degraded half.
|
|
268
|
+
|
|
269
|
+
Postgres writes `COMMENT ON COLUMN ... IS '...'`, which touches one catalogue row and nothing
|
|
270
|
+
else. MySQL has NO statement that changes a column's comment on its own — measured,
|
|
271
|
+
`ALTER COLUMN c COMMENT` is a 1064 and `MODIFY COLUMN c COMMENT` is error 4161 — so the only
|
|
272
|
+
spelling is a `MODIFY COLUMN` carrying the WHOLE definition.
|
|
273
|
+
|
|
274
|
+
That is why `_column_def` is reused rather than a comment clause pasted on: a `MODIFY` deletes
|
|
275
|
+
everything it does not respell, and measured on MariaDB the naive shape turned a
|
|
276
|
+
`NOT NULL DEFAULT 7` into `DEFAULT NULL` and dropped an `AUTO_INCREMENT` in silence. Respelling
|
|
277
|
+
from the metadata is what keeps everything the model knows. What the model does NOT describe is
|
|
278
|
+
lost, and `Cap.COMMENTS` says so as `Degraded` rather than `Full`.
|
|
279
|
+
"""
|
|
280
|
+
if _comments_are_inline(dialect):
|
|
281
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
282
|
+
return f"ALTER TABLE {table_ref} MODIFY COLUMN {_column_def(column, dialect)}"
|
|
283
|
+
quote = dialect.quote_ident
|
|
284
|
+
column_ref = f"{qualified(table.schema, table.name, dialect)}.{quote(column.name)}"
|
|
285
|
+
return f"COMMENT ON COLUMN {column_ref} IS {dialect.literal(column.db_comment)}"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def emit_comments(table: SnakeTableInfo, dialect: SnakeDialect) -> list[str]:
|
|
289
|
+
"""The EXTRA statements a freshly created table needs for its comments. Often none.
|
|
290
|
+
|
|
291
|
+
Empty for two different reasons, and keeping them apart matters:
|
|
292
|
+
|
|
293
|
+
- `CommentStyle.INLINE` (MySQL): the comments already travelled inside the `CREATE TABLE`,
|
|
294
|
+
table and columns alike. Emitting them again would write each one twice.
|
|
295
|
+
- `Cap.COMMENTS` absent (SQLite): the engine stores none, and it gets TRANSLATED into nothing
|
|
296
|
+
instead of complaining — a comment is documentation, not integrity, and losing it does not
|
|
297
|
+
corrupt data. Project rule: translate when there is an equivalent, complain when there is none.
|
|
298
|
+
"""
|
|
299
|
+
if not dialect.supports_comments or _comments_are_inline(dialect):
|
|
300
|
+
return []
|
|
301
|
+
statements: list[str] = []
|
|
302
|
+
if table.db_comment is not None:
|
|
303
|
+
statements.append(emit_table_comment(table, dialect))
|
|
304
|
+
statements.extend(
|
|
305
|
+
emit_column_comment(table, column, dialect)
|
|
306
|
+
for column in table.columns
|
|
307
|
+
if column.db_comment is not None
|
|
308
|
+
)
|
|
309
|
+
return statements
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def emit_create_schema(schema: str, dialect: SnakeDialect) -> str:
|
|
313
|
+
"""Emits `CREATE SCHEMA IF NOT EXISTS "name"`.
|
|
314
|
+
|
|
315
|
+
`IF NOT EXISTS` because the DBA may have created the schema beforehand: failing then
|
|
316
|
+
adds nothing, the desired state already holds.
|
|
317
|
+
"""
|
|
318
|
+
return f"CREATE SCHEMA IF NOT EXISTS {dialect.quote_ident(schema)}"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def emit_drop_schema(schema: str, dialect: SnakeDialect) -> str:
|
|
322
|
+
"""Emits `DROP SCHEMA "name"` (the reverse, deliberately WITHOUT CASCADE).
|
|
323
|
+
|
|
324
|
+
No `CASCADE`: if anything is left inside, let it fail. Wiping out what it did not
|
|
325
|
+
create is worse than refusing.
|
|
326
|
+
"""
|
|
327
|
+
return f"DROP SCHEMA {dialect.quote_ident(schema)}"
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def emit_drop_table(table: SnakeTableInfo, dialect: SnakeDialect) -> str:
|
|
331
|
+
"""Emits the `DROP TABLE` of a table (the reverse of CREATE TABLE)."""
|
|
332
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
333
|
+
return f"DROP TABLE {table_ref}"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _emitted_as_constraint(index: SnakeIndexInfo, dialect: SnakeDialect) -> bool:
|
|
337
|
+
"""Is this index written as a CONSTRAINT on this engine, or as a unique index?
|
|
338
|
+
|
|
339
|
+
`is_constraint` is the declared INTENT (engine-agnostic); the FORM is decided by the
|
|
340
|
+
engine. Without `ADD CONSTRAINT` (SQLite) it is translated into `CREATE UNIQUE
|
|
341
|
+
INDEX`, which gives the same guarantee. The NAME (`uq_*`) does not change: the
|
|
342
|
+
metadata fixes it and adding and dropping have to agree.
|
|
343
|
+
"""
|
|
344
|
+
return index.is_constraint and dialect.supports_add_constraint
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def emit_create_index(
|
|
348
|
+
table: SnakeTableInfo, index: SnakeIndexInfo, dialect: SnakeDialect
|
|
349
|
+
) -> str:
|
|
350
|
+
"""The DDL that adds a declared index: a CONSTRAINT with `unique`, an INDEX if not.
|
|
351
|
+
|
|
352
|
+
Uniqueness goes as a constraint (not as a unique index) because it STATES a rule of
|
|
353
|
+
the domain —the one `ON CONFLICT` and the engine errors refer to—; the index is only
|
|
354
|
+
the how. On an engine without `ADD CONSTRAINT` it falls back to the `CREATE UNIQUE
|
|
355
|
+
INDEX`; see `_emitted_as_constraint`.
|
|
356
|
+
"""
|
|
357
|
+
name = dialect.quote_ident(index.resolved_name(table.name))
|
|
358
|
+
columns = ", ".join(dialect.quote_ident(column) for column in index.columns)
|
|
359
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
360
|
+
if _emitted_as_constraint(index, dialect):
|
|
361
|
+
return (
|
|
362
|
+
f"ALTER TABLE {table_ref} ADD "
|
|
363
|
+
f"{_unique_constraint_def(table, index.columns, dialect, index.name)}"
|
|
364
|
+
)
|
|
365
|
+
# A PARTIAL unique does not fit in a constraint (Postgres does not accept `UNIQUE
|
|
366
|
+
# ... WHERE`), so here a `CREATE UNIQUE INDEX` does come out. It is the documented
|
|
367
|
+
# exception.
|
|
368
|
+
unique = "UNIQUE " if index.unique else ""
|
|
369
|
+
# BTREE is the engine default: the USING is omitted instead of writing the obvious.
|
|
370
|
+
using = (
|
|
371
|
+
f" USING {dialect.index_method(index.method)}"
|
|
372
|
+
if index.method is not None and index.method is not SnakeIndexMethod.BTREE
|
|
373
|
+
else ""
|
|
374
|
+
)
|
|
375
|
+
# The `WHERE` is only written where the engine HAS partial indexes. MySQL/MariaDB do not: the
|
|
376
|
+
# clause is not in their `CREATE INDEX` grammar, so writing it earned an ERROR 1064 from the
|
|
377
|
+
# server — the ORM emitting SQL that gets rejected, which is neither stopping nor degrading.
|
|
378
|
+
#
|
|
379
|
+
# Dropping it here degrades a SEARCH index into one over the whole table: same rows found, same
|
|
380
|
+
# data stored, more space. A partial UNIQUE cannot be degraded this way —widening it forbids
|
|
381
|
+
# rows the domain allows— and `realize()` stops it before this ever runs. The reason reaches the
|
|
382
|
+
# user through `Cap.PARTIAL_INDEXES`, announced once when the session opens.
|
|
383
|
+
where = (
|
|
384
|
+
f" WHERE {emit_condition_ddl(index.where, dialect)}"
|
|
385
|
+
if index.where is not None and dialect.capabilities.can(Cap.PARTIAL_INDEXES)
|
|
386
|
+
else ""
|
|
387
|
+
)
|
|
388
|
+
return f"CREATE {unique}INDEX {name} ON {table_ref}{using} ({columns}){where}"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def emit_drop_index(
|
|
392
|
+
table: SnakeTableInfo, index: SnakeIndexInfo, dialect: SnakeDialect
|
|
393
|
+
) -> str:
|
|
394
|
+
"""The drop DDL: `DROP CONSTRAINT` for uniqueness, `DROP INDEX` for an index.
|
|
395
|
+
|
|
396
|
+
An index is qualified with the SCHEMA (it is an object of the schema); a constraint
|
|
397
|
+
is dropped through ITS TABLE. Both by the resolved name, the same one the add used.
|
|
398
|
+
"""
|
|
399
|
+
name = dialect.quote_ident(index.resolved_name(table.name))
|
|
400
|
+
if _emitted_as_constraint(index, dialect):
|
|
401
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
402
|
+
return f"ALTER TABLE {table_ref} DROP CONSTRAINT {name}"
|
|
403
|
+
# Two SEPARATE decisions, and they were conflated into one: the condition for naming
|
|
404
|
+
# the table was `supports_schemas`, which has nothing to do with it. MySQL, which
|
|
405
|
+
# has no schemas, fell into the short branch because of that and was also left
|
|
406
|
+
# without the `ON table` it does need — and since the caller here is
|
|
407
|
+
# `CreateIndex.down_sql`, the rollback of every migration with an index died.
|
|
408
|
+
if dialect.syntax.indexes_are_table_scoped:
|
|
409
|
+
return f"DROP INDEX {name} ON {qualified(table.schema, table.name, dialect)}"
|
|
410
|
+
if not dialect.supports_schemas:
|
|
411
|
+
return f"DROP INDEX {name}"
|
|
412
|
+
return f"DROP INDEX {dialect.quote_ident(table.schema)}.{name}"
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def emit_add_column(
|
|
416
|
+
table: SnakeTableInfo, column: SnakeColumnInfo, dialect: SnakeDialect
|
|
417
|
+
) -> str:
|
|
418
|
+
"""Emits `ALTER TABLE ... ADD COLUMN <def>`.
|
|
419
|
+
|
|
420
|
+
Warning: adding a NOT NULL column without a DEFAULT to a table that has rows fails
|
|
421
|
+
on Postgres; declare a default or make it nullable.
|
|
422
|
+
"""
|
|
423
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
424
|
+
return f"ALTER TABLE {table_ref} ADD COLUMN {_column_def(column, dialect)}"
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def emit_drop_column(
|
|
428
|
+
table: SnakeTableInfo, column_name: str, dialect: SnakeDialect
|
|
429
|
+
) -> str:
|
|
430
|
+
"""Emits `ALTER TABLE ... DROP COLUMN "col"`."""
|
|
431
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
432
|
+
return f"ALTER TABLE {table_ref} DROP COLUMN {dialect.quote_ident(column_name)}"
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def emit_rename_column(
|
|
436
|
+
table: SnakeTableInfo, old_name: str, new_name: str, dialect: SnakeDialect
|
|
437
|
+
) -> str:
|
|
438
|
+
"""Emits `ALTER TABLE ... RENAME COLUMN old TO new`.
|
|
439
|
+
|
|
440
|
+
Renaming KEEPS the data; the drop + add the diff would produce erases it. It is
|
|
441
|
+
written by hand because the diff does not guess a rename.
|
|
442
|
+
"""
|
|
443
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
444
|
+
return (
|
|
445
|
+
f"ALTER TABLE {table_ref} RENAME COLUMN "
|
|
446
|
+
f"{dialect.quote_ident(old_name)} TO {dialect.quote_ident(new_name)}"
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def emit_rename_table(
|
|
451
|
+
table: SnakeTableInfo, new_name: str, dialect: SnakeDialect
|
|
452
|
+
) -> str:
|
|
453
|
+
"""Emits `ALTER TABLE <table> RENAME TO "new"`, the same grammar on the three engines.
|
|
454
|
+
|
|
455
|
+
THE OLD NAME IS QUALIFIED AND THE NEW ONE IS NOT, and that is measured rather than chosen:
|
|
456
|
+
PostgreSQL answers a syntax error to `RENAME TO "public"."new"`. It reads natural and it does
|
|
457
|
+
not parse, because a rename cannot move a table between schemas — that is `SET SCHEMA`, a
|
|
458
|
+
different statement. MariaDB and SQLite take the bare form too, so there is nothing to
|
|
459
|
+
translate: one string, three quotings, which `qualified` and `quote_ident` already handle.
|
|
460
|
+
"""
|
|
461
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
462
|
+
return f"ALTER TABLE {table_ref} RENAME TO {dialect.quote_ident(new_name)}"
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
REBUILD_SCRATCH_PREFIX = "__snakeorm_new_"
|
|
466
|
+
"""Prefix of the table a rebuild builds beside the original before renaming it into place.
|
|
467
|
+
|
|
468
|
+
Deterministic and derived from the table's own name, like every other identifier the migrations
|
|
469
|
+
emit: nothing here is ever read back out of the server, so the `DROP` and the `RENAME` that follow
|
|
470
|
+
can name it without a connection. The prefix is long and ugly on purpose — it has to be a name no
|
|
471
|
+
model would ever declare, and it lives in the schema for exactly three statements.
|
|
472
|
+
"""
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def rebuild_scratch_name(table: SnakeTableInfo) -> str:
|
|
476
|
+
"""The temporary name the rebuild of this table uses."""
|
|
477
|
+
return f"{REBUILD_SCRATCH_PREFIX}{table.name}"
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _to_one_keys(table: SnakeTableInfo) -> dict[str, SnakeRelationshipInfo]:
|
|
481
|
+
"""The table's to-one relations by CONSTRAINT NAME, which is their identity in the database.
|
|
482
|
+
|
|
483
|
+
Only to-one: a to-many is the inverse relation and its key lives on the other table, the same
|
|
484
|
+
filter `_diff_foreign_keys` and `drop_order` already apply.
|
|
485
|
+
"""
|
|
486
|
+
return {
|
|
487
|
+
foreign_key_name(table, relationship): relationship
|
|
488
|
+
for relationship in table.relationships
|
|
489
|
+
if relationship.kind is SnakeRelationshipKind.TO_ONE
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _check_fingerprints(table: SnakeTableInfo, dialect: SnakeDialect) -> dict[str, str]:
|
|
494
|
+
"""The table's CHECKs by resolved name, valued by the SQL they emit.
|
|
495
|
+
|
|
496
|
+
Compared by the emitted condition and not by the object: the AST nodes use `eq=False`, so two
|
|
497
|
+
identical conditions are never `==` and every rebuild would look like it changed everything.
|
|
498
|
+
"""
|
|
499
|
+
return {
|
|
500
|
+
check.resolved_name(table.name): emit_condition_ddl(check.condition, dialect)
|
|
501
|
+
for check in table.checks
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _add_foreign_key_clause(
|
|
506
|
+
table: SnakeTableInfo, relationship: SnakeRelationshipInfo, dialect: SnakeDialect
|
|
507
|
+
) -> str:
|
|
508
|
+
"""`ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY ...` taken from the relation's OWN target.
|
|
509
|
+
|
|
510
|
+
It reuses `_inline_foreign_key_def`, so the key an engine writes inside its `CREATE TABLE` and
|
|
511
|
+
the one another engine adds afterwards are the SAME text with a different preamble — one name,
|
|
512
|
+
one set of referential actions, and no second place where they could drift.
|
|
513
|
+
|
|
514
|
+
It reads `relationship.target_table` instead of taking a resolved `SnakeTableInfo` the way
|
|
515
|
+
`emit_add_foreign_key` does, because a rebuild is described by two snapshots of ONE table and
|
|
516
|
+
there is no second table in the operation to hand over. The field is the linker's own resolved
|
|
517
|
+
spelling, which is what the inline form already trusts.
|
|
518
|
+
"""
|
|
519
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
520
|
+
return f"ALTER TABLE {table_ref} ADD {_inline_foreign_key_def(table, relationship, dialect)}"
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _minimal_constraint_alters(
|
|
524
|
+
before: SnakeTableInfo, after: SnakeTableInfo, dialect: SnakeDialect
|
|
525
|
+
) -> list[str]:
|
|
526
|
+
"""The least an engine with `ALTER TABLE ... ADD CONSTRAINT` has to run: drops, then adds.
|
|
527
|
+
|
|
528
|
+
Drops first and adds after, which is the order `_diff_checks` and `_diff_foreign_keys` already
|
|
529
|
+
put them in and for the same reason: a constraint is not altered in place, so one that changed
|
|
530
|
+
under the same name has to leave before its replacement arrives.
|
|
531
|
+
"""
|
|
532
|
+
before_checks = _check_fingerprints(before, dialect)
|
|
533
|
+
after_checks = _check_fingerprints(after, dialect)
|
|
534
|
+
before_keys = _to_one_keys(before)
|
|
535
|
+
after_keys = _to_one_keys(after)
|
|
536
|
+
|
|
537
|
+
statements: list[str] = [
|
|
538
|
+
emit_drop_check(before, check, dialect)
|
|
539
|
+
for check in before.checks
|
|
540
|
+
if after_checks.get(check.resolved_name(before.name))
|
|
541
|
+
!= before_checks[check.resolved_name(before.name)]
|
|
542
|
+
]
|
|
543
|
+
statements.extend(
|
|
544
|
+
emit_drop_foreign_key(before, relationship, dialect)
|
|
545
|
+
for name, relationship in before_keys.items()
|
|
546
|
+
if name not in after_keys
|
|
547
|
+
or after_keys[name].foreign_key != relationship.foreign_key
|
|
548
|
+
or after_keys[name].target_table != relationship.target_table
|
|
549
|
+
)
|
|
550
|
+
statements.extend(
|
|
551
|
+
_add_foreign_key_clause(after, relationship, dialect)
|
|
552
|
+
for name, relationship in after_keys.items()
|
|
553
|
+
if name not in before_keys
|
|
554
|
+
or before_keys[name].foreign_key != relationship.foreign_key
|
|
555
|
+
or before_keys[name].target_table != relationship.target_table
|
|
556
|
+
)
|
|
557
|
+
statements.extend(
|
|
558
|
+
emit_add_check(after, check, dialect)
|
|
559
|
+
for check in after.checks
|
|
560
|
+
if before_checks.get(check.resolved_name(after.name))
|
|
561
|
+
!= after_checks[check.resolved_name(after.name)]
|
|
562
|
+
)
|
|
563
|
+
return statements
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _remake_table(
|
|
567
|
+
before: SnakeTableInfo,
|
|
568
|
+
after: SnakeTableInfo,
|
|
569
|
+
dialect: SnakeDialect,
|
|
570
|
+
triggers: Sequence[SnakeTriggerInfo] = (),
|
|
571
|
+
) -> list[str]:
|
|
572
|
+
"""The whole rebuild, for an engine whose only window on a constraint is the `CREATE TABLE`.
|
|
573
|
+
|
|
574
|
+
THE ORDER IS THE ONE THAT WORKS. The new table is built beside the old one and renamed into
|
|
575
|
+
place last; renaming the OLD one out of the way first needs `PRAGMA foreign_keys = OFF`, because
|
|
576
|
+
a rename rewrites the `REFERENCES` clauses of every other table while the keys are armed — and
|
|
577
|
+
that pragma is documented as a no-op inside a transaction, which is where a migration always is.
|
|
578
|
+
|
|
579
|
+
The indexes are recreated at the END, after the `DROP TABLE` has taken the old ones with it:
|
|
580
|
+
creating them earlier would clash with names that are still occupied. They come from `after`, so
|
|
581
|
+
an index the model does NOT declare does not come back — one created by hand in the database is
|
|
582
|
+
gone after a rebuild.
|
|
583
|
+
|
|
584
|
+
THE TRIGGERS GO LAST FOR THE SAME REASON, and they arrive by a different road: a `SnakeTableInfo`
|
|
585
|
+
has no `triggers` field, so `after` cannot carry them and the operation passes them in. They also
|
|
586
|
+
have to come after the RENAME, since the statement that creates one names the table it hangs off
|
|
587
|
+
and that name only becomes the real one at the end.
|
|
588
|
+
|
|
589
|
+
NO VIEW IS BRACKETED AROUND IT (see `emit_rebuild_table`). The `RENAME` at the end REPARSES THE
|
|
590
|
+
ENTIRE SCHEMA on this engine, and at that instant the old table is already dropped and the new
|
|
591
|
+
one still wears its scratch name — so a view whose SELECT names the table cannot be parsed and
|
|
592
|
+
the RENAME fails with `error in view <v>: no such table: main.<t>`, measured on SQLite 3.50; it
|
|
593
|
+
is that statement that fails, not the `DROP TABLE`. `frameworks/shared/migrations/inventory/
|
|
594
|
+
0004_on_hand_and_available_view.py` is the worked example, with the `DropView` before and the
|
|
595
|
+
`CreateView` after written by hand.
|
|
596
|
+
"""
|
|
597
|
+
scratch = rebuild_scratch_name(after)
|
|
598
|
+
columns = ", ".join(dialect.quote_ident(column.name) for column in after.columns)
|
|
599
|
+
statements: list[str] = []
|
|
600
|
+
if dialect.syntax.defer_constraints_statement is not None:
|
|
601
|
+
statements.append(dialect.syntax.defer_constraints_statement)
|
|
602
|
+
statements.extend(
|
|
603
|
+
(
|
|
604
|
+
emit_create_table(after, dialect, as_name=scratch),
|
|
605
|
+
f"INSERT INTO {qualified(after.schema, scratch, dialect)} ({columns}) "
|
|
606
|
+
f"SELECT {columns} FROM {qualified(before.schema, before.name, dialect)}",
|
|
607
|
+
emit_drop_table(before, dialect),
|
|
608
|
+
emit_rename_table(replace(after, name=scratch), after.name, dialect),
|
|
609
|
+
)
|
|
610
|
+
)
|
|
611
|
+
statements.extend(
|
|
612
|
+
emit_create_index(after, index, dialect) for index in after.indexes
|
|
613
|
+
)
|
|
614
|
+
for trigger in triggers:
|
|
615
|
+
statements.extend(emit_create_trigger(trigger, dialect))
|
|
616
|
+
return statements
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def emit_rebuild_table(
|
|
620
|
+
before: SnakeTableInfo,
|
|
621
|
+
after: SnakeTableInfo,
|
|
622
|
+
dialect: SnakeDialect,
|
|
623
|
+
triggers: Sequence[SnakeTriggerInfo] = (),
|
|
624
|
+
) -> list[str]:
|
|
625
|
+
"""Takes a table from the `before` shape to the `after` one, in the way this engine can.
|
|
626
|
+
|
|
627
|
+
Two spellings of ONE operation: an engine that can change a constraint in place gets the minimal
|
|
628
|
+
`ALTER`s, and an engine that cannot gets the table remade around it.
|
|
629
|
+
|
|
630
|
+
The two snapshots differ only in constraints; `RebuildTable` refuses to be built otherwise, so
|
|
631
|
+
nothing here has to guess whether a column change was meant to travel.
|
|
632
|
+
|
|
633
|
+
The `triggers` are the ones hanging off this table, and only the engine that REMAKES it has
|
|
634
|
+
anything to do with them: an engine that alters the constraint in place never drops the table,
|
|
635
|
+
so nothing took them away and recreating them would collide with names still in use.
|
|
636
|
+
|
|
637
|
+
Views are NOT part of this call in either spelling. The engine that remakes the table is the one
|
|
638
|
+
a standing view can break, and which views read the table is not something the metadata says —
|
|
639
|
+
so the person writes the `DropView`/`CreateView` around the rebuild, and the ORM translates the
|
|
640
|
+
engine's complaint when they did not.
|
|
641
|
+
"""
|
|
642
|
+
if dialect.supports_add_constraint:
|
|
643
|
+
return _minimal_constraint_alters(before, after, dialect)
|
|
644
|
+
return _remake_table(before, after, dialect, triggers)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def emit_alter_column(
|
|
648
|
+
table: SnakeTableInfo,
|
|
649
|
+
old: SnakeColumnInfo,
|
|
650
|
+
new: SnakeColumnInfo,
|
|
651
|
+
dialect: SnakeDialect,
|
|
652
|
+
) -> list[str]:
|
|
653
|
+
"""The statements to go from `old` to `new`, in the GRAMMAR of the engine.
|
|
654
|
+
|
|
655
|
+
Scope: type, nullable, unique and default. The reverse (down) is obtained by
|
|
656
|
+
swapping old/new.
|
|
657
|
+
|
|
658
|
+
The form is decided by `dialect.syntax.alter_column_style` and not by this emitter,
|
|
659
|
+
which is exactly what was wrong: it wrote the Postgres one for everybody. MySQL
|
|
660
|
+
declares that it knows how to alter columns —and it does—, so the plan let the
|
|
661
|
+
operation through and the failure came out as a syntax error from the engine halfway
|
|
662
|
+
through the migration; with no transactional DDL, that is, with no rollback.
|
|
663
|
+
"""
|
|
664
|
+
if dialect.syntax.alter_column_style is AlterColumnStyle.MYSQL_MODIFY:
|
|
665
|
+
statements = _alter_column_by_modify(table, old, new, dialect)
|
|
666
|
+
else:
|
|
667
|
+
# POSTGRES_TYPE_USING, and UNSUPPORTED too: on an engine that does not know how
|
|
668
|
+
# to alter columns this never gets to run —`realize()` stops the operation in
|
|
669
|
+
# the PLAN, with a readable reason—, so whatever form is emitted there does not
|
|
670
|
+
# matter as long as nobody runs it.
|
|
671
|
+
statements = _alter_column_by_alter(table, old, new, dialect)
|
|
672
|
+
if (
|
|
673
|
+
new.db_comment != old.db_comment
|
|
674
|
+
and dialect.supports_comments
|
|
675
|
+
and not _comments_are_inline(dialect)
|
|
676
|
+
):
|
|
677
|
+
# The capability was already read here —without it, altering a commented column slipped a
|
|
678
|
+
# `COMMENT ON` into engines that do not understand it— and now the STYLE is read too. On an
|
|
679
|
+
# inline engine the comment is part of the definition, so `_alter_column_by_modify` has
|
|
680
|
+
# already carried it in its `MODIFY`; appending a second statement would rewrite the column
|
|
681
|
+
# twice to say the same thing.
|
|
682
|
+
statements.append(emit_column_comment(table, new, dialect))
|
|
683
|
+
return statements
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _alter_column_by_alter(
|
|
687
|
+
table: SnakeTableInfo,
|
|
688
|
+
old: SnakeColumnInfo,
|
|
689
|
+
new: SnakeColumnInfo,
|
|
690
|
+
dialect: SnakeDialect,
|
|
691
|
+
) -> list[str]:
|
|
692
|
+
"""Postgres form: one statement per change, and a `USING` to cast the type.
|
|
693
|
+
|
|
694
|
+
The autoincrement is NOT one more property here, and that is the whole difference from the
|
|
695
|
+
MySQL form: `BIGSERIAL` is not a type this engine has, it is a `CREATE TABLE` shorthand. So the
|
|
696
|
+
toggle is expanded into what the shorthand means (see `_autoincrement_change`) and the type
|
|
697
|
+
statement is written with the STORABLE type, never with the shorthand.
|
|
698
|
+
"""
|
|
699
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
700
|
+
column = dialect.quote_ident(new.name)
|
|
701
|
+
statements: list[str] = []
|
|
702
|
+
new_type = _storable_type(new, dialect)
|
|
703
|
+
if new_type != _storable_type(old, dialect):
|
|
704
|
+
statements.append(
|
|
705
|
+
f"ALTER TABLE {table_ref} ALTER COLUMN {column} "
|
|
706
|
+
f"TYPE {new_type} USING {column}::{new_type}"
|
|
707
|
+
)
|
|
708
|
+
if new.nullable != old.nullable:
|
|
709
|
+
action = "DROP NOT NULL" if new.nullable else "SET NOT NULL"
|
|
710
|
+
statements.append(f"ALTER TABLE {table_ref} ALTER COLUMN {column} {action}")
|
|
711
|
+
statements.extend(_unique_change(table, old, new, dialect))
|
|
712
|
+
if old.autoincrement != new.autoincrement:
|
|
713
|
+
# The sequence OWNS the DEFAULT at both ends of the toggle, so the ordinary default branch
|
|
714
|
+
# would only fight it: turning it on the default IS `nextval(...)`, and turning it off the
|
|
715
|
+
# `DROP DEFAULT` is already part of dismantling the sequence.
|
|
716
|
+
return statements + _autoincrement_change(table, new, dialect)
|
|
717
|
+
if _default_changed(old, new):
|
|
718
|
+
expression = _default_expr(new, dialect)
|
|
719
|
+
if expression is not None:
|
|
720
|
+
statements.append(
|
|
721
|
+
f"ALTER TABLE {table_ref} ALTER COLUMN {column} SET DEFAULT {expression}"
|
|
722
|
+
)
|
|
723
|
+
else:
|
|
724
|
+
statements.append(
|
|
725
|
+
f"ALTER TABLE {table_ref} ALTER COLUMN {column} DROP DEFAULT"
|
|
726
|
+
)
|
|
727
|
+
return statements
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _storable_type(column: SnakeColumnInfo, dialect: SnakeDialect) -> str:
|
|
731
|
+
"""The column's type as an `ALTER ... TYPE` can spell it: the shorthand resolved away.
|
|
732
|
+
|
|
733
|
+
`sql_type_of` answers what a `CREATE TABLE` writes, and on Postgres an autoincrementing column
|
|
734
|
+
writes `BIGSERIAL` there. That word only exists in a `CREATE TABLE`: the server answers
|
|
735
|
+
`type "bigserial" does not exist` to an `ALTER ... TYPE BIGSERIAL`, which is what this emitter
|
|
736
|
+
used to produce. Asking for the type WITHOUT the autoincrement gives the type the column really
|
|
737
|
+
has —`BIGINT`—, which is the one an `ALTER` can name.
|
|
738
|
+
"""
|
|
739
|
+
return dialect.map_type(
|
|
740
|
+
column.storage_type, autoincrement=False, params=column.type_params
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _sequence_name(table: SnakeTableInfo, column: SnakeColumnInfo) -> str:
|
|
745
|
+
"""The name Postgres itself gives the sequence behind a `SERIAL`: `<table>_<column>_seq`.
|
|
746
|
+
|
|
747
|
+
Derived and not stored, and it has to be exactly this one: a column born `BIGSERIAL` inside a
|
|
748
|
+
`CREATE TABLE` already has a sequence under this name, so a later toggle finds it instead of
|
|
749
|
+
leaving a second, orphaned one behind.
|
|
750
|
+
"""
|
|
751
|
+
return f"{table.name}_{column.name}_seq"
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _autoincrement_change(
|
|
755
|
+
table: SnakeTableInfo, new: SnakeColumnInfo, dialect: SnakeDialect
|
|
756
|
+
) -> list[str]:
|
|
757
|
+
"""What `SERIAL` MEANS, spelled out: a sequence, a `DEFAULT nextval(...)` and its owner.
|
|
758
|
+
|
|
759
|
+
Turning it ON, four statements. `OWNED BY` is what makes the sequence die with the column, just
|
|
760
|
+
as it does when the shorthand creates it. And the `setval` is not decoration: over a populated
|
|
761
|
+
table a fresh sequence starts at 1 and the first insert collides with a key that is already
|
|
762
|
+
there. `is_called = false` means the NEXT `nextval()` returns exactly that value, so an empty
|
|
763
|
+
table (`MAX` is NULL, hence `COALESCE` 0) starts at 1, like a brand-new `BIGSERIAL`.
|
|
764
|
+
|
|
765
|
+
Turning it OFF, two. This is the direction that used to be worse: it emitted
|
|
766
|
+
`TYPE BIGINT USING "code"::BIGINT` on a column that was ALREADY `bigint`, so the server accepted
|
|
767
|
+
it, changed nothing, and left the `DEFAULT nextval(...)` and the sequence in place. A green
|
|
768
|
+
migration that did not migrate. Failing loudly is bad; succeeding and lying is worse.
|
|
769
|
+
"""
|
|
770
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
771
|
+
column = dialect.quote_ident(new.name)
|
|
772
|
+
sequence = qualified(table.schema, _sequence_name(table, new), dialect)
|
|
773
|
+
if not new.autoincrement:
|
|
774
|
+
return [
|
|
775
|
+
f"ALTER TABLE {table_ref} ALTER COLUMN {column} DROP DEFAULT",
|
|
776
|
+
f"DROP SEQUENCE {sequence}",
|
|
777
|
+
]
|
|
778
|
+
return [
|
|
779
|
+
f"CREATE SEQUENCE {sequence}",
|
|
780
|
+
f"ALTER TABLE {table_ref} ALTER COLUMN {column} SET DEFAULT "
|
|
781
|
+
f"nextval({dialect.literal(sequence)}::regclass)",
|
|
782
|
+
f"ALTER SEQUENCE {sequence} OWNED BY {table_ref}.{column}",
|
|
783
|
+
f"SELECT setval({dialect.literal(sequence)}, "
|
|
784
|
+
f"COALESCE((SELECT MAX({column}) FROM {table_ref}), 0) + 1, false)",
|
|
785
|
+
]
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _alter_column_by_modify(
|
|
789
|
+
table: SnakeTableInfo,
|
|
790
|
+
old: SnakeColumnInfo,
|
|
791
|
+
new: SnakeColumnInfo,
|
|
792
|
+
dialect: SnakeDialect,
|
|
793
|
+
) -> list[str]:
|
|
794
|
+
"""MySQL form: `MODIFY COLUMN` with the WHOLE definition, in ONE statement.
|
|
795
|
+
|
|
796
|
+
`MODIFY` does not change one property, it rewrites the definition completely. That
|
|
797
|
+
is why type, nullability and default travel together: emitting one statement per
|
|
798
|
+
change —as in Postgres— would have the second one trample what the first left
|
|
799
|
+
behind, and a column that only changed type would lose its NOT NULL.
|
|
800
|
+
|
|
801
|
+
Uniqueness does NOT come in here: in MySQL it is a constraint apart as well, with a
|
|
802
|
+
name of its own.
|
|
803
|
+
|
|
804
|
+
The COMMENT is in the trigger list for the same reason the others are: it lives inside the
|
|
805
|
+
definition, so a comment-only edit still needs the whole `MODIFY`. Before it was listed, such an
|
|
806
|
+
edit produced an EMPTY statement list on this engine — the diff saw the change, wrote it into
|
|
807
|
+
the migration file, and applying the file did nothing at all.
|
|
808
|
+
"""
|
|
809
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
810
|
+
statements: list[str] = []
|
|
811
|
+
if (
|
|
812
|
+
sql_type_of(new, dialect) != sql_type_of(old, dialect)
|
|
813
|
+
or new.nullable != old.nullable
|
|
814
|
+
or _default_changed(old, new)
|
|
815
|
+
or new.db_comment != old.db_comment
|
|
816
|
+
):
|
|
817
|
+
statements.append(
|
|
818
|
+
f"ALTER TABLE {table_ref} MODIFY COLUMN {_column_def(new, dialect)}"
|
|
819
|
+
)
|
|
820
|
+
statements.extend(_unique_change(table, old, new, dialect))
|
|
821
|
+
return statements
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _unique_change(
|
|
825
|
+
table: SnakeTableInfo,
|
|
826
|
+
old: SnakeColumnInfo,
|
|
827
|
+
new: SnakeColumnInfo,
|
|
828
|
+
dialect: SnakeDialect,
|
|
829
|
+
) -> list[str]:
|
|
830
|
+
"""Adding or dropping the uniqueness constraint, written alike on both engines."""
|
|
831
|
+
if new.unique == old.unique:
|
|
832
|
+
return []
|
|
833
|
+
table_ref = qualified(table.schema, table.name, dialect)
|
|
834
|
+
constraint = dialect.quote_ident(_uq_name(table, new.name))
|
|
835
|
+
if new.unique:
|
|
836
|
+
column = dialect.quote_ident(new.name)
|
|
837
|
+
return [
|
|
838
|
+
f"ALTER TABLE {table_ref} ADD CONSTRAINT {constraint} UNIQUE ({column})"
|
|
839
|
+
]
|
|
840
|
+
return [f"ALTER TABLE {table_ref} DROP CONSTRAINT {constraint}"]
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def emit_add_foreign_key(
|
|
844
|
+
table: SnakeTableInfo,
|
|
845
|
+
relationship: SnakeRelationshipInfo,
|
|
846
|
+
target: SnakeTableInfo,
|
|
847
|
+
dialect: SnakeDialect,
|
|
848
|
+
) -> str:
|
|
849
|
+
"""Emits `ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY (...) REFERENCES ...`.
|
|
850
|
+
|
|
851
|
+
FKs are added AT THE END (after every table has been created), so `target` is
|
|
852
|
+
already resolved here and no topological order is needed. Emits ON DELETE/UPDATE
|
|
853
|
+
when they are not NO ACTION.
|
|
854
|
+
"""
|
|
855
|
+
quote = dialect.quote_ident
|
|
856
|
+
fk = relationship.foreign_key
|
|
857
|
+
local = ", ".join(quote(local_col) for local_col, _ in fk.pairs)
|
|
858
|
+
remote = ", ".join(quote(remote_col) for _, remote_col in fk.pairs)
|
|
859
|
+
source_ref = qualified(table.schema, table.name, dialect)
|
|
860
|
+
target_ref = qualified(target.schema, target.name, dialect)
|
|
861
|
+
sql = (
|
|
862
|
+
f"ALTER TABLE {source_ref} ADD CONSTRAINT {quote(foreign_key_name(table, relationship))} "
|
|
863
|
+
f"FOREIGN KEY ({local}) REFERENCES {target_ref} ({remote})"
|
|
864
|
+
)
|
|
865
|
+
if fk.on_delete is not SnakeFkAction.NO_ACTION:
|
|
866
|
+
sql = f"{sql} ON DELETE {fk.on_delete.value}"
|
|
867
|
+
if fk.on_update is not SnakeFkAction.NO_ACTION:
|
|
868
|
+
sql = f"{sql} ON UPDATE {fk.on_update.value}"
|
|
869
|
+
return sql
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def emit_drop_foreign_key(
|
|
873
|
+
table: SnakeTableInfo, relationship: SnakeRelationshipInfo, dialect: SnakeDialect
|
|
874
|
+
) -> str:
|
|
875
|
+
"""Emits `ALTER TABLE ... DROP CONSTRAINT ...` (the reverse of ADD)."""
|
|
876
|
+
source_ref = qualified(table.schema, table.name, dialect)
|
|
877
|
+
return f"ALTER TABLE {source_ref} DROP CONSTRAINT {dialect.quote_ident(foreign_key_name(table, relationship))}"
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _view_ref(view: SnakeTableInfo, dialect: SnakeDialect) -> str:
|
|
881
|
+
"""Qualified `"schema"."view"` reference of a view."""
|
|
882
|
+
return qualified(view.schema, view.name, dialect)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def view_body(view: SnakeTableInfo, dialect: SnakeDialect) -> str:
|
|
886
|
+
"""The SELECT that defines the view, written in the TARGET dialect.
|
|
887
|
+
|
|
888
|
+
With `query=` it is compiled here and not when the model is declared: quoting,
|
|
889
|
+
schema qualification, literals and `LIMIT/OFFSET` change between engines, and
|
|
890
|
+
compiling it in the decorator froze the body into the dialect of a single one.
|
|
891
|
+
|
|
892
|
+
With `sql=` it is returned as is. The user wrote it in the dialect of their engine,
|
|
893
|
+
so there is nothing to recompile and reinterpreting it would be worse.
|
|
894
|
+
"""
|
|
895
|
+
if view.view_query is not None:
|
|
896
|
+
# The cast names the WHOLE union the decorator accepts, not just the query. It said
|
|
897
|
+
# `SnakeQuery` while a compound already flowed through it correctly, so the one place that
|
|
898
|
+
# knows what a view body can be was describing half of it.
|
|
899
|
+
query = cast(
|
|
900
|
+
"SnakeQuery[object] | SnakeCompound[object]",
|
|
901
|
+
view.view_query,
|
|
902
|
+
)
|
|
903
|
+
text, params = query.to_sql(dialect)
|
|
904
|
+
# The `CREATE VIEW ... AS <select>` DDL does not accept placeholders: the
|
|
905
|
+
# literals of the filter get inlined (the same piece the CHECKs use).
|
|
906
|
+
return inline_params(text, params, dialect)
|
|
907
|
+
return _raw_view_definition(view)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def view_fingerprint(view: SnakeTableInfo) -> str:
|
|
911
|
+
"""The fingerprint a view is COMPARED by between two states of the schema.
|
|
912
|
+
|
|
913
|
+
It is computed with a CANONICAL dialect and is never executed. It has to be the same
|
|
914
|
+
on the three engines because the snapshot and the diff come out of it: if it
|
|
915
|
+
depended on the engine, the same model would generate a different migration
|
|
916
|
+
depending on the machine `makemigrations` is run from, and two devs with different
|
|
917
|
+
engines would trample each other's history.
|
|
918
|
+
|
|
919
|
+
It is the separation that was missing. Before, one single string acted as
|
|
920
|
+
fingerprint AND as DDL, so one of them could not be fixed without breaking the
|
|
921
|
+
other.
|
|
922
|
+
"""
|
|
923
|
+
return view_body(view, _CANONICAL_DIALECT)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _raw_view_definition(view: SnakeTableInfo) -> str:
|
|
927
|
+
"""The definition (SELECT) of a view; fails clearly if the node lacks it."""
|
|
928
|
+
if view.view_definition is None:
|
|
929
|
+
raise SnakeMigrationError(
|
|
930
|
+
f"View '{view.name}' has no definition (view_definition); its DDL cannot be emitted. "
|
|
931
|
+
f"Was it compiled with @snake_view (sql=... or query=...)?"
|
|
932
|
+
)
|
|
933
|
+
return view.view_definition
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def emit_create_view(view: SnakeTableInfo, dialect: SnakeDialect) -> str:
|
|
937
|
+
"""Emits `CREATE VIEW "schema"."view" AS <definition>` (raw SELECT, on purpose).
|
|
938
|
+
|
|
939
|
+
A view emits NO FKs and NO constraints: navigating its relations is pure SQL
|
|
940
|
+
generation, the database does not guarantee it. Views are created AFTER the tables
|
|
941
|
+
(they depend on them).
|
|
942
|
+
"""
|
|
943
|
+
return f"CREATE VIEW {_view_ref(view, dialect)} AS {view_body(view, dialect)}"
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def emit_drop_view(view: SnakeTableInfo, dialect: SnakeDialect) -> str:
|
|
947
|
+
"""Emits `DROP VIEW "schema"."view"` (the reverse of CREATE VIEW)."""
|
|
948
|
+
return f"DROP VIEW {_view_ref(view, dialect)}"
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def topological_view_order(views: Sequence[SnakeTableInfo]) -> list[SnakeTableInfo]:
|
|
952
|
+
"""Orders the views so each one comes AFTER those it depends on (`depends_on`).
|
|
953
|
+
|
|
954
|
+
Only dependencies WITHIN the given set count (a dependency on a view that already exists — not in
|
|
955
|
+
the set — imposes no order here). A DFS with an "in progress" mark detects cycles and raises
|
|
956
|
+
`SnakeMigrationError`. The walk starts in name order -> stable output.
|
|
957
|
+
|
|
958
|
+
IT LIVES IN THE EMITTER AND NOT IN THE DIFF, and today it has ONE caller: `_diff_views`, which
|
|
959
|
+
orders the `CreateView`/`DropView` operations. It had a second — `_remake_table` bracketed a
|
|
960
|
+
rebuild with the standing views — and that second caller was removed on purpose, because the ORM
|
|
961
|
+
cannot know which tables a view reads. Moving the function back into `diff.py` for that would be
|
|
962
|
+
churn with a cost: `diff` already imports `view_fingerprint` from here, so the arrow between the
|
|
963
|
+
two modules only goes one way, and this is the module that spells view DDL.
|
|
964
|
+
"""
|
|
965
|
+
by_name = {view.name: view for view in views}
|
|
966
|
+
ordered: list[SnakeTableInfo] = []
|
|
967
|
+
visited: set[str] = set()
|
|
968
|
+
visiting: set[str] = set()
|
|
969
|
+
|
|
970
|
+
def visit(view: SnakeTableInfo) -> None:
|
|
971
|
+
if view.name in visited:
|
|
972
|
+
return
|
|
973
|
+
if view.name in visiting:
|
|
974
|
+
raise SnakeMigrationError(
|
|
975
|
+
f"Dependency cycle among views: '{view.name}' takes part in a `depends_on` "
|
|
976
|
+
f"cycle. A view cannot depend (directly or indirectly) on itself."
|
|
977
|
+
)
|
|
978
|
+
visiting.add(view.name)
|
|
979
|
+
for dependency in view.depends_on:
|
|
980
|
+
target = by_name.get(dependency)
|
|
981
|
+
if (
|
|
982
|
+
target is not None
|
|
983
|
+
): # dependencies outside the set already exist: they impose no order
|
|
984
|
+
visit(target)
|
|
985
|
+
visiting.discard(view.name)
|
|
986
|
+
visited.add(view.name)
|
|
987
|
+
ordered.append(view)
|
|
988
|
+
|
|
989
|
+
for view in sorted(views, key=lambda item: item.name):
|
|
990
|
+
visit(view)
|
|
991
|
+
return ordered
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def emit_replace_view(view: SnakeTableInfo, dialect: SnakeDialect) -> str:
|
|
995
|
+
"""Emits `CREATE OR REPLACE VIEW ... AS <definition>` (to change a view SELECT).
|
|
996
|
+
|
|
997
|
+
It replaces the whole view: a view has no AddColumn/AlterColumn, it is redefined in
|
|
998
|
+
one piece.
|
|
999
|
+
"""
|
|
1000
|
+
return f"CREATE OR REPLACE VIEW {_view_ref(view, dialect)} AS {view_body(view, dialect)}"
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
# -- ROUTINE DDL (stored functions/procedures) -----------------------------------------
|
|
1004
|
+
#
|
|
1005
|
+
# A routine is OPAQUE SQL: its `body` is the complete `CREATE OR REPLACE FUNCTION ...`,
|
|
1006
|
+
# raw and NOT portable. The create/replace emitter returns the `body` as is; only the
|
|
1007
|
+
# `DROP FUNCTION` of the reverse is built out of the name.
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def emit_create_function(routine: SnakeRoutineInfo, dialect: SnakeDialect) -> str:
|
|
1011
|
+
"""The DDL of a routine: its raw `body` (opaque, not portable, `CREATE OR REPLACE`).
|
|
1012
|
+
|
|
1013
|
+
It serves CreateFunction and AlterFunction alike: with `CREATE OR REPLACE` both of
|
|
1014
|
+
them are idempotent.
|
|
1015
|
+
"""
|
|
1016
|
+
return routine.body
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def emit_drop_function(routine: SnakeRoutineInfo, dialect: SnakeDialect) -> str:
|
|
1020
|
+
"""Builds `DROP FUNCTION "schema"."name"` (the reverse of creating a routine).
|
|
1021
|
+
|
|
1022
|
+
No argument signature: in Postgres the name is enough as long as the routine is not
|
|
1023
|
+
overloaded (scope: one routine per name).
|
|
1024
|
+
"""
|
|
1025
|
+
routine_ref = qualified(routine.schema, routine.name, dialect)
|
|
1026
|
+
return f"DROP FUNCTION {routine_ref}"
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def sql_type_of(column: SnakeColumnInfo, dialect: SnakeDialect) -> str:
|
|
1030
|
+
"""The COMPLETE SQL type of a column, parameters included.
|
|
1031
|
+
|
|
1032
|
+
It is the only thing it makes sense to compare two versions of a column with:
|
|
1033
|
+
looking at `python_type` alone gave `NUMERIC(10,2)` == `NUMERIC(12,2)` (both of them
|
|
1034
|
+
`Decimal`) and lost the change of precision. Comparing the resulting type covers any
|
|
1035
|
+
parameterized type to come.
|
|
1036
|
+
|
|
1037
|
+
NOTHING is concatenated here. It used to be: the precision was glued to the type
|
|
1038
|
+
with an f-string, OUTSIDE the dialect, and that was the hole — the only parameter
|
|
1039
|
+
that did not go through `map_type` was also the only one nobody validated, and a
|
|
1040
|
+
`precision` on a `str` emitted `TEXT(12,2)` that only blew up on the `migrate`. The
|
|
1041
|
+
dialect returns the whole type or it does not return it.
|
|
1042
|
+
"""
|
|
1043
|
+
return dialect.map_type(
|
|
1044
|
+
column.storage_type,
|
|
1045
|
+
autoincrement=column.autoincrement,
|
|
1046
|
+
params=column.type_params,
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _column_def(column: SnakeColumnInfo, dialect: SnakeDialect) -> str:
|
|
1051
|
+
"""Defines a column: `"name" TYPE [NOT NULL] [DEFAULT x] [COMMENT '...']`.
|
|
1052
|
+
|
|
1053
|
+
The `COMMENT` only appears on an engine that spells comments as a clause, and it is appended in
|
|
1054
|
+
BOTH branches on purpose: the autoincrement one returns early, and leaving it out there would
|
|
1055
|
+
have dropped the comment of every surrogate primary key without a word.
|
|
1056
|
+
"""
|
|
1057
|
+
sql_type = sql_type_of(column, dialect)
|
|
1058
|
+
parts = [dialect.quote_ident(column.name), sql_type]
|
|
1059
|
+
if not column.autoincrement:
|
|
1060
|
+
# The autoincrement type already implies NOT NULL and brings its own value.
|
|
1061
|
+
if not column.nullable:
|
|
1062
|
+
parts.append("NOT NULL")
|
|
1063
|
+
expression = _default_expr(column, dialect)
|
|
1064
|
+
if expression is not None:
|
|
1065
|
+
parts.append(f"DEFAULT {expression}")
|
|
1066
|
+
if _comments_are_inline(dialect) and column.db_comment is not None:
|
|
1067
|
+
parts.append(f"COMMENT {dialect.literal(column.db_comment)}")
|
|
1068
|
+
# Uniqueness is NOT emitted here: it comes out as a NAMED constraint at table level
|
|
1069
|
+
# (see `emit_create_table`), so that the add and the drop use the same identifier.
|
|
1070
|
+
return " ".join(parts)
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _default_expr(column: SnakeColumnInfo, dialect: SnakeDialect) -> str | None:
|
|
1074
|
+
"""SQL expression of the `DEFAULT` of a column, or None if it has none.
|
|
1075
|
+
|
|
1076
|
+
Priority: `server_default` (enum → dialect), `server_default_sql` (raw NOT portable
|
|
1077
|
+
SQL), and last the client-side literal `default` (the dialect formats it). The three
|
|
1078
|
+
sources are mutually exclusive by construction (the descriptor guarantees it when
|
|
1079
|
+
the column is declared).
|
|
1080
|
+
"""
|
|
1081
|
+
if column.server_default is not None:
|
|
1082
|
+
return dialect.server_default_sql(column.server_default)
|
|
1083
|
+
if column.server_default_sql is not None:
|
|
1084
|
+
return column.server_default_sql
|
|
1085
|
+
if column.has_default:
|
|
1086
|
+
return dialect.literal(column.default)
|
|
1087
|
+
return None
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def _default_changed(old: SnakeColumnInfo, new: SnakeColumnInfo) -> bool:
|
|
1091
|
+
"""Tells whether the `DEFAULT` of a column changed (client literal or server)."""
|
|
1092
|
+
return (
|
|
1093
|
+
old.has_default != new.has_default
|
|
1094
|
+
or old.default != new.default
|
|
1095
|
+
or old.server_default != new.server_default
|
|
1096
|
+
or old.server_default_sql != new.server_default_sql
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _function_name(trigger: SnakeTriggerInfo) -> str:
|
|
1101
|
+
"""The name of the function a PostgreSQL trigger calls: the trigger's own, plus `_fn`.
|
|
1102
|
+
|
|
1103
|
+
Derived and not declared, so that dropping the trigger knows what to drop without carrying a
|
|
1104
|
+
second field that could disagree with the first.
|
|
1105
|
+
"""
|
|
1106
|
+
return f"{trigger.name}_fn"
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def _body_already_calls_a_function(body: str) -> bool:
|
|
1110
|
+
"""Whether the body is already the PostgreSQL shape, so wrapping it would nest a call in a call."""
|
|
1111
|
+
return body.strip().upper().startswith(("EXECUTE FUNCTION", "EXECUTE PROCEDURE"))
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def emit_create_trigger(trigger: SnakeTriggerInfo, dialect: SnakeDialect) -> list[str]:
|
|
1115
|
+
"""The statements that create a trigger. ONE on the inline engines, TWO on PostgreSQL.
|
|
1116
|
+
|
|
1117
|
+
The events are emitted in the ORDER DECLARED (reordering them adds noise to the diff without
|
|
1118
|
+
gaining anything).
|
|
1119
|
+
|
|
1120
|
+
THE BODY IS WRITTEN ONCE AND THE DIALECT SPELLS IT. PostgreSQL cannot hold statements in a
|
|
1121
|
+
trigger — it calls a function — while MySQL and SQLite cannot call one. Sending the body through
|
|
1122
|
+
verbatim made a declaration that ran on two engines fail on the third with `syntax error at or
|
|
1123
|
+
near "UPDATE"`, from the driver, about a token. Translating spelling is exactly what a dialect is
|
|
1124
|
+
for, and there is precedent one file over: SQLite has no `CREATE OR REPLACE VIEW` and the dialect
|
|
1125
|
+
rewrites it as `DROP` + `CREATE`.
|
|
1126
|
+
|
|
1127
|
+
A body that ALREADY calls a function is left alone. The dialect translates what needs
|
|
1128
|
+
translating; it does not second-guess somebody who wrote the target shape on purpose.
|
|
1129
|
+
"""
|
|
1130
|
+
quote = dialect.quote_ident
|
|
1131
|
+
events = " OR ".join(event.value for event in trigger.events)
|
|
1132
|
+
scope = "FOR EACH ROW" if trigger.for_each_row else "FOR EACH STATEMENT"
|
|
1133
|
+
table = qualified(trigger.schema, trigger.table, dialect)
|
|
1134
|
+
|
|
1135
|
+
before, body = dialect.trigger_statements(trigger.name, trigger.body)
|
|
1136
|
+
statements: list[str] = list(before)
|
|
1137
|
+
statements.append(
|
|
1138
|
+
f"CREATE TRIGGER {quote(trigger.name)} {trigger.timing.value} {events} "
|
|
1139
|
+
f"ON {table} {scope} {body}"
|
|
1140
|
+
)
|
|
1141
|
+
return statements
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def emit_drop_trigger(trigger: SnakeTriggerInfo, dialect: SnakeDialect) -> list[str]:
|
|
1145
|
+
"""The statements that drop a trigger, INCLUDING the function it was given on PostgreSQL.
|
|
1146
|
+
|
|
1147
|
+
In Postgres a trigger belongs to a table (its name is not unique), so the `ON` is needed; in
|
|
1148
|
+
SQLite it is a global object and the `ON` is invalid syntax. A difference of SYNTAX, not of
|
|
1149
|
+
capability, so it gets translated here (same criterion as the UNIQUE constraint → unique index).
|
|
1150
|
+
|
|
1151
|
+
And on PostgreSQL the function goes with it. `emit_create_trigger` wraps an inline body in one, so
|
|
1152
|
+
leaving it behind would be debris a rolled-back migration never cleans up — invisible until a name
|
|
1153
|
+
collides. The trigger goes FIRST: it depends on the function, and getting that order wrong fails
|
|
1154
|
+
only on rollback, which is the run nobody watches.
|
|
1155
|
+
|
|
1156
|
+
`IF EXISTS` on the function because a body that already called one was never wrapped, so there may
|
|
1157
|
+
be nothing of ours to drop.
|
|
1158
|
+
"""
|
|
1159
|
+
quote = dialect.quote_ident
|
|
1160
|
+
if not dialect.triggers_are_table_scoped:
|
|
1161
|
+
statements = [f"DROP TRIGGER {quote(trigger.name)}"]
|
|
1162
|
+
else:
|
|
1163
|
+
table = qualified(trigger.schema, trigger.table, dialect)
|
|
1164
|
+
statements = [f"DROP TRIGGER {quote(trigger.name)} ON {table}"]
|
|
1165
|
+
|
|
1166
|
+
# Whatever the dialect creates BEFORE the trigger has to come back down with it. Asking the
|
|
1167
|
+
# dialect again — rather than remembering what it did — is what keeps the two halves from
|
|
1168
|
+
# disagreeing the day an engine starts or stops needing one.
|
|
1169
|
+
before, _ = dialect.trigger_statements(trigger.name, trigger.body)
|
|
1170
|
+
if before:
|
|
1171
|
+
statements.append(f"DROP FUNCTION IF EXISTS {quote(_function_name(trigger))}()")
|
|
1172
|
+
return statements
|