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.
Files changed (198) hide show
  1. snake_orm-0.1.0b1.dist-info/METADATA +366 -0
  2. snake_orm-0.1.0b1.dist-info/RECORD +198 -0
  3. snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
  4. snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
  5. snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
  6. snakeorm/__init__.py +214 -0
  7. snakeorm/advisor.py +109 -0
  8. snakeorm/cli/__init__.py +3 -0
  9. snakeorm/cli/app.py +992 -0
  10. snakeorm/cli/discovery.py +179 -0
  11. snakeorm/cli/hooks.py +86 -0
  12. snakeorm/compiler/__init__.py +3 -0
  13. snakeorm/compiler/compiler.py +330 -0
  14. snakeorm/connection.py +228 -0
  15. snakeorm/contrib/__init__.py +17 -0
  16. snakeorm/contrib/asgi.py +192 -0
  17. snakeorm/contrib/config.py +264 -0
  18. snakeorm/contrib/deliver.py +256 -0
  19. snakeorm/contrib/django.py +245 -0
  20. snakeorm/contrib/sidecar.py +44 -0
  21. snakeorm/contrib/wsgi.py +176 -0
  22. snakeorm/core/__init__.py +1 -0
  23. snakeorm/core/config.py +173 -0
  24. snakeorm/core/converters.py +172 -0
  25. snakeorm/core/exceptions.py +175 -0
  26. snakeorm/core/placement.py +15 -0
  27. snakeorm/core/sentinels.py +37 -0
  28. snakeorm/core/signals.py +114 -0
  29. snakeorm/debug/__init__.py +42 -0
  30. snakeorm/debug/assets/css/panel.css +616 -0
  31. snakeorm/debug/assets/css/snake_orm_app.css +293 -0
  32. snakeorm/debug/assets/img/icon.svg +5 -0
  33. snakeorm/debug/assets/js/history.js +425 -0
  34. snakeorm/debug/assets/js/language.js +283 -0
  35. snakeorm/debug/assets/js/panel.js +241 -0
  36. snakeorm/debug/assets/js/snake_orm_app.js +82 -0
  37. snakeorm/debug/assets/pages/cli.html +28 -0
  38. snakeorm/debug/assets/pages/config.html +34 -0
  39. snakeorm/debug/assets/pages/database_first.html +29 -0
  40. snakeorm/debug/assets/pages/help.html +21 -0
  41. snakeorm/debug/assets/pages/history.html +27 -0
  42. snakeorm/debug/capture.py +234 -0
  43. snakeorm/debug/channel.py +131 -0
  44. snakeorm/debug/collector.py +144 -0
  45. snakeorm/debug/config.py +149 -0
  46. snakeorm/debug/html.py +578 -0
  47. snakeorm/debug/matrix.py +60 -0
  48. snakeorm/debug/origin.py +42 -0
  49. snakeorm/debug/otel/__init__.py +68 -0
  50. snakeorm/debug/otel/context.py +43 -0
  51. snakeorm/debug/otel/exporter.py +277 -0
  52. snakeorm/debug/otel/payload.py +97 -0
  53. snakeorm/debug/otel/report.py +29 -0
  54. snakeorm/debug/otel/spans.py +393 -0
  55. snakeorm/debug/otel/summary.py +59 -0
  56. snakeorm/debug/record.py +54 -0
  57. snakeorm/debug/report.py +311 -0
  58. snakeorm/debug/testing.py +23 -0
  59. snakeorm/decorators/__init__.py +13 -0
  60. snakeorm/decorators/abstract.py +26 -0
  61. snakeorm/decorators/db_first.py +104 -0
  62. snakeorm/decorators/function.py +28 -0
  63. snakeorm/decorators/model.py +268 -0
  64. snakeorm/decorators/polymorphic.py +140 -0
  65. snakeorm/decorators/result.py +167 -0
  66. snakeorm/decorators/row.py +90 -0
  67. snakeorm/decorators/trigger.py +47 -0
  68. snakeorm/decorators/view.py +149 -0
  69. snakeorm/dialects/__init__.py +6 -0
  70. snakeorm/dialects/base.py +373 -0
  71. snakeorm/dialects/capabilities.py +571 -0
  72. snakeorm/dialects/literals.py +46 -0
  73. snakeorm/dialects/matrix.py +384 -0
  74. snakeorm/dialects/mysql.py +519 -0
  75. snakeorm/dialects/postgres.py +504 -0
  76. snakeorm/dialects/sqlite.py +417 -0
  77. snakeorm/drivers/__init__.py +18 -0
  78. snakeorm/drivers/asyncbase.py +73 -0
  79. snakeorm/drivers/asyncdecorators.py +244 -0
  80. snakeorm/drivers/asyncpool.py +228 -0
  81. snakeorm/drivers/asyncpsycopg.py +124 -0
  82. snakeorm/drivers/asyncpymysql.py +50 -0
  83. snakeorm/drivers/asyncsqlite.py +30 -0
  84. snakeorm/drivers/base.py +76 -0
  85. snakeorm/drivers/failures.py +181 -0
  86. snakeorm/drivers/logging.py +167 -0
  87. snakeorm/drivers/pool.py +352 -0
  88. snakeorm/drivers/psycopg.py +188 -0
  89. snakeorm/drivers/pymysql.py +176 -0
  90. snakeorm/drivers/savepoints.py +46 -0
  91. snakeorm/drivers/sqlite.py +170 -0
  92. snakeorm/drivers/threaded.py +143 -0
  93. snakeorm/drivers/timeout.py +87 -0
  94. snakeorm/dto/__init__.py +55 -0
  95. snakeorm/dto/read.py +231 -0
  96. snakeorm/dto/region.py +326 -0
  97. snakeorm/dto/resolve.py +354 -0
  98. snakeorm/dto/spec.py +201 -0
  99. snakeorm/expressions/__init__.py +88 -0
  100. snakeorm/expressions/conditional.py +178 -0
  101. snakeorm/expressions/expression.py +810 -0
  102. snakeorm/expressions/functions.py +71 -0
  103. snakeorm/expressions/keys.py +299 -0
  104. snakeorm/expressions/paths.py +43 -0
  105. snakeorm/expressions/scalar.py +293 -0
  106. snakeorm/expressions/window.py +248 -0
  107. snakeorm/fields/__init__.py +68 -0
  108. snakeorm/fields/check.py +79 -0
  109. snakeorm/fields/column.py +316 -0
  110. snakeorm/fields/enum.py +64 -0
  111. snakeorm/fields/index.py +46 -0
  112. snakeorm/fields/relationship.py +738 -0
  113. snakeorm/fields/typed.py +736 -0
  114. snakeorm/helpers/__init__.py +1 -0
  115. snakeorm/helpers/annotations.py +21 -0
  116. snakeorm/helpers/inheritance.py +41 -0
  117. snakeorm/helpers/pyliteral.py +52 -0
  118. snakeorm/helpers/pytype.py +50 -0
  119. snakeorm/introspection/__init__.py +13 -0
  120. snakeorm/introspection/base.py +30 -0
  121. snakeorm/introspection/drift.py +78 -0
  122. snakeorm/introspection/models.py +633 -0
  123. snakeorm/introspection/mysql.py +367 -0
  124. snakeorm/introspection/postgres.py +397 -0
  125. snakeorm/introspection/sqlite.py +217 -0
  126. snakeorm/introspection/unsupported.py +94 -0
  127. snakeorm/linker/__init__.py +3 -0
  128. snakeorm/linker/linker.py +521 -0
  129. snakeorm/metadata/__init__.py +41 -0
  130. snakeorm/metadata/check.py +37 -0
  131. snakeorm/metadata/column.py +201 -0
  132. snakeorm/metadata/enum_storage.py +33 -0
  133. snakeorm/metadata/fk_action.py +19 -0
  134. snakeorm/metadata/foreign_key.py +26 -0
  135. snakeorm/metadata/index.py +43 -0
  136. snakeorm/metadata/index_method.py +19 -0
  137. snakeorm/metadata/int_size.py +20 -0
  138. snakeorm/metadata/json_storage.py +21 -0
  139. snakeorm/metadata/polymorphic.py +30 -0
  140. snakeorm/metadata/primary_key.py +23 -0
  141. snakeorm/metadata/relationship.py +60 -0
  142. snakeorm/metadata/relationship_kind.py +43 -0
  143. snakeorm/metadata/routine.py +26 -0
  144. snakeorm/metadata/server_default.py +20 -0
  145. snakeorm/metadata/table.py +97 -0
  146. snakeorm/metadata/table_kind.py +23 -0
  147. snakeorm/metadata/trigger.py +62 -0
  148. snakeorm/metadata/type_params.py +348 -0
  149. snakeorm/migration/__init__.py +88 -0
  150. snakeorm/migration/asyncrunner.py +168 -0
  151. snakeorm/migration/autodetect.py +96 -0
  152. snakeorm/migration/ddl.py +1172 -0
  153. snakeorm/migration/diff.py +701 -0
  154. snakeorm/migration/loader.py +77 -0
  155. snakeorm/migration/operations.py +952 -0
  156. snakeorm/migration/planner.py +78 -0
  157. snakeorm/migration/realize.py +420 -0
  158. snakeorm/migration/renames.py +153 -0
  159. snakeorm/migration/render.py +1095 -0
  160. snakeorm/migration/runner.py +314 -0
  161. snakeorm/migration/squash.py +60 -0
  162. snakeorm/migration/state.py +107 -0
  163. snakeorm/model.py +162 -0
  164. snakeorm/py.typed +0 -0
  165. snakeorm/query/__init__.py +9 -0
  166. snakeorm/query/compound.py +288 -0
  167. snakeorm/query/join_kind.py +19 -0
  168. snakeorm/query/joined.py +143 -0
  169. snakeorm/query/query.py +1001 -0
  170. snakeorm/query/recursive.py +244 -0
  171. snakeorm/registry/__init__.py +5 -0
  172. snakeorm/registry/by_module.py +163 -0
  173. snakeorm/registry/registry.py +166 -0
  174. snakeorm/session/__init__.py +8 -0
  175. snakeorm/session/asyncsession.py +638 -0
  176. snakeorm/session/coercion.py +306 -0
  177. snakeorm/session/factory.py +29 -0
  178. snakeorm/session/guards.py +269 -0
  179. snakeorm/session/isolation.py +23 -0
  180. snakeorm/session/mapper.py +213 -0
  181. snakeorm/session/planning.py +815 -0
  182. snakeorm/session/retry.py +96 -0
  183. snakeorm/session/session.py +871 -0
  184. snakeorm/session/shared.py +355 -0
  185. snakeorm/sql/__init__.py +18 -0
  186. snakeorm/sql/adapt.py +104 -0
  187. snakeorm/sql/aggregate.py +232 -0
  188. snakeorm/sql/condition.py +359 -0
  189. snakeorm/sql/delete.py +46 -0
  190. snakeorm/sql/insert.py +149 -0
  191. snakeorm/sql/joins.py +158 -0
  192. snakeorm/sql/pk_subquery.py +54 -0
  193. snakeorm/sql/refs.py +19 -0
  194. snakeorm/sql/resolver.py +28 -0
  195. snakeorm/sql/select.py +175 -0
  196. snakeorm/sql/update.py +82 -0
  197. snakeorm/sql/value.py +552 -0
  198. 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