snake-orm 0.1.0b1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- snake_orm-0.1.0b1.dist-info/METADATA +366 -0
- snake_orm-0.1.0b1.dist-info/RECORD +198 -0
- snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
- snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
- snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
- snakeorm/__init__.py +214 -0
- snakeorm/advisor.py +109 -0
- snakeorm/cli/__init__.py +3 -0
- snakeorm/cli/app.py +992 -0
- snakeorm/cli/discovery.py +179 -0
- snakeorm/cli/hooks.py +86 -0
- snakeorm/compiler/__init__.py +3 -0
- snakeorm/compiler/compiler.py +330 -0
- snakeorm/connection.py +228 -0
- snakeorm/contrib/__init__.py +17 -0
- snakeorm/contrib/asgi.py +192 -0
- snakeorm/contrib/config.py +264 -0
- snakeorm/contrib/deliver.py +256 -0
- snakeorm/contrib/django.py +245 -0
- snakeorm/contrib/sidecar.py +44 -0
- snakeorm/contrib/wsgi.py +176 -0
- snakeorm/core/__init__.py +1 -0
- snakeorm/core/config.py +173 -0
- snakeorm/core/converters.py +172 -0
- snakeorm/core/exceptions.py +175 -0
- snakeorm/core/placement.py +15 -0
- snakeorm/core/sentinels.py +37 -0
- snakeorm/core/signals.py +114 -0
- snakeorm/debug/__init__.py +42 -0
- snakeorm/debug/assets/css/panel.css +616 -0
- snakeorm/debug/assets/css/snake_orm_app.css +293 -0
- snakeorm/debug/assets/img/icon.svg +5 -0
- snakeorm/debug/assets/js/history.js +425 -0
- snakeorm/debug/assets/js/language.js +283 -0
- snakeorm/debug/assets/js/panel.js +241 -0
- snakeorm/debug/assets/js/snake_orm_app.js +82 -0
- snakeorm/debug/assets/pages/cli.html +28 -0
- snakeorm/debug/assets/pages/config.html +34 -0
- snakeorm/debug/assets/pages/database_first.html +29 -0
- snakeorm/debug/assets/pages/help.html +21 -0
- snakeorm/debug/assets/pages/history.html +27 -0
- snakeorm/debug/capture.py +234 -0
- snakeorm/debug/channel.py +131 -0
- snakeorm/debug/collector.py +144 -0
- snakeorm/debug/config.py +149 -0
- snakeorm/debug/html.py +578 -0
- snakeorm/debug/matrix.py +60 -0
- snakeorm/debug/origin.py +42 -0
- snakeorm/debug/otel/__init__.py +68 -0
- snakeorm/debug/otel/context.py +43 -0
- snakeorm/debug/otel/exporter.py +277 -0
- snakeorm/debug/otel/payload.py +97 -0
- snakeorm/debug/otel/report.py +29 -0
- snakeorm/debug/otel/spans.py +393 -0
- snakeorm/debug/otel/summary.py +59 -0
- snakeorm/debug/record.py +54 -0
- snakeorm/debug/report.py +311 -0
- snakeorm/debug/testing.py +23 -0
- snakeorm/decorators/__init__.py +13 -0
- snakeorm/decorators/abstract.py +26 -0
- snakeorm/decorators/db_first.py +104 -0
- snakeorm/decorators/function.py +28 -0
- snakeorm/decorators/model.py +268 -0
- snakeorm/decorators/polymorphic.py +140 -0
- snakeorm/decorators/result.py +167 -0
- snakeorm/decorators/row.py +90 -0
- snakeorm/decorators/trigger.py +47 -0
- snakeorm/decorators/view.py +149 -0
- snakeorm/dialects/__init__.py +6 -0
- snakeorm/dialects/base.py +373 -0
- snakeorm/dialects/capabilities.py +571 -0
- snakeorm/dialects/literals.py +46 -0
- snakeorm/dialects/matrix.py +384 -0
- snakeorm/dialects/mysql.py +519 -0
- snakeorm/dialects/postgres.py +504 -0
- snakeorm/dialects/sqlite.py +417 -0
- snakeorm/drivers/__init__.py +18 -0
- snakeorm/drivers/asyncbase.py +73 -0
- snakeorm/drivers/asyncdecorators.py +244 -0
- snakeorm/drivers/asyncpool.py +228 -0
- snakeorm/drivers/asyncpsycopg.py +124 -0
- snakeorm/drivers/asyncpymysql.py +50 -0
- snakeorm/drivers/asyncsqlite.py +30 -0
- snakeorm/drivers/base.py +76 -0
- snakeorm/drivers/failures.py +181 -0
- snakeorm/drivers/logging.py +167 -0
- snakeorm/drivers/pool.py +352 -0
- snakeorm/drivers/psycopg.py +188 -0
- snakeorm/drivers/pymysql.py +176 -0
- snakeorm/drivers/savepoints.py +46 -0
- snakeorm/drivers/sqlite.py +170 -0
- snakeorm/drivers/threaded.py +143 -0
- snakeorm/drivers/timeout.py +87 -0
- snakeorm/dto/__init__.py +55 -0
- snakeorm/dto/read.py +231 -0
- snakeorm/dto/region.py +326 -0
- snakeorm/dto/resolve.py +354 -0
- snakeorm/dto/spec.py +201 -0
- snakeorm/expressions/__init__.py +88 -0
- snakeorm/expressions/conditional.py +178 -0
- snakeorm/expressions/expression.py +810 -0
- snakeorm/expressions/functions.py +71 -0
- snakeorm/expressions/keys.py +299 -0
- snakeorm/expressions/paths.py +43 -0
- snakeorm/expressions/scalar.py +293 -0
- snakeorm/expressions/window.py +248 -0
- snakeorm/fields/__init__.py +68 -0
- snakeorm/fields/check.py +79 -0
- snakeorm/fields/column.py +316 -0
- snakeorm/fields/enum.py +64 -0
- snakeorm/fields/index.py +46 -0
- snakeorm/fields/relationship.py +738 -0
- snakeorm/fields/typed.py +736 -0
- snakeorm/helpers/__init__.py +1 -0
- snakeorm/helpers/annotations.py +21 -0
- snakeorm/helpers/inheritance.py +41 -0
- snakeorm/helpers/pyliteral.py +52 -0
- snakeorm/helpers/pytype.py +50 -0
- snakeorm/introspection/__init__.py +13 -0
- snakeorm/introspection/base.py +30 -0
- snakeorm/introspection/drift.py +78 -0
- snakeorm/introspection/models.py +633 -0
- snakeorm/introspection/mysql.py +367 -0
- snakeorm/introspection/postgres.py +397 -0
- snakeorm/introspection/sqlite.py +217 -0
- snakeorm/introspection/unsupported.py +94 -0
- snakeorm/linker/__init__.py +3 -0
- snakeorm/linker/linker.py +521 -0
- snakeorm/metadata/__init__.py +41 -0
- snakeorm/metadata/check.py +37 -0
- snakeorm/metadata/column.py +201 -0
- snakeorm/metadata/enum_storage.py +33 -0
- snakeorm/metadata/fk_action.py +19 -0
- snakeorm/metadata/foreign_key.py +26 -0
- snakeorm/metadata/index.py +43 -0
- snakeorm/metadata/index_method.py +19 -0
- snakeorm/metadata/int_size.py +20 -0
- snakeorm/metadata/json_storage.py +21 -0
- snakeorm/metadata/polymorphic.py +30 -0
- snakeorm/metadata/primary_key.py +23 -0
- snakeorm/metadata/relationship.py +60 -0
- snakeorm/metadata/relationship_kind.py +43 -0
- snakeorm/metadata/routine.py +26 -0
- snakeorm/metadata/server_default.py +20 -0
- snakeorm/metadata/table.py +97 -0
- snakeorm/metadata/table_kind.py +23 -0
- snakeorm/metadata/trigger.py +62 -0
- snakeorm/metadata/type_params.py +348 -0
- snakeorm/migration/__init__.py +88 -0
- snakeorm/migration/asyncrunner.py +168 -0
- snakeorm/migration/autodetect.py +96 -0
- snakeorm/migration/ddl.py +1172 -0
- snakeorm/migration/diff.py +701 -0
- snakeorm/migration/loader.py +77 -0
- snakeorm/migration/operations.py +952 -0
- snakeorm/migration/planner.py +78 -0
- snakeorm/migration/realize.py +420 -0
- snakeorm/migration/renames.py +153 -0
- snakeorm/migration/render.py +1095 -0
- snakeorm/migration/runner.py +314 -0
- snakeorm/migration/squash.py +60 -0
- snakeorm/migration/state.py +107 -0
- snakeorm/model.py +162 -0
- snakeorm/py.typed +0 -0
- snakeorm/query/__init__.py +9 -0
- snakeorm/query/compound.py +288 -0
- snakeorm/query/join_kind.py +19 -0
- snakeorm/query/joined.py +143 -0
- snakeorm/query/query.py +1001 -0
- snakeorm/query/recursive.py +244 -0
- snakeorm/registry/__init__.py +5 -0
- snakeorm/registry/by_module.py +163 -0
- snakeorm/registry/registry.py +166 -0
- snakeorm/session/__init__.py +8 -0
- snakeorm/session/asyncsession.py +638 -0
- snakeorm/session/coercion.py +306 -0
- snakeorm/session/factory.py +29 -0
- snakeorm/session/guards.py +269 -0
- snakeorm/session/isolation.py +23 -0
- snakeorm/session/mapper.py +213 -0
- snakeorm/session/planning.py +815 -0
- snakeorm/session/retry.py +96 -0
- snakeorm/session/session.py +871 -0
- snakeorm/session/shared.py +355 -0
- snakeorm/sql/__init__.py +18 -0
- snakeorm/sql/adapt.py +104 -0
- snakeorm/sql/aggregate.py +232 -0
- snakeorm/sql/condition.py +359 -0
- snakeorm/sql/delete.py +46 -0
- snakeorm/sql/insert.py +149 -0
- snakeorm/sql/joins.py +158 -0
- snakeorm/sql/pk_subquery.py +54 -0
- snakeorm/sql/refs.py +19 -0
- snakeorm/sql/resolver.py +28 -0
- snakeorm/sql/select.py +175 -0
- snakeorm/sql/update.py +82 -0
- snakeorm/sql/value.py +552 -0
- snakeorm/times.py +276 -0
|
@@ -0,0 +1,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)
|