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,1095 @@
1
+ """Serialization of the migration history to Python SOURCE CODE.
2
+
3
+ Key decision: `python_type` is a Python `type`; serializing it to JSON would demand a name↔type
4
+ registry (a second, worse type system). In `.py` it is an import and a reference: "if the type
5
+ comes from Python, the history is written in Python".
6
+
7
+ `render_migration` returns the text of a file that, on import, rebuilds the operations EXACTLY.
8
+ It is readable and editable: tables are pulled out into named variables and reused; every object
9
+ is rendered imitating `ruff format` (multiline if it does not fit in 88 columns, double quotes,
10
+ default-valued fields omitted), so it passes `ruff check`/`ruff format --check` as is. A
11
+ non-literal `default`, or a lambda/closure `default_factory` (no import path), is rejected loudly.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import keyword
17
+ import re
18
+ from collections.abc import Callable, Sequence
19
+ from dataclasses import dataclass, field, fields
20
+ from decimal import Decimal
21
+ from enum import Enum
22
+ from typing import get_args, get_origin
23
+
24
+ from snakeorm.core.placement import DEFAULT_SCHEMA
25
+ from snakeorm.core.exceptions import SnakeEmitError, SnakeModelDefinitionError
26
+ from snakeorm.expressions import (
27
+ SnakeAnd,
28
+ SnakeArith,
29
+ SnakeComparison,
30
+ SnakeCondition,
31
+ SnakeExpr,
32
+ SnakeInList,
33
+ SnakeIsNotNull,
34
+ SnakeIsNull,
35
+ SnakeLike,
36
+ SnakeNot,
37
+ SnakeOr,
38
+ SnakeValue,
39
+ )
40
+ from snakeorm.metadata import (
41
+ SnakeCheckInfo,
42
+ SnakeColumnInfo,
43
+ SnakeFkAction,
44
+ SnakeForeignKeyInfo,
45
+ SnakeIndexInfo,
46
+ SnakePrimaryKeyInfo,
47
+ SnakeRelationshipInfo,
48
+ SnakeRoutineInfo,
49
+ SnakeServerDefault,
50
+ SnakeTableInfo,
51
+ SnakeTableKind,
52
+ SnakeTriggerInfo,
53
+ SnakeTypeParams,
54
+ )
55
+ from snakeorm.helpers.pyliteral import str_lit
56
+ from snakeorm.migration.ddl import view_fingerprint
57
+ from snakeorm.migration.operations import (
58
+ AddCheck,
59
+ AddColumn,
60
+ AddForeignKey,
61
+ AlterColumn,
62
+ AlterTableComment,
63
+ AlterFunction,
64
+ AlterTrigger,
65
+ AlterView,
66
+ CreateFunction,
67
+ CreateIndex,
68
+ CreateSchema,
69
+ CreateTable,
70
+ CreateTrigger,
71
+ CreateView,
72
+ DropCheck,
73
+ DropColumn,
74
+ DropForeignKey,
75
+ DropFunction,
76
+ DropIndex,
77
+ DropSchema,
78
+ DropTable,
79
+ DropTrigger,
80
+ DropView,
81
+ RebuildTable,
82
+ RenameColumn,
83
+ RenameTable,
84
+ RunPython,
85
+ RunSQL,
86
+ SnakeMigrationOperation,
87
+ )
88
+
89
+ _MAX_LINE = 88 # the same default width that `ruff format` uses
90
+ _INDENT = 4
91
+
92
+ # The parameter families come from the UNION, not from this list. `SnakeDateTimeParams` was born
93
+ # with the date family and nobody added it here, so every migration with a date column generated a
94
+ # file carrying a `NameError`: the name got rendered and never imported. Deriving them makes a new
95
+ # family walk in on its own, which is the only thing that keeps this from happening twice.
96
+ _PARAM_CLASSES = frozenset(family.__name__ for family in get_args(SnakeTypeParams))
97
+
98
+ _META_CLASSES = _PARAM_CLASSES | frozenset(
99
+ {
100
+ "SnakeCheckInfo",
101
+ "SnakeColumnInfo",
102
+ "SnakeEnumStorage",
103
+ "SnakeFkAction",
104
+ "SnakeForeignKeyInfo",
105
+ "SnakeIndexInfo",
106
+ "SnakeIndexMethod",
107
+ "SnakeIntSize",
108
+ "SnakeJsonStorage",
109
+ "SnakePrimaryKeyInfo",
110
+ "SnakeRelationshipInfo",
111
+ "SnakeRoutineInfo",
112
+ "SnakeServerDefault",
113
+ "SnakeTriggerEvent",
114
+ "SnakeTriggerInfo",
115
+ "SnakeTriggerTiming",
116
+ "SnakeTableInfo",
117
+ "SnakeTableKind",
118
+ "SnakeRelationshipKind",
119
+ }
120
+ )
121
+ # Nodes of the boolean AST that `build_condition` knows how to write (the ones that fit in a CHECK
122
+ # or in the WHERE of a partial index). They are imported from `snakeorm.expressions`, not `metadata`.
123
+ _EXPR_CLASSES = frozenset(
124
+ {
125
+ "SnakeAnd",
126
+ "SnakeArith",
127
+ "SnakeArithOp",
128
+ "SnakeComparison",
129
+ "SnakeExpr",
130
+ "SnakeInList",
131
+ "SnakeIsNotNull",
132
+ "SnakeIsNull",
133
+ "SnakeLike",
134
+ "SnakeNot",
135
+ "SnakeOp",
136
+ "SnakeOr",
137
+ }
138
+ )
139
+ _OP_CLASSES = frozenset(
140
+ {
141
+ "AddCheck",
142
+ "AddColumn",
143
+ "AddForeignKey",
144
+ "AlterColumn",
145
+ "AlterFunction",
146
+ "AlterTableComment",
147
+ "AlterTrigger",
148
+ "AlterView",
149
+ "CreateFunction",
150
+ "CreateTrigger",
151
+ "CreateIndex",
152
+ "CreateSchema",
153
+ "CreateTable",
154
+ "CreateView",
155
+ "DropCheck",
156
+ "DropColumn",
157
+ "DropForeignKey",
158
+ "DropFunction",
159
+ "DropTrigger",
160
+ "DropIndex",
161
+ "DropSchema",
162
+ "DropTable",
163
+ "DropView",
164
+ "RebuildTable",
165
+ "RenameColumn",
166
+ "RenameTable",
167
+ "RunPython",
168
+ "RunSQL",
169
+ }
170
+ )
171
+ # Names a table variable may NEVER take (they would collide with the file's own bindings).
172
+ _RESERVED = frozenset(
173
+ {"version", "operations", "migration", "Migration"} | _META_CLASSES | _OP_CLASSES
174
+ )
175
+
176
+
177
+ # -- rendering IR: a small tree flattened to text imitating `ruff format` -------------------
178
+
179
+
180
+ @dataclass(frozen=True)
181
+ class _Scalar:
182
+ """A leaf that is already text (an `int`, a literal, a reference to a variable)."""
183
+
184
+ text: str
185
+
186
+
187
+ @dataclass(frozen=True)
188
+ class _Tuple:
189
+ """A literal tuple `(a, b, ...)`."""
190
+
191
+ items: tuple[_Node, ...]
192
+
193
+
194
+ @dataclass(frozen=True)
195
+ class _Call:
196
+ """A call `Name(pos0, pos1, kw=value, ...)`."""
197
+
198
+ name: str
199
+ args: tuple[_Node, ...] = ()
200
+ kwargs: tuple[tuple[str, _Node], ...] = field(default_factory=tuple)
201
+
202
+
203
+ _Node = _Scalar | _Tuple | _Call
204
+
205
+
206
+ def _inline(node: _Node) -> str:
207
+ """Renders the node on ONE single line (without deciding whether it fits: just the compact form)."""
208
+ if isinstance(node, _Scalar):
209
+ return node.text
210
+ if isinstance(node, _Tuple):
211
+ inner = ", ".join(_inline(item) for item in node.items)
212
+ if len(node.items) == 1:
213
+ inner += "," # the comma is mandatory in a one-element tuple
214
+ return f"({inner})"
215
+ parts = [_inline(arg) for arg in node.args]
216
+ parts.extend(f"{name}={_inline(value)}" for name, value in node.kwargs)
217
+ return f"{node.name}({', '.join(parts)})"
218
+
219
+
220
+ def _format(node: _Node, indent: int, col: int, tail: int) -> str:
221
+ """Renders the node fitting into 88 columns: inline if it fits, exploded one per line if not.
222
+
223
+ `indent` is the indentation of the line; `col` the column where the node starts; `tail` the
224
+ characters trailing the node (the comma, say). When exploding it adds a trailing comma (magic
225
+ comma) so as to stay stable against `ruff format --check`.
226
+ """
227
+ inline = _inline(node)
228
+ if col + len(inline) + tail <= _MAX_LINE:
229
+ return inline
230
+
231
+ child_indent = indent + _INDENT
232
+ pad = " " * child_indent
233
+ if isinstance(node, _Tuple):
234
+ lines = [
235
+ f"{pad}{_format(item, child_indent, child_indent, 1)},"
236
+ for item in node.items
237
+ ]
238
+ return "(\n" + "\n".join(lines) + f"\n{' ' * indent})"
239
+ if isinstance(node, _Call):
240
+ lines = [
241
+ f"{pad}{_format(arg, child_indent, child_indent, 1)}," for arg in node.args
242
+ ]
243
+ for name, value in node.kwargs:
244
+ prefix = f"{name}="
245
+ rendered = _format(value, child_indent, child_indent + len(prefix), 1)
246
+ lines.append(f"{pad}{prefix}{rendered},")
247
+ return f"{node.name}(\n" + "\n".join(lines) + f"\n{' ' * indent})"
248
+ return inline # a _Scalar that is too long simply cannot be split
249
+
250
+
251
+ _str_lit = str_lit
252
+ """Literal encoder, sitting in `snakeorm.pyliteral` so that the scaffolding shares the very same one
253
+ (it heads off a second encoder built out of f-strings, which opened code execution on import)."""
254
+
255
+
256
+ def _identifier(name: str) -> str:
257
+ """Sanitizes a table name into a valid Python identifier to name its variable."""
258
+ cleaned = re.sub(r"\W", "_", name)
259
+ if not cleaned or cleaned[0].isdigit():
260
+ cleaned = f"t_{cleaned}"
261
+ if keyword.iskeyword(cleaned):
262
+ cleaned = f"{cleaned}_"
263
+ return cleaned
264
+
265
+
266
+ class _Renderer:
267
+ """Accumulates the imports it needs while translating the metadata graph into the rendering IR."""
268
+
269
+ def __init__(self) -> None:
270
+ self._imports: set[str] = set() # `import X` statements, stdlib or user code
271
+ self._used: set[str] = set() # snakeorm class names that turn up
272
+
273
+ # -- leaf types and values ------------------------------------------------
274
+
275
+ def render_type(self, python_type: object) -> str:
276
+ """Reference to a type along with its import (`int`, `uuid.UUID`, `list[str]`, ...).
277
+
278
+ GENERIC ALIASES are rendered recursively together with their arguments: `list[str]` is not a
279
+ `type` but a `GenericAlias`, and reading its `__qualname__` gave a bare `"list"` (the cycle
280
+ quietly stopped closing). The recursion also REGISTERS the import of every argument (`uuid`
281
+ in `list[uuid.UUID]`).
282
+ """
283
+ origin = get_origin(python_type)
284
+ if origin is not None:
285
+ args = ", ".join(self.render_type(arg) for arg in get_args(python_type))
286
+ return f"{self.render_type(origin)}[{args}]"
287
+
288
+ if not isinstance(python_type, type):
289
+ raise SnakeModelDefinitionError(
290
+ f"The type {python_type!r} is not renderable in a migration. Types and generic "
291
+ f"aliases (`list[str]`, `dict[str, int]`) are accepted."
292
+ )
293
+
294
+ module = python_type.__module__
295
+ qualname = python_type.__qualname__
296
+ if module == "builtins":
297
+ return qualname # int, str, bool... always in scope, no import
298
+ self._imports.add(f"import {module}")
299
+ return f"{module}.{qualname}"
300
+
301
+ def render_default(self, value: object) -> str:
302
+ """Reference to a DDL literal. Literals only; anything else gets rejected.
303
+
304
+ An ENUM member is unwrapped down to its value: its `repr` (`<Priority.NORMAL: 1>`) is not
305
+ valid Python, and in the DDL the DEFAULT is the value anyway.
306
+ """
307
+ if isinstance(value, Enum):
308
+ value = value.value
309
+ if value is None or isinstance(value, bool):
310
+ return repr(value) # None/True/False do not hang on quoting
311
+ if isinstance(value, (int, float)):
312
+ return repr(value)
313
+ if isinstance(value, str):
314
+ return _str_lit(value)
315
+ if isinstance(value, Decimal):
316
+ self._imports.add("import decimal")
317
+ return f"decimal.Decimal({_str_lit(str(value))})"
318
+ raise SnakeModelDefinitionError(
319
+ f"The default {value!r} is not renderable: DDL DEFAULTs must be literals "
320
+ f"(None, bool, int, float, str, Decimal). For a computed value use default_factory."
321
+ )
322
+
323
+ def render_factory(self, factory: Callable[..., object]) -> str:
324
+ """Importable reference to a callable by its module + qualname (or class + method).
325
+
326
+ It serves `default_factory` and the `forward`/`backward` of a `RunPython`: it is never
327
+ INVOKED, only its import attributes get read, so the signature is moot (`Callable[..., object]`).
328
+ """
329
+ # Pure reflection (`getattr` would hand back `Any`): it is anchored to `object` and narrowed
330
+ # with `isinstance` to return a checked `str`, not an `Any` in disguise. House rule: never `Any`.
331
+ bound_to: object = getattr(factory, "__self__", None)
332
+ name: object = getattr(factory, "__name__", None)
333
+ if isinstance(bound_to, type) and isinstance(name, str) and name:
334
+ # Builtin method bound to a class (datetime.datetime.now, say): its __module__
335
+ # is None, but __self__ hands over the class and __name__ the method.
336
+ return f"{self.render_type(bound_to)}.{name}"
337
+ module: object = getattr(factory, "__module__", None)
338
+ qualname: object = getattr(factory, "__qualname__", None)
339
+ if (
340
+ not isinstance(module, str)
341
+ or not isinstance(qualname, str)
342
+ or "<lambda>" in qualname
343
+ or "<locals>" in qualname
344
+ ):
345
+ raise SnakeModelDefinitionError(
346
+ f"default_factory {factory!r} is not importable: a lambda or a closure has no "
347
+ f"import path and cannot be written into a migration file. Use a module-level "
348
+ f"function (for instance datetime.datetime.now)."
349
+ )
350
+ if module == "builtins":
351
+ return qualname
352
+ self._imports.add(f"import {module}")
353
+ return f"{module}.{qualname}"
354
+
355
+ def build_condition(self, node: object) -> _Node:
356
+ """Serializes a node of the boolean AST into Python code, recursively.
357
+
358
+ It is what allows a CONDITION (CHECK, partial indexes) to be saved in a readable migration
359
+ file. Whatever cannot be rendered (a subquery, EXISTS) is rejected loudly: it does not fit
360
+ in a Postgres CHECK either, so this is correctness, not a shortcoming.
361
+ """
362
+ if isinstance(node, SnakeExpr):
363
+ self._used.add("SnakeExpr")
364
+ path = _Tuple(tuple(_Scalar(_str_lit(step)) for step in node.path))
365
+ return _Call("SnakeExpr", kwargs=(("path", path),))
366
+ if isinstance(node, SnakeArith):
367
+ self._used.add("SnakeArith")
368
+ self._used.add("SnakeArithOp")
369
+ return _Call(
370
+ "SnakeArith",
371
+ args=(
372
+ self.build_condition(node.left),
373
+ _Scalar(f"SnakeArithOp.{node.op.name}"),
374
+ self.build_condition(node.right),
375
+ ),
376
+ )
377
+ if isinstance(node, SnakeComparison):
378
+ self._used.add("SnakeComparison")
379
+ self._used.add("SnakeOp")
380
+ return _Call(
381
+ "SnakeComparison",
382
+ args=(
383
+ self.build_condition(node.left),
384
+ _Scalar(f"SnakeOp.{node.op.name}"),
385
+ self.build_condition(node.right),
386
+ ),
387
+ )
388
+ if isinstance(node, (SnakeAnd, SnakeOr)):
389
+ name = type(node).__name__
390
+ self._used.add(name)
391
+ parts = _Tuple(tuple(self.build_condition(part) for part in node.parts))
392
+ return _Call(name, kwargs=(("parts", parts),))
393
+ if isinstance(node, SnakeNot):
394
+ self._used.add("SnakeNot")
395
+ return _Call(
396
+ "SnakeNot", kwargs=(("operand", self.build_condition(node.operand)),)
397
+ )
398
+ if isinstance(node, SnakeInList):
399
+ self._used.add("SnakeInList")
400
+ values = _Tuple(tuple(self.build_condition(value) for value in node.values))
401
+ return _Call("SnakeInList", args=(self.build_condition(node.left), values))
402
+ if isinstance(node, (SnakeIsNull, SnakeIsNotNull)):
403
+ name = type(node).__name__
404
+ self._used.add(name)
405
+ return _Call(name, args=(self.build_condition(node.left),))
406
+ if isinstance(node, SnakeLike):
407
+ self._used.add("SnakeLike")
408
+ arguments = [
409
+ self.build_condition(node.left),
410
+ _Scalar(_str_lit(node.pattern)),
411
+ ]
412
+ kwargs: tuple[tuple[str, _Node], ...] = ()
413
+ if node.case_insensitive:
414
+ kwargs = (("case_insensitive", _Scalar("True")),)
415
+ return _Call("SnakeLike", args=tuple(arguments), kwargs=kwargs)
416
+ if isinstance(node, (SnakeCondition, SnakeValue)):
417
+ raise SnakeModelDefinitionError(
418
+ f"{type(node).__name__} cannot be written into a migration file: it carries a "
419
+ f"subquery or a correlation, which cannot be rebuilt from standalone code (and "
420
+ f"which a Postgres CHECK does not accept either). Use a condition over the table's "
421
+ f"own columns."
422
+ )
423
+ # Anything else is a LITERAL (the right-hand side of a comparison, a value inside an IN).
424
+ return _Scalar(self.render_default(node))
425
+
426
+ def render_fk_action(self, action: SnakeFkAction) -> str:
427
+ """Reference to an enum member by its name (`SnakeFkAction.CASCADE`)."""
428
+ self._used.add("SnakeFkAction")
429
+ return f"SnakeFkAction.{action.name}"
430
+
431
+ def render_server_default(self, value: SnakeServerDefault) -> str:
432
+ """Reference to an enum member by its name (`SnakeServerDefault.NOW`)."""
433
+ self._used.add("SnakeServerDefault")
434
+ return f"SnakeServerDefault.{value.name}"
435
+
436
+ def render_type_params(self, params: SnakeTypeParams) -> str:
437
+ """The family's parameters, exactly as they are built (`SnakeDecimalParams(12, 2)`).
438
+
439
+ Generic on purpose: it walks the fields of the dataclass, so a NEW family renders itself
440
+ without touching any of this. The only thing to remember is to put its class into
441
+ `_META_CLASSES` so that it shows up in the import block of the generated file.
442
+ """
443
+ name = type(params).__name__
444
+ self._used.add(name)
445
+ args = ", ".join(
446
+ f"{field.name}={self._render_param(getattr(params, field.name))}"
447
+ for field in fields(params)
448
+ )
449
+ return f"{name}({args})"
450
+
451
+ def _render_param(self, value: object) -> str:
452
+ """A parameter value: an enum member by its name, or its `repr` if it is a scalar."""
453
+ if isinstance(value, Enum):
454
+ self._used.add(type(value).__name__)
455
+ return f"{type(value).__name__}.{value.name}"
456
+ return repr(value)
457
+
458
+ # -- graph structures (they build IR nodes) -------------------------------
459
+
460
+ def build_column(self, column: SnakeColumnInfo) -> _Call:
461
+ """Node for a SnakeColumnInfo, leaving out the fields that equal their default."""
462
+ self._used.add("SnakeColumnInfo")
463
+ kwargs: list[tuple[str, _Node]] = [
464
+ ("name", _Scalar(_str_lit(column.name))),
465
+ ("python_type", _Scalar(self.render_type(column.python_type))),
466
+ ]
467
+ if column.nullable:
468
+ kwargs.append(("nullable", _Scalar("True")))
469
+ if column.unique:
470
+ kwargs.append(("unique", _Scalar("True")))
471
+ if column.has_default:
472
+ kwargs.append(("default", _Scalar(self.render_default(column.default))))
473
+ kwargs.append(("has_default", _Scalar("True")))
474
+ if column.index:
475
+ kwargs.append(("index", _Scalar("True")))
476
+ if column.db_comment is not None:
477
+ kwargs.append(("db_comment", _Scalar(_str_lit(column.db_comment))))
478
+ if column.attr_name:
479
+ kwargs.append(("attr_name", _Scalar(_str_lit(column.attr_name))))
480
+ if column.autoincrement:
481
+ kwargs.append(("autoincrement", _Scalar("True")))
482
+ if column.default_factory is not None:
483
+ kwargs.append(
484
+ (
485
+ "default_factory",
486
+ _Scalar(self.render_factory(column.default_factory)),
487
+ )
488
+ )
489
+ if column.server_default is not None:
490
+ kwargs.append(
491
+ (
492
+ "server_default",
493
+ _Scalar(self.render_server_default(column.server_default)),
494
+ )
495
+ )
496
+ if (params := column.type_params) is not None:
497
+ kwargs.append(("type_params", _Scalar(self.render_type_params(params))))
498
+ if column.enum_type is not None:
499
+ kwargs.append(("enum_type", _Scalar(self.render_type(column.enum_type))))
500
+ if column.enum_storage is not None:
501
+ self._used.add("SnakeEnumStorage")
502
+ kwargs.append(
503
+ (
504
+ "enum_storage",
505
+ _Scalar(f"SnakeEnumStorage.{column.enum_storage.name}"),
506
+ )
507
+ )
508
+ if column.server_default_sql is not None:
509
+ kwargs.append(
510
+ ("server_default_sql", _Scalar(_str_lit(column.server_default_sql)))
511
+ )
512
+ return _Call("SnakeColumnInfo", kwargs=tuple(kwargs))
513
+
514
+ def build_primary_key(self, pk: SnakePrimaryKeyInfo) -> _Call:
515
+ """Node for a SnakePrimaryKeyInfo (it repeats the columns for readability; see the module)."""
516
+ self._used.add("SnakePrimaryKeyInfo")
517
+ columns = _Tuple(tuple(self.build_column(column) for column in pk.columns))
518
+ return _Call("SnakePrimaryKeyInfo", kwargs=(("columns", columns),))
519
+
520
+ def build_check(self, check: SnakeCheckInfo) -> _Call:
521
+ """Node for a SnakeCheckInfo: its serialized condition plus the name if it is explicit."""
522
+ self._used.add("SnakeCheckInfo")
523
+ kwargs: list[tuple[str, _Node]] = [
524
+ ("condition", self.build_condition(check.condition))
525
+ ]
526
+ if check.name is not None:
527
+ kwargs.append(("name", _Scalar(_str_lit(check.name))))
528
+ return _Call("SnakeCheckInfo", kwargs=tuple(kwargs))
529
+
530
+ def build_index(self, index: SnakeIndexInfo) -> _Call:
531
+ """Node for a SnakeIndexInfo."""
532
+ self._used.add("SnakeIndexInfo")
533
+ columns = _Tuple(tuple(_Scalar(_str_lit(name)) for name in index.columns))
534
+ kwargs: list[tuple[str, _Node]] = [("columns", columns)]
535
+ if index.unique:
536
+ kwargs.append(("unique", _Scalar("True")))
537
+ if index.name is not None:
538
+ kwargs.append(("name", _Scalar(_str_lit(index.name))))
539
+ if index.where is not None:
540
+ kwargs.append(("where", self.build_condition(index.where)))
541
+ if index.method is not None:
542
+ self._used.add("SnakeIndexMethod")
543
+ kwargs.append(("method", _Scalar(f"SnakeIndexMethod.{index.method.name}")))
544
+ return _Call("SnakeIndexInfo", kwargs=tuple(kwargs))
545
+
546
+ def build_foreign_key(self, fk: SnakeForeignKeyInfo) -> _Call:
547
+ """Node for a SnakeForeignKeyInfo (target + pairs + actions)."""
548
+ self._used.add("SnakeForeignKeyInfo")
549
+ pairs = _Tuple(
550
+ tuple(
551
+ _Tuple((_Scalar(_str_lit(local)), _Scalar(_str_lit(remote))))
552
+ for local, remote in fk.pairs
553
+ )
554
+ )
555
+ kwargs: list[tuple[str, _Node]] = [
556
+ ("target", _Scalar(_str_lit(fk.target))),
557
+ ("pairs", pairs),
558
+ ]
559
+ if fk.on_delete is not SnakeFkAction.NO_ACTION:
560
+ kwargs.append(("on_delete", _Scalar(self.render_fk_action(fk.on_delete))))
561
+ if fk.on_update is not SnakeFkAction.NO_ACTION:
562
+ kwargs.append(("on_update", _Scalar(self.render_fk_action(fk.on_update))))
563
+ return _Call("SnakeForeignKeyInfo", kwargs=tuple(kwargs))
564
+
565
+ def build_relationship(self, rel: SnakeRelationshipInfo) -> _Call:
566
+ """Node for a SnakeRelationshipInfo."""
567
+ self._used.add("SnakeRelationshipInfo")
568
+ # Symbolic (`SnakeRelationshipKind.TO_ONE`), not the string: rename the member and the file
569
+ # stops compiling instead of loading a string with no meaning. Histories carrying the old
570
+ # string keep loading (`coerce` converts them).
571
+ self._used.add("SnakeRelationshipKind")
572
+ kwargs: list[tuple[str, _Node]] = [
573
+ ("name", _Scalar(_str_lit(rel.name))),
574
+ ("target", _Scalar(_str_lit(rel.target))),
575
+ ("kind", _Scalar(f"SnakeRelationshipKind.{rel.kind.name}")),
576
+ ("foreign_key", self.build_foreign_key(rel.foreign_key)),
577
+ ]
578
+ if rel.target_table:
579
+ # The target ALREADY RESOLVED (`schema.table`): it makes the migration SELF-CONTAINED —no
580
+ # need to import the target model on apply to resolve the FK by name— and it allows the FK to
581
+ # be INLINED inside the `CREATE TABLE` on SQLite (which takes no later `ADD CONSTRAINT`).
582
+ kwargs.append(("target_table", _Scalar(_str_lit(rel.target_table))))
583
+ return _Call("SnakeRelationshipInfo", kwargs=tuple(kwargs))
584
+
585
+ def build_routine(self, routine: SnakeRoutineInfo) -> _Call:
586
+ """Node for a SnakeRoutineInfo (name + raw body; it is rendered inline in the operation)."""
587
+ self._used.add("SnakeRoutineInfo")
588
+ kwargs: list[tuple[str, _Node]] = [
589
+ ("name", _Scalar(_str_lit(routine.name))),
590
+ ("body", _Scalar(_str_lit(routine.body))),
591
+ ]
592
+ if routine.schema != DEFAULT_SCHEMA:
593
+ kwargs.append(("schema", _Scalar(_str_lit(routine.schema))))
594
+ return _Call("SnakeRoutineInfo", kwargs=tuple(kwargs))
595
+
596
+ def build_trigger(self, trigger: SnakeTriggerInfo) -> _Call:
597
+ """Node for a SnakeTriggerInfo. Enums are rendered by their member (`SnakeTriggerTiming.AFTER`,
598
+ not `"AFTER"`): rename the member and the file stops compiling. Same as with `SnakeFkAction`."""
599
+ self._used.update(
600
+ {"SnakeTriggerInfo", "SnakeTriggerTiming", "SnakeTriggerEvent"}
601
+ )
602
+ events = _Tuple(
603
+ tuple(
604
+ _Scalar(f"SnakeTriggerEvent.{event.name}") for event in trigger.events
605
+ )
606
+ )
607
+ kwargs: list[tuple[str, _Node]] = [
608
+ ("name", _Scalar(_str_lit(trigger.name))),
609
+ ("table", _Scalar(_str_lit(trigger.table))),
610
+ ("timing", _Scalar(f"SnakeTriggerTiming.{trigger.timing.name}")),
611
+ ("events", events),
612
+ ("body", _Scalar(_str_lit(trigger.body))),
613
+ ]
614
+ if trigger.schema != DEFAULT_SCHEMA:
615
+ kwargs.append(("schema", _Scalar(_str_lit(trigger.schema))))
616
+ if not trigger.for_each_row:
617
+ kwargs.append(("for_each_row", _Scalar("False")))
618
+ return _Call("SnakeTriggerInfo", kwargs=tuple(kwargs))
619
+
620
+ def build_table(self, table: SnakeTableInfo) -> _Call:
621
+ """Node for a complete SnakeTableInfo (what gets assigned to a named variable)."""
622
+ self._used.add("SnakeTableInfo")
623
+ columns = _Tuple(tuple(self.build_column(column) for column in table.columns))
624
+ kwargs: list[tuple[str, _Node]] = [
625
+ ("name", _Scalar(_str_lit(table.name))),
626
+ ("columns", columns),
627
+ ("primary_key", self.build_primary_key(table.primary_key)),
628
+ ]
629
+ if table.relationships:
630
+ rels = _Tuple(
631
+ tuple(self.build_relationship(rel) for rel in table.relationships)
632
+ )
633
+ kwargs.append(("relationships", rels))
634
+ if table.checks:
635
+ checks = _Tuple(tuple(self.build_check(check) for check in table.checks))
636
+ kwargs.append(("checks", checks))
637
+ if table.database != "default":
638
+ kwargs.append(("database", _Scalar(_str_lit(table.database))))
639
+ if table.schema != DEFAULT_SCHEMA:
640
+ kwargs.append(("schema", _Scalar(_str_lit(table.schema))))
641
+ if table.db_comment is not None:
642
+ kwargs.append(("db_comment", _Scalar(_str_lit(table.db_comment))))
643
+ if table.indexes:
644
+ indexes = _Tuple(tuple(self.build_index(index) for index in table.indexes))
645
+ kwargs.append(("indexes", indexes))
646
+ if table.kind is not SnakeTableKind.TABLE:
647
+ self._used.add("SnakeTableKind")
648
+ kwargs.append(("kind", _Scalar(f"SnakeTableKind.{table.kind.name}")))
649
+ if table.kind is SnakeTableKind.VIEW:
650
+ # What goes into the migration file is always the FINGERPRINT, that is SQL already
651
+ # compiled with the canonical dialect, even if the view was declared with `query=`. The
652
+ # snapshot has to be reproducible: the same migration from any machine and without
653
+ # depending on the local engine. A `SnakeQuery` serialized into the file would tie the
654
+ # migration to the version of the model, which is just what a snapshot exists NOT to do.
655
+ kwargs.append(
656
+ ("view_definition", _Scalar(_str_lit(view_fingerprint(table))))
657
+ )
658
+ if table.depends_on:
659
+ depends = _Tuple(
660
+ tuple(_Scalar(_str_lit(name)) for name in table.depends_on)
661
+ )
662
+ kwargs.append(("depends_on", depends))
663
+ return _Call("SnakeTableInfo", kwargs=tuple(kwargs))
664
+
665
+ def build_operation(
666
+ self,
667
+ operation: SnakeMigrationOperation,
668
+ var_of: Callable[[SnakeTableInfo], str],
669
+ ) -> _Call:
670
+ """Node for an operation, referencing the tables by their named variable."""
671
+ if isinstance(operation, CreateTable):
672
+ self._used.add("CreateTable")
673
+ return _Call("CreateTable", args=(_Scalar(var_of(operation.table)),))
674
+ if isinstance(operation, DropTable):
675
+ self._used.add("DropTable")
676
+ return _Call("DropTable", args=(_Scalar(var_of(operation.table)),))
677
+ if isinstance(operation, AddColumn):
678
+ self._used.add("AddColumn")
679
+ return _Call(
680
+ "AddColumn",
681
+ args=(
682
+ _Scalar(var_of(operation.table)),
683
+ self.build_column(operation.column),
684
+ ),
685
+ )
686
+ if isinstance(operation, DropColumn):
687
+ self._used.add("DropColumn")
688
+ return _Call(
689
+ "DropColumn",
690
+ args=(
691
+ _Scalar(var_of(operation.table)),
692
+ self.build_column(operation.column),
693
+ ),
694
+ )
695
+ if isinstance(operation, AlterColumn):
696
+ self._used.add("AlterColumn")
697
+ return _Call(
698
+ "AlterColumn",
699
+ args=(
700
+ _Scalar(var_of(operation.table)),
701
+ self.build_column(operation.old),
702
+ self.build_column(operation.new),
703
+ ),
704
+ )
705
+ if isinstance(operation, AlterTableComment):
706
+ self._used.add("AlterTableComment")
707
+ previous = (
708
+ _Scalar(_str_lit(operation.previous))
709
+ if operation.previous is not None
710
+ else _Scalar("None")
711
+ )
712
+ return _Call(
713
+ "AlterTableComment",
714
+ args=(_Scalar(var_of(operation.table)),),
715
+ kwargs=(("previous", previous),),
716
+ )
717
+ if isinstance(operation, (CreateSchema, DropSchema)):
718
+ name = type(operation).__name__
719
+ self._used.add(name)
720
+ return _Call(name, args=(_Scalar(_str_lit(operation.schema)),))
721
+ if isinstance(operation, RenameColumn):
722
+ self._used.add("RenameColumn")
723
+ return _Call(
724
+ "RenameColumn",
725
+ args=(_Scalar(var_of(operation.table)),),
726
+ kwargs=(
727
+ ("old_name", _Scalar(_str_lit(operation.old_name))),
728
+ ("new_name", _Scalar(_str_lit(operation.new_name))),
729
+ ),
730
+ )
731
+ if isinstance(operation, RebuildTable):
732
+ self._used.add("RebuildTable")
733
+ kwargs: tuple[tuple[str, _Node], ...] = ()
734
+ if operation.triggers:
735
+ # The triggers are written INSIDE the operation and not as a table variable: they
736
+ # belong to this rebuild, which is the only thing that recreates them, and a
737
+ # `SnakeTableInfo` has nowhere to put one. Omitted when empty so a rebuild over a
738
+ # table with no triggers writes the same line it always did.
739
+ kwargs = (
740
+ (
741
+ "triggers",
742
+ _Tuple(
743
+ tuple(
744
+ self.build_trigger(trigger)
745
+ for trigger in operation.triggers
746
+ )
747
+ ),
748
+ ),
749
+ )
750
+ return _Call(
751
+ "RebuildTable",
752
+ args=(
753
+ _Scalar(var_of(operation.before)),
754
+ _Scalar(var_of(operation.after)),
755
+ ),
756
+ kwargs=kwargs,
757
+ )
758
+ if isinstance(operation, RenameTable):
759
+ self._used.add("RenameTable")
760
+ return _Call(
761
+ "RenameTable",
762
+ args=(_Scalar(var_of(operation.table)),),
763
+ kwargs=(("new_name", _Scalar(_str_lit(operation.new_name))),),
764
+ )
765
+ if isinstance(operation, AddCheck):
766
+ self._used.add("AddCheck")
767
+ return _Call(
768
+ "AddCheck",
769
+ args=(
770
+ _Scalar(var_of(operation.table)),
771
+ self.build_check(operation.check),
772
+ ),
773
+ )
774
+ if isinstance(operation, DropCheck):
775
+ self._used.add("DropCheck")
776
+ return _Call(
777
+ "DropCheck",
778
+ args=(
779
+ _Scalar(var_of(operation.table)),
780
+ self.build_check(operation.check),
781
+ ),
782
+ )
783
+ if isinstance(operation, CreateIndex):
784
+ self._used.add("CreateIndex")
785
+ return _Call(
786
+ "CreateIndex",
787
+ args=(
788
+ _Scalar(var_of(operation.table)),
789
+ self.build_index(operation.index),
790
+ ),
791
+ )
792
+ if isinstance(operation, DropIndex):
793
+ self._used.add("DropIndex")
794
+ return _Call(
795
+ "DropIndex",
796
+ args=(
797
+ _Scalar(var_of(operation.table)),
798
+ self.build_index(operation.index),
799
+ ),
800
+ )
801
+ if isinstance(operation, AddForeignKey):
802
+ self._used.add("AddForeignKey")
803
+ return _Call(
804
+ "AddForeignKey",
805
+ args=(
806
+ _Scalar(var_of(operation.table)),
807
+ self.build_relationship(operation.relationship),
808
+ _Scalar(var_of(operation.target)),
809
+ ),
810
+ )
811
+ if isinstance(operation, DropForeignKey):
812
+ self._used.add("DropForeignKey")
813
+ return _Call(
814
+ "DropForeignKey",
815
+ args=(
816
+ _Scalar(var_of(operation.table)),
817
+ self.build_relationship(operation.relationship),
818
+ _Scalar(var_of(operation.target)),
819
+ ),
820
+ )
821
+ if isinstance(operation, CreateView):
822
+ self._used.add("CreateView")
823
+ return _Call("CreateView", args=(_Scalar(var_of(operation.view)),))
824
+ if isinstance(operation, DropView):
825
+ self._used.add("DropView")
826
+ return _Call("DropView", args=(_Scalar(var_of(operation.view)),))
827
+ if isinstance(operation, AlterView):
828
+ self._used.add("AlterView")
829
+ return _Call(
830
+ "AlterView",
831
+ args=(_Scalar(var_of(operation.old)), _Scalar(var_of(operation.new))),
832
+ )
833
+ if isinstance(operation, CreateTrigger):
834
+ self._used.add("CreateTrigger")
835
+ return _Call(
836
+ "CreateTrigger", args=(self.build_trigger(operation.definition),)
837
+ )
838
+ if isinstance(operation, DropTrigger):
839
+ self._used.add("DropTrigger")
840
+ return _Call(
841
+ "DropTrigger", args=(self.build_trigger(operation.definition),)
842
+ )
843
+ if isinstance(operation, AlterTrigger):
844
+ self._used.add("AlterTrigger")
845
+ return _Call(
846
+ "AlterTrigger",
847
+ args=(
848
+ self.build_trigger(operation.old),
849
+ self.build_trigger(operation.new),
850
+ ),
851
+ )
852
+ if isinstance(operation, CreateFunction):
853
+ self._used.add("CreateFunction")
854
+ return _Call(
855
+ "CreateFunction", args=(self.build_routine(operation.definition),)
856
+ )
857
+ if isinstance(operation, DropFunction):
858
+ self._used.add("DropFunction")
859
+ return _Call(
860
+ "DropFunction", args=(self.build_routine(operation.definition),)
861
+ )
862
+ if isinstance(operation, AlterFunction):
863
+ self._used.add("AlterFunction")
864
+ return _Call(
865
+ "AlterFunction",
866
+ args=(
867
+ self.build_routine(operation.old),
868
+ self.build_routine(operation.new),
869
+ ),
870
+ )
871
+ if isinstance(operation, RunSQL):
872
+ return self.build_run_sql(operation)
873
+ if isinstance(operation, RunPython):
874
+ return self.build_run_python(operation)
875
+ raise SnakeModelDefinitionError(
876
+ f"render does not know how to serialise the operation {type(operation).__name__!r}."
877
+ )
878
+
879
+ def build_run_sql(self, operation: RunSQL) -> _Call:
880
+ """Node for a `RunSQL`: emits `up` (and `down` if there is one) as a str or tuple of raw str."""
881
+ self._used.add("RunSQL")
882
+ kwargs: list[tuple[str, _Node]] = [("up", self._render_sql_arg(operation.up))]
883
+ if operation.down is not None:
884
+ kwargs.append(("down", self._render_sql_arg(operation.down)))
885
+ return _Call("RunSQL", kwargs=tuple(kwargs))
886
+
887
+ def build_run_python(self, operation: RunPython) -> _Call:
888
+ """Node for a `RunPython`: references `forward` (and `backward`) by its import path.
889
+
890
+ Same as `default_factory`: the functions have to be importable (at module level). They are
891
+ rendered as `module.qualname` (with their import); a lambda or closure is rejected loudly.
892
+ """
893
+ self._used.add("RunPython")
894
+ args: list[_Node] = [_Scalar(self.render_factory(operation.forward))]
895
+ if operation.backward is not None:
896
+ args.append(_Scalar(self.render_factory(operation.backward)))
897
+ return _Call("RunPython", args=tuple(args))
898
+
899
+ def _render_sql_arg(self, sql: str | tuple[str, ...]) -> _Node:
900
+ """Renders the `up`/`down` of a `RunSQL`: a str as a literal; a tuple as a tuple of literals."""
901
+ if isinstance(sql, str):
902
+ return _Scalar(_str_lit(sql))
903
+ return _Tuple(tuple(_Scalar(_str_lit(statement)) for statement in sql))
904
+
905
+ def import_roots(self) -> set[str]:
906
+ """Top-level names the imports bind (so that no variable ever tramples on them)."""
907
+ return {
908
+ statement.removeprefix("import ").split(".", 1)[0]
909
+ for statement in self._imports
910
+ }
911
+
912
+ def imports_block(self) -> list[str]:
913
+ """Builds the import lines of the file (stdlib/user + metadata + migration)."""
914
+ lines: list[str] = sorted(self._imports)
915
+ if lines:
916
+ lines.append("")
917
+ meta = sorted(name for name in self._used if name in _META_CLASSES)
918
+ if meta:
919
+ lines.append("from snakeorm.metadata import (")
920
+ lines.extend(f" {name}," for name in meta)
921
+ lines.append(")")
922
+ expressions = sorted(name for name in self._used if name in _EXPR_CLASSES)
923
+ if expressions:
924
+ lines.append("from snakeorm.expressions import (")
925
+ lines.extend(f" {name}," for name in expressions)
926
+ lines.append(")")
927
+ orphans = sorted(self._used - _META_CLASSES - _EXPR_CLASSES - _OP_CLASSES)
928
+ if orphans:
929
+ # Without this the name drops out of the block IN SILENCE and the file blows up on
930
+ # import, that is on applying the migration: the worst moment to find out. Failing here
931
+ # means failing while generating it, with the name right in front of you.
932
+ raise SnakeEmitError(
933
+ f"The renderer uses names it does not know how to import: {', '.join(orphans)}. "
934
+ f"Register them in _META_CLASSES, _EXPR_CLASSES or _OP_CLASSES of "
935
+ f"migration/render.py, or the generated file will fail with a NameError on apply."
936
+ )
937
+ ops = sorted(name for name in self._used if name in _OP_CLASSES)
938
+ lines.append("from snakeorm.migration import (")
939
+ lines.extend(f" {name}," for name in ops)
940
+ lines.append(" Migration,")
941
+ lines.append(")")
942
+ return lines
943
+
944
+
945
+ def render_condition(condition: SnakeCondition) -> tuple[str, list[str]]:
946
+ """Renders a condition into `(code, import lines)`.
947
+
948
+ It hands the imports back separately because whoever embeds it (a CHECK constraint inside its
949
+ `SnakeCheckInfo`, the WHERE of a partial index) has to merge them with its own in the header of
950
+ the file. Raises `SnakeModelDefinitionError` if the AST carries anything not rebuildable.
951
+ """
952
+ renderer = _Renderer()
953
+ node = renderer.build_condition(condition)
954
+ return _inline(node), renderer.imports_block()
955
+
956
+
957
+ def _referenced_tables(operation: SnakeMigrationOperation) -> list[SnakeTableInfo]:
958
+ """The tables an operation references, in order (source and, where it applies, target)."""
959
+ if isinstance(operation, (AddForeignKey, DropForeignKey)):
960
+ return [operation.table, operation.target]
961
+ if isinstance(
962
+ operation,
963
+ (
964
+ CreateTable,
965
+ DropTable,
966
+ AddColumn,
967
+ DropColumn,
968
+ AlterColumn,
969
+ AlterTableComment,
970
+ CreateIndex,
971
+ DropIndex,
972
+ AddCheck,
973
+ DropCheck,
974
+ RenameColumn,
975
+ RenameTable,
976
+ ),
977
+ ):
978
+ return [operation.table]
979
+ if isinstance(operation, (CreateView, DropView)):
980
+ return [operation.view]
981
+ if isinstance(operation, AlterView):
982
+ return [operation.old, operation.new]
983
+ if isinstance(operation, RebuildTable):
984
+ return [operation.before, operation.after]
985
+ return []
986
+
987
+
988
+ def _collect_tables(
989
+ operations: Sequence[SnakeMigrationOperation],
990
+ ) -> list[SnakeTableInfo]:
991
+ """Distinct tables (by structural equality) in order of first appearance."""
992
+ distinct: list[SnakeTableInfo] = []
993
+ for operation in operations:
994
+ for table in _referenced_tables(operation):
995
+ if table not in distinct:
996
+ distinct.append(table)
997
+ return distinct
998
+
999
+
1000
+ def _assign_names(tables: list[SnakeTableInfo], reserved: set[str]) -> dict[int, str]:
1001
+ """Assigns each table (by id) a sanitized, unique variable; disambiguates with a number suffix."""
1002
+ used = set(reserved)
1003
+ names: dict[int, str] = {}
1004
+ for table in tables:
1005
+ base = _identifier(table.name)
1006
+ candidate = base
1007
+ suffix = 2
1008
+ while candidate in used:
1009
+ candidate = f"{base}_{suffix}"
1010
+ suffix += 1
1011
+ used.add(candidate)
1012
+ names[id(table)] = candidate
1013
+ return names
1014
+
1015
+
1016
+ def render_migration(
1017
+ version: str,
1018
+ operations: Sequence[SnakeMigrationOperation],
1019
+ replaces: Sequence[str] = (),
1020
+ ) -> str:
1021
+ """Generates the text of a migration file that rebuilds `operations` when it is imported.
1022
+
1023
+ It exposes `version`, `operations` and `migration: Migration`. `replaces` are the versions that
1024
+ this file SUPERSEDES (a squash); it is written only when there is one (a normal migration does not
1025
+ shift a single byte) and it must be written: the runner needs it so as not to rerun a squash over
1026
+ a DB that is already migrated.
1027
+ """
1028
+ renderer = _Renderer()
1029
+
1030
+ tables = _collect_tables(operations)
1031
+ table_nodes = [(table, renderer.build_table(table)) for table in tables]
1032
+
1033
+ reserved = set(_RESERVED) | renderer.import_roots()
1034
+ names = _assign_names(tables, reserved)
1035
+
1036
+ def var_of(table: SnakeTableInfo) -> str:
1037
+ """The variable for a table; it matches by identity and, failing that, by structural equality."""
1038
+ name = names.get(id(table))
1039
+ if name is not None:
1040
+ return name
1041
+ for (
1042
+ original
1043
+ ) in tables: # a different object but structurally equal (earlier dedup)
1044
+ if original == table:
1045
+ return names[id(original)]
1046
+ raise SnakeModelDefinitionError( # pragma: no cover - defensive
1047
+ f"An operation's table {table.name!r} was not collected as a variable."
1048
+ )
1049
+
1050
+ operation_nodes = [
1051
+ renderer.build_operation(operation, var_of) for operation in operations
1052
+ ]
1053
+
1054
+ lines = [
1055
+ '"""Migration generated by SnakeORM. Editable by hand."""',
1056
+ "",
1057
+ "from __future__ import annotations",
1058
+ "",
1059
+ ]
1060
+ lines.extend(renderer.imports_block())
1061
+ lines.extend(["", f"version = {_str_lit(version)}", ""])
1062
+
1063
+ for table, node in table_nodes:
1064
+ name = names[id(table)]
1065
+ rendered = _format(node, 0, len(name) + len(" = "), 0)
1066
+ lines.append(f"{name} = {rendered}")
1067
+ lines.append("")
1068
+
1069
+ lines.append("operations = [")
1070
+ lines.extend(
1071
+ f" {_format(node, _INDENT, _INDENT, 1)}," for node in operation_nodes
1072
+ )
1073
+ lines.append("]")
1074
+ if replaces:
1075
+ lines.extend(["", "replaces = ["])
1076
+ lines.extend(f" {_str_lit(version)}," for version in replaces)
1077
+ lines.append("]")
1078
+ lines.extend(
1079
+ [
1080
+ "",
1081
+ "migration = Migration(",
1082
+ " version=version, operations=tuple(operations), replaces=tuple(replaces)",
1083
+ ")",
1084
+ "",
1085
+ ]
1086
+ )
1087
+ else:
1088
+ lines.extend(
1089
+ [
1090
+ "",
1091
+ "migration = Migration(version=version, operations=tuple(operations))",
1092
+ "",
1093
+ ]
1094
+ )
1095
+ return "\n".join(lines)