django-migrate-sql-deux 1.0.0__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.
- django_migrate_sql/__init__.py +1 -0
- django_migrate_sql/autodetector.py +326 -0
- django_migrate_sql/config.py +25 -0
- django_migrate_sql/graph.py +119 -0
- django_migrate_sql/management/__init__.py +0 -0
- django_migrate_sql/management/commands/__init__.py +0 -0
- django_migrate_sql/management/commands/makemigrations.py +143 -0
- django_migrate_sql/operations.py +221 -0
- django_migrate_sql_deux-1.0.0.dist-info/METADATA +306 -0
- django_migrate_sql_deux-1.0.0.dist-info/RECORD +13 -0
- django_migrate_sql_deux-1.0.0.dist-info/WHEEL +5 -0
- django_migrate_sql_deux-1.0.0.dist-info/licenses/LICENSE +13 -0
- django_migrate_sql_deux-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '1.0.0'
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import django
|
|
2
|
+
from django.conf import settings
|
|
3
|
+
from django.db.migrations.autodetector import MigrationAutodetector as DjangoMigrationAutodetector
|
|
4
|
+
from django.db.migrations.operations import RunSQL
|
|
5
|
+
from django.utils.datastructures import OrderedSet
|
|
6
|
+
if django.VERSION >= (5, 1):
|
|
7
|
+
from django.db.migrations.autodetector import OperationDependency
|
|
8
|
+
|
|
9
|
+
from .operations import (AlterSQL, ReverseAlterSQL, CreateSQL, DeleteSQL, AlterSQLState)
|
|
10
|
+
from .graph import SQLStateGraph
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SQLBlob(object):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
# Dummy object used to identify django dependency as the one used by this tool only.
|
|
17
|
+
SQL_BLOB = SQLBlob()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _sql_params(sql):
|
|
21
|
+
"""
|
|
22
|
+
Identify `sql` as either SQL string or 2-tuple of SQL and params.
|
|
23
|
+
Same format as supported by Django's RunSQL operation for sql/reverse_sql.
|
|
24
|
+
"""
|
|
25
|
+
params = None
|
|
26
|
+
if isinstance(sql, (list, tuple)):
|
|
27
|
+
elements = len(sql)
|
|
28
|
+
if elements == 2:
|
|
29
|
+
sql, params = sql
|
|
30
|
+
else:
|
|
31
|
+
raise ValueError("Expected a 2-tuple but got %d" % elements)
|
|
32
|
+
return sql, params
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_sql_equal(sqls1, sqls2):
|
|
36
|
+
"""
|
|
37
|
+
Find out equality of two SQL items.
|
|
38
|
+
|
|
39
|
+
See https://docs.djangoproject.com/en/1.8/ref/migration-operations/#runsql.
|
|
40
|
+
Args:
|
|
41
|
+
sqls1, sqls2: SQL items, have the same format as supported by Django's RunSQL operation.
|
|
42
|
+
Returns:
|
|
43
|
+
(bool) `True` if equal, otherwise `False`.
|
|
44
|
+
"""
|
|
45
|
+
is_seq1 = isinstance(sqls1, (list, tuple))
|
|
46
|
+
is_seq2 = isinstance(sqls2, (list, tuple))
|
|
47
|
+
|
|
48
|
+
if not is_seq1:
|
|
49
|
+
sqls1 = (sqls1,)
|
|
50
|
+
if not is_seq2:
|
|
51
|
+
sqls2 = (sqls2,)
|
|
52
|
+
|
|
53
|
+
if len(sqls1) != len(sqls2):
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
for sql1, sql2 in zip(sqls1, sqls2):
|
|
57
|
+
sql1, params1 = _sql_params(sql1)
|
|
58
|
+
sql2, params2 = _sql_params(sql2)
|
|
59
|
+
if sql1 != sql2 or params1 != params2:
|
|
60
|
+
return False
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_ancestors(node):
|
|
65
|
+
"""Logic extracted from Django <2.2 as this is dropped in later versions."""
|
|
66
|
+
if '_ancestors' not in node.__dict__:
|
|
67
|
+
ancestors = []
|
|
68
|
+
for parent in sorted(node.parents, reverse=True):
|
|
69
|
+
ancestors += get_ancestors(parent)
|
|
70
|
+
ancestors.append(node.key)
|
|
71
|
+
node.__dict__['_ancestors'] = list(OrderedSet(ancestors))
|
|
72
|
+
return node.__dict__['_ancestors']
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_descendants(node):
|
|
76
|
+
"""Logic extracted from Django <2.2 as this is dropped in later versions."""
|
|
77
|
+
if '_descendants' not in node.__dict__:
|
|
78
|
+
descendants = []
|
|
79
|
+
for child in sorted(node.children, reverse=True):
|
|
80
|
+
descendants += get_descendants(child)
|
|
81
|
+
descendants.append(node.key)
|
|
82
|
+
node.__dict__['_descendants'] = list(OrderedSet(descendants))
|
|
83
|
+
return node.__dict__['_descendants']
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class MigrationAutodetector(DjangoMigrationAutodetector):
|
|
87
|
+
"""
|
|
88
|
+
Substitutes Django's MigrationAutodetector class, injecting SQL migrations logic.
|
|
89
|
+
"""
|
|
90
|
+
def __init__(self, from_state, to_state, questioner=None, to_sql_graph=None):
|
|
91
|
+
super(MigrationAutodetector, self).__init__(from_state, to_state, questioner)
|
|
92
|
+
self.to_sql_graph = to_sql_graph
|
|
93
|
+
self.from_sql_graph = getattr(self.from_state, 'sql_state', None) or SQLStateGraph()
|
|
94
|
+
self.from_sql_graph.build_graph()
|
|
95
|
+
self._sql_operations = []
|
|
96
|
+
|
|
97
|
+
def assemble_changes(self, keys, resolve_keys, sql_state):
|
|
98
|
+
"""
|
|
99
|
+
Accepts keys of SQL items available, sorts them and adds additional dependencies.
|
|
100
|
+
Uses graph of `sql_state` nodes to build `keys` and `resolve_keys` into sequence that
|
|
101
|
+
starts with leaves (items that have not dependents) and ends with roots.
|
|
102
|
+
|
|
103
|
+
Changes `resolve_keys` argument as dependencies are added to the result.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
keys (list): List of migration keys, that are one of create/delete operations, and
|
|
107
|
+
dont require respective reverse operations.
|
|
108
|
+
resolve_keys (list): List of migration keys, that are changing existing items,
|
|
109
|
+
and may require respective reverse operations.
|
|
110
|
+
sql_sate (graph.SQLStateGraph): State of SQL items.
|
|
111
|
+
Returns:
|
|
112
|
+
(list) Sorted sequence of migration keys, enriched with dependencies.
|
|
113
|
+
"""
|
|
114
|
+
result_keys = []
|
|
115
|
+
all_keys = keys | resolve_keys
|
|
116
|
+
for key in all_keys:
|
|
117
|
+
node = sql_state.node_map[key]
|
|
118
|
+
sql_item = sql_state.nodes[key]
|
|
119
|
+
ancs = get_ancestors(node)[:-1]
|
|
120
|
+
ancs.reverse()
|
|
121
|
+
pos = next((i for i, k in enumerate(result_keys) if k in ancs), len(result_keys))
|
|
122
|
+
result_keys.insert(pos, key)
|
|
123
|
+
|
|
124
|
+
if key in resolve_keys and not sql_item.replace:
|
|
125
|
+
# ancestors() and descendants() include key itself, need to cut it out.
|
|
126
|
+
descs = reversed(get_descendants(node)[:-1])
|
|
127
|
+
for desc in descs:
|
|
128
|
+
if desc not in all_keys and desc not in result_keys:
|
|
129
|
+
result_keys.insert(pos, desc)
|
|
130
|
+
# these items added may also need reverse operations.
|
|
131
|
+
resolve_keys.add(desc)
|
|
132
|
+
return result_keys
|
|
133
|
+
|
|
134
|
+
def add_sql_operation(self, app_label, sql_name, operation, dependencies):
|
|
135
|
+
"""
|
|
136
|
+
Add SQL operation and register it to be used as dependency for further
|
|
137
|
+
sequential operations.
|
|
138
|
+
"""
|
|
139
|
+
if django.VERSION >= (5, 1):
|
|
140
|
+
deps = [
|
|
141
|
+
OperationDependency(
|
|
142
|
+
app_label=dp[0],
|
|
143
|
+
model_name=SQL_BLOB,
|
|
144
|
+
field_name=dp[1],
|
|
145
|
+
type=self._sql_operations.get(dp),
|
|
146
|
+
)
|
|
147
|
+
for dp in dependencies
|
|
148
|
+
]
|
|
149
|
+
else:
|
|
150
|
+
deps = [(dp[0], SQL_BLOB, dp[1], self._sql_operations.get(dp)) for dp in dependencies]
|
|
151
|
+
|
|
152
|
+
self.add_operation(app_label, operation, dependencies=deps)
|
|
153
|
+
self._sql_operations[(app_label, sql_name)] = operation
|
|
154
|
+
|
|
155
|
+
def _generate_reversed_sql(self, keys, changed_keys):
|
|
156
|
+
"""
|
|
157
|
+
Generate reversed operations for changes, that require full rollback and creation.
|
|
158
|
+
"""
|
|
159
|
+
for key in keys:
|
|
160
|
+
if key not in changed_keys:
|
|
161
|
+
continue
|
|
162
|
+
app_label, sql_name = key
|
|
163
|
+
old_item = self.from_sql_graph.nodes[key]
|
|
164
|
+
new_item = self.to_sql_graph.nodes[key]
|
|
165
|
+
if not old_item.reverse_sql or old_item.reverse_sql == RunSQL.noop or new_item.replace:
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
# migrate backwards
|
|
169
|
+
operation = ReverseAlterSQL(sql_name, old_item.reverse_sql, reverse_sql=old_item.sql)
|
|
170
|
+
sql_deps = [n.key for n in self.from_sql_graph.node_map[key].children]
|
|
171
|
+
sql_deps.append(key)
|
|
172
|
+
self.add_sql_operation(app_label, sql_name, operation, sql_deps)
|
|
173
|
+
|
|
174
|
+
def _generate_sql(self, keys, changed_keys):
|
|
175
|
+
"""
|
|
176
|
+
Generate forward operations for changing/creating SQL items.
|
|
177
|
+
"""
|
|
178
|
+
for key in reversed(keys):
|
|
179
|
+
app_label, sql_name = key
|
|
180
|
+
new_item = self.to_sql_graph.nodes[key]
|
|
181
|
+
sql_deps = [n.key for n in self.to_sql_graph.node_map[key].parents]
|
|
182
|
+
reverse_sql = new_item.reverse_sql
|
|
183
|
+
|
|
184
|
+
if key in changed_keys:
|
|
185
|
+
operation_cls = AlterSQL
|
|
186
|
+
kwargs = {}
|
|
187
|
+
# in case of replace mode, AlterSQL will hold sql, reverse_sql and
|
|
188
|
+
# state_reverse_sql, the latter one will be used for building state forward
|
|
189
|
+
# instead of reverse_sql.
|
|
190
|
+
if new_item.replace:
|
|
191
|
+
kwargs['state_reverse_sql'] = reverse_sql
|
|
192
|
+
reverse_sql = self.from_sql_graph.nodes[key].sql
|
|
193
|
+
else:
|
|
194
|
+
operation_cls = CreateSQL
|
|
195
|
+
kwargs = {'dependencies': list(sql_deps)}
|
|
196
|
+
|
|
197
|
+
operation = operation_cls(
|
|
198
|
+
sql_name, new_item.sql, reverse_sql=reverse_sql, **kwargs)
|
|
199
|
+
sql_deps.append(key)
|
|
200
|
+
self.add_sql_operation(app_label, sql_name, operation, sql_deps)
|
|
201
|
+
|
|
202
|
+
def _generate_altered_sql_dependencies(self, dep_changed_keys):
|
|
203
|
+
"""
|
|
204
|
+
Generate forward operations for changing/creating SQL item dependencies.
|
|
205
|
+
|
|
206
|
+
Dependencies are only in-memory and should be reflecting database dependencies, so
|
|
207
|
+
changing them in SQL config does not alter database. Such actions are persisted in separate
|
|
208
|
+
type operation - `AlterSQLState`.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
dep_changed_keys (list): Data about keys, that have their dependencies changed.
|
|
212
|
+
List of tuples (key, removed depndencies, added_dependencies).
|
|
213
|
+
"""
|
|
214
|
+
for key, removed_deps, added_deps in dep_changed_keys:
|
|
215
|
+
app_label, sql_name = key
|
|
216
|
+
operation = AlterSQLState(sql_name, add_dependencies=tuple(added_deps),
|
|
217
|
+
remove_dependencies=tuple(removed_deps))
|
|
218
|
+
sql_deps = [key]
|
|
219
|
+
self.add_sql_operation(app_label, sql_name, operation, sql_deps)
|
|
220
|
+
|
|
221
|
+
def _generate_delete_sql(self, delete_keys):
|
|
222
|
+
"""
|
|
223
|
+
Generate forward delete operations for SQL items.
|
|
224
|
+
"""
|
|
225
|
+
for key in delete_keys:
|
|
226
|
+
app_label, sql_name = key
|
|
227
|
+
old_node = self.from_sql_graph.nodes[key]
|
|
228
|
+
operation = DeleteSQL(sql_name, old_node.reverse_sql, reverse_sql=old_node.sql)
|
|
229
|
+
sql_deps = [n.key for n in self.from_sql_graph.node_map[key].children]
|
|
230
|
+
sql_deps.append(key)
|
|
231
|
+
self.add_sql_operation(app_label, sql_name, operation, sql_deps)
|
|
232
|
+
|
|
233
|
+
def generate_sql_changes(self):
|
|
234
|
+
"""
|
|
235
|
+
Starting point of this tool, which identifies changes and generates respective
|
|
236
|
+
operations.
|
|
237
|
+
"""
|
|
238
|
+
from_keys = set(self.from_sql_graph.nodes.keys())
|
|
239
|
+
to_keys = set(self.to_sql_graph.nodes.keys())
|
|
240
|
+
new_keys = to_keys - from_keys
|
|
241
|
+
delete_keys = from_keys - to_keys
|
|
242
|
+
changed_keys = set()
|
|
243
|
+
dep_changed_keys = []
|
|
244
|
+
|
|
245
|
+
for key in from_keys & to_keys:
|
|
246
|
+
old_node = self.from_sql_graph.nodes[key]
|
|
247
|
+
new_node = self.to_sql_graph.nodes[key]
|
|
248
|
+
|
|
249
|
+
# identify SQL changes -- these will alter database.
|
|
250
|
+
if not is_sql_equal(old_node.sql, new_node.sql):
|
|
251
|
+
changed_keys.add(key)
|
|
252
|
+
|
|
253
|
+
# identify dependencies change
|
|
254
|
+
old_deps = self.from_sql_graph.dependencies[key]
|
|
255
|
+
new_deps = self.to_sql_graph.dependencies[key]
|
|
256
|
+
removed_deps = old_deps - new_deps
|
|
257
|
+
added_deps = new_deps - old_deps
|
|
258
|
+
if removed_deps or added_deps:
|
|
259
|
+
dep_changed_keys.append((key, removed_deps, added_deps))
|
|
260
|
+
|
|
261
|
+
# we do basic sort here and inject dependency keys here.
|
|
262
|
+
# operations built using these keys will properly set operation dependencies which will
|
|
263
|
+
# enforce django to build/keep a correct order of operations (stable_topological_sort).
|
|
264
|
+
keys = self.assemble_changes(new_keys, changed_keys, self.to_sql_graph)
|
|
265
|
+
delete_keys = self.assemble_changes(delete_keys, set(), self.from_sql_graph)
|
|
266
|
+
|
|
267
|
+
self._sql_operations = {}
|
|
268
|
+
self._generate_reversed_sql(keys, changed_keys)
|
|
269
|
+
self._generate_sql(keys, changed_keys)
|
|
270
|
+
self._generate_delete_sql(delete_keys)
|
|
271
|
+
self._generate_altered_sql_dependencies(dep_changed_keys)
|
|
272
|
+
|
|
273
|
+
def check_dependency(self, operation, dependency):
|
|
274
|
+
"""
|
|
275
|
+
Enhances default behavior of method by checking dependency for matching operation.
|
|
276
|
+
"""
|
|
277
|
+
if django.VERSION >= (5, 1):
|
|
278
|
+
# dependency is a named tuple
|
|
279
|
+
if isinstance(dependency.model_name, SQLBlob):
|
|
280
|
+
# NOTE: we follow the sort order created by `assemble_changes` so we build a fixed chain
|
|
281
|
+
# of operations. thus we should match exact operation here.
|
|
282
|
+
return dependency.type == operation
|
|
283
|
+
else:
|
|
284
|
+
# dependency is a tuple
|
|
285
|
+
if isinstance(dependency[1], SQLBlob):
|
|
286
|
+
# NOTE: we follow the sort order created by `assemble_changes` so we build a fixed chain
|
|
287
|
+
# of operations. thus we should match exact operation here.
|
|
288
|
+
return dependency[3] == operation
|
|
289
|
+
return super(MigrationAutodetector, self).check_dependency(operation, dependency)
|
|
290
|
+
|
|
291
|
+
def generate_altered_fields(self):
|
|
292
|
+
"""
|
|
293
|
+
Injecting point. This is quite awkward, and i'm looking forward Django for having the logic
|
|
294
|
+
divided into smaller methods/functions for easier enhancement and substitution.
|
|
295
|
+
So far we're doing all the SQL magic in this method.
|
|
296
|
+
"""
|
|
297
|
+
result = super(MigrationAutodetector, self).generate_altered_fields()
|
|
298
|
+
self.generate_sql_changes()
|
|
299
|
+
return result
|
|
300
|
+
|
|
301
|
+
@staticmethod
|
|
302
|
+
def _resolve_dependency(dependency):
|
|
303
|
+
"""
|
|
304
|
+
Return the resolved dependency and a boolean denoting whether or not
|
|
305
|
+
it was swappable.
|
|
306
|
+
"""
|
|
307
|
+
if dependency[0] != "__setting__":
|
|
308
|
+
return dependency, False
|
|
309
|
+
resolved_app_label, resolved_object_name = getattr(
|
|
310
|
+
settings, dependency[1]
|
|
311
|
+
).split(".")
|
|
312
|
+
if django.VERSION >= (5, 1):
|
|
313
|
+
return (
|
|
314
|
+
OperationDependency(
|
|
315
|
+
resolved_app_label,
|
|
316
|
+
resolved_object_name.lower(),
|
|
317
|
+
dependency[2],
|
|
318
|
+
dependency[3],
|
|
319
|
+
),
|
|
320
|
+
True,
|
|
321
|
+
)
|
|
322
|
+
else:
|
|
323
|
+
return (
|
|
324
|
+
(resolved_app_label, resolved_object_name.lower()) + dependency[2:],
|
|
325
|
+
True
|
|
326
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
class SQLItem(object):
|
|
2
|
+
"""
|
|
3
|
+
Represents any SQL entity (unit), for example function, type, index or trigger.
|
|
4
|
+
"""
|
|
5
|
+
def __init__(self, name, sql, reverse_sql=None, dependencies=None, replace=False):
|
|
6
|
+
"""
|
|
7
|
+
Args:
|
|
8
|
+
name (str): Name of the SQL item. Should be unique among other items in the current
|
|
9
|
+
application. It is the name that other items can refer to.
|
|
10
|
+
sql (str/tuple): Forward SQL that creates entity.
|
|
11
|
+
drop_sql (str/tuple, optional): Backward SQL that destroyes entity. (DROPs).
|
|
12
|
+
dependencies (list, optional): Collection of item keys, that the current one depends on.
|
|
13
|
+
Each element is a tuple of two: (app, item_name). Order does not matter.
|
|
14
|
+
replace (bool, optional): If `True`, further migrations will not drop previous version
|
|
15
|
+
of item before creating, assuming that a forward SQL replaces. For example Postgre's
|
|
16
|
+
`create or replace function` which does not require dropping it previously.
|
|
17
|
+
If `False` then each changed item will get two operations: dropping previous version
|
|
18
|
+
and creating new one.
|
|
19
|
+
Default = `False`.
|
|
20
|
+
"""
|
|
21
|
+
self.name = name
|
|
22
|
+
self.sql = sql
|
|
23
|
+
self.reverse_sql = reverse_sql
|
|
24
|
+
self.dependencies = dependencies or []
|
|
25
|
+
self.replace = replace
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from importlib import import_module
|
|
3
|
+
|
|
4
|
+
from django.db.migrations.graph import Node, NodeNotFoundError, CircularDependencyError
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from django.apps import apps
|
|
7
|
+
|
|
8
|
+
SQL_CONFIG_MODULE = settings.__dict__.get('SQL_CONFIG_MODULE', 'sql_config')
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SQLStateGraph(object):
|
|
12
|
+
"""
|
|
13
|
+
Represents graph assembled by SQL items as nodes and parent-child relations as arcs.
|
|
14
|
+
"""
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.nodes = {}
|
|
17
|
+
self.node_map = {}
|
|
18
|
+
self.dependencies = defaultdict(set)
|
|
19
|
+
|
|
20
|
+
def remove_node(self, key):
|
|
21
|
+
# XXX: Workaround for Issue #2
|
|
22
|
+
# Silences state aggregation problem in `migrate` command.
|
|
23
|
+
if key in self.nodes and key in self.node_map:
|
|
24
|
+
del self.nodes[key]
|
|
25
|
+
del self.node_map[key]
|
|
26
|
+
|
|
27
|
+
def add_node(self, key, sql_item):
|
|
28
|
+
node = Node(key)
|
|
29
|
+
self.node_map[key] = node
|
|
30
|
+
self.nodes[key] = sql_item
|
|
31
|
+
|
|
32
|
+
def add_lazy_dependency(self, child, parent):
|
|
33
|
+
"""
|
|
34
|
+
Add dependency to be resolved and applied later.
|
|
35
|
+
"""
|
|
36
|
+
self.dependencies[child].add(parent)
|
|
37
|
+
|
|
38
|
+
def remove_lazy_dependency(self, child, parent):
|
|
39
|
+
"""
|
|
40
|
+
Add dependency to be resolved and applied later.
|
|
41
|
+
"""
|
|
42
|
+
self.dependencies[child].remove(parent)
|
|
43
|
+
|
|
44
|
+
def remove_lazy_for_child(self, child):
|
|
45
|
+
"""
|
|
46
|
+
Remove dependency to be resolved and applied later.
|
|
47
|
+
"""
|
|
48
|
+
if child in self.dependencies:
|
|
49
|
+
del self.dependencies[child]
|
|
50
|
+
|
|
51
|
+
def build_graph(self):
|
|
52
|
+
"""
|
|
53
|
+
Read lazy dependency list and build graph.
|
|
54
|
+
"""
|
|
55
|
+
for child, parents in self.dependencies.items():
|
|
56
|
+
if child not in self.nodes:
|
|
57
|
+
raise NodeNotFoundError(
|
|
58
|
+
"App %s SQL item dependencies reference nonexistent child node %r" % (
|
|
59
|
+
child[0], child),
|
|
60
|
+
child
|
|
61
|
+
)
|
|
62
|
+
for parent in parents:
|
|
63
|
+
if parent not in self.nodes:
|
|
64
|
+
raise NodeNotFoundError(
|
|
65
|
+
"App %s SQL item dependencies reference nonexistent parent node %r" % (
|
|
66
|
+
child[0], parent),
|
|
67
|
+
parent
|
|
68
|
+
)
|
|
69
|
+
self.node_map[child].add_parent(self.node_map[parent])
|
|
70
|
+
self.node_map[parent].add_child(self.node_map[child])
|
|
71
|
+
|
|
72
|
+
for node in self.nodes:
|
|
73
|
+
self.ensure_not_cyclic(node,
|
|
74
|
+
lambda x: (parent.key for parent in self.node_map[x].parents))
|
|
75
|
+
|
|
76
|
+
def ensure_not_cyclic(self, start, get_children):
|
|
77
|
+
# Algo from GvR:
|
|
78
|
+
# http://neopythonic.blogspot.co.uk/2009/01/detecting-cycles-in-directed-graph.html
|
|
79
|
+
todo = set(self.nodes)
|
|
80
|
+
while todo:
|
|
81
|
+
node = todo.pop()
|
|
82
|
+
stack = [node]
|
|
83
|
+
while stack:
|
|
84
|
+
top = stack[-1]
|
|
85
|
+
for node in get_children(top):
|
|
86
|
+
if node in stack:
|
|
87
|
+
cycle = stack[stack.index(node):]
|
|
88
|
+
raise CircularDependencyError(", ".join("%s.%s" % n for n in cycle))
|
|
89
|
+
if node in todo:
|
|
90
|
+
stack.append(node)
|
|
91
|
+
todo.remove(node)
|
|
92
|
+
break
|
|
93
|
+
else:
|
|
94
|
+
node = stack.pop()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_current_graph():
|
|
98
|
+
"""
|
|
99
|
+
Read current state of SQL items from the current project state.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
(SQLStateGraph) Current project state graph.
|
|
103
|
+
"""
|
|
104
|
+
graph = SQLStateGraph()
|
|
105
|
+
for app_name, config in apps.app_configs.items():
|
|
106
|
+
try:
|
|
107
|
+
module = import_module(
|
|
108
|
+
'.'.join((config.module.__name__, SQL_CONFIG_MODULE)))
|
|
109
|
+
sql_items = module.sql_items
|
|
110
|
+
except (ImportError, AttributeError):
|
|
111
|
+
continue
|
|
112
|
+
for sql_item in sql_items:
|
|
113
|
+
graph.add_node((app_name, sql_item.name), sql_item)
|
|
114
|
+
|
|
115
|
+
for dep in sql_item.dependencies:
|
|
116
|
+
graph.add_lazy_dependency((app_name, sql_item.name), dep)
|
|
117
|
+
|
|
118
|
+
graph.build_graph()
|
|
119
|
+
return graph
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Replaces built-in Django command and forces it generate SQL item modification operations
|
|
3
|
+
into regular Django migrations.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import django
|
|
9
|
+
from django.core.management.commands.makemigrations import Command as MakeMigrationsCommand
|
|
10
|
+
from django.db.migrations.loader import MigrationLoader
|
|
11
|
+
from django.db.migrations import Migration
|
|
12
|
+
from django.core.management.base import CommandError, no_translations
|
|
13
|
+
from django.db.migrations.questioner import InteractiveMigrationQuestioner
|
|
14
|
+
from django.apps import apps
|
|
15
|
+
from django.db.migrations.state import ProjectState
|
|
16
|
+
|
|
17
|
+
from ...autodetector import MigrationAutodetector
|
|
18
|
+
from ...graph import build_current_graph
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Command(MakeMigrationsCommand):
|
|
22
|
+
|
|
23
|
+
@no_translations
|
|
24
|
+
def handle(self, *app_labels, **options):
|
|
25
|
+
if django.VERSION >= (4, 1):
|
|
26
|
+
self.written_files = []
|
|
27
|
+
self.verbosity = options.get('verbosity')
|
|
28
|
+
self.interactive = options.get('interactive')
|
|
29
|
+
self.dry_run = options.get('dry_run', False)
|
|
30
|
+
self.merge = options.get('merge', False)
|
|
31
|
+
self.empty = options.get('empty', False)
|
|
32
|
+
self.migration_name = options.get('name', None)
|
|
33
|
+
self.exit_code = options.get('exit_code', False)
|
|
34
|
+
self.include_header = options.get('include_header', True)
|
|
35
|
+
check_changes = options.get('check_changes', False)
|
|
36
|
+
if django.VERSION >= (4, 1):
|
|
37
|
+
self.scriptable = options["scriptable"]
|
|
38
|
+
# If logs and prompts are diverted to stderr, remove the ERROR style.
|
|
39
|
+
if self.scriptable:
|
|
40
|
+
self.stderr.style_func = None
|
|
41
|
+
|
|
42
|
+
# Make sure the app they asked for exists
|
|
43
|
+
app_labels = set(app_labels)
|
|
44
|
+
bad_app_labels = set()
|
|
45
|
+
for app_label in app_labels:
|
|
46
|
+
try:
|
|
47
|
+
apps.get_app_config(app_label)
|
|
48
|
+
except LookupError:
|
|
49
|
+
bad_app_labels.add(app_label)
|
|
50
|
+
if bad_app_labels:
|
|
51
|
+
for app_label in bad_app_labels:
|
|
52
|
+
self.stderr.write("App '%s' could not be found. Is it in INSTALLED_APPS?" % app_label)
|
|
53
|
+
sys.exit(2)
|
|
54
|
+
|
|
55
|
+
# Load the current graph state. Pass in None for the connection so
|
|
56
|
+
# the loader doesn't try to resolve replaced migrations from DB.
|
|
57
|
+
loader = MigrationLoader(None, ignore_no_migrations=True)
|
|
58
|
+
|
|
59
|
+
# Before anything else, see if there's conflicting apps and drop out
|
|
60
|
+
# hard if there are any and they don't want to merge
|
|
61
|
+
conflicts = loader.detect_conflicts()
|
|
62
|
+
|
|
63
|
+
# If app_labels is specified, filter out conflicting migrations for unspecified apps
|
|
64
|
+
if app_labels:
|
|
65
|
+
conflicts = {
|
|
66
|
+
app_label: conflict for app_label, conflict in conflicts.items()
|
|
67
|
+
if app_label in app_labels
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if conflicts and not self.merge:
|
|
71
|
+
name_str = "; ".join(
|
|
72
|
+
"%s in %s" % (", ".join(names), app)
|
|
73
|
+
for app, names in conflicts.items()
|
|
74
|
+
)
|
|
75
|
+
raise CommandError(
|
|
76
|
+
"Conflicting migrations detected (%s).\nTo fix them run "
|
|
77
|
+
"'python manage.py makemigrations --merge'" % name_str
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# If they want to merge and there's nothing to merge, then politely exit
|
|
81
|
+
if self.merge and not conflicts:
|
|
82
|
+
self.stdout.write("No conflicts detected to merge.")
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
# If they want to merge and there is something to merge, then
|
|
86
|
+
# divert into the merge code
|
|
87
|
+
if self.merge and conflicts:
|
|
88
|
+
return self.handle_merge(loader, conflicts)
|
|
89
|
+
|
|
90
|
+
state = loader.project_state()
|
|
91
|
+
|
|
92
|
+
# NOTE: customization. Passing graph to autodetector.
|
|
93
|
+
sql_graph = build_current_graph()
|
|
94
|
+
|
|
95
|
+
# Set up autodetector
|
|
96
|
+
autodetector = MigrationAutodetector(
|
|
97
|
+
state,
|
|
98
|
+
ProjectState.from_apps(apps),
|
|
99
|
+
InteractiveMigrationQuestioner(specified_apps=app_labels, dry_run=self.dry_run),
|
|
100
|
+
sql_graph,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# If they want to make an empty migration, make one for each app
|
|
104
|
+
if self.empty:
|
|
105
|
+
if not app_labels:
|
|
106
|
+
raise CommandError("You must supply at least one app label when using --empty.")
|
|
107
|
+
# Make a fake changes() result we can pass to arrange_for_graph
|
|
108
|
+
changes = {
|
|
109
|
+
app: [Migration("custom", app)]
|
|
110
|
+
for app in app_labels
|
|
111
|
+
}
|
|
112
|
+
changes = autodetector.arrange_for_graph(
|
|
113
|
+
changes=changes,
|
|
114
|
+
graph=loader.graph,
|
|
115
|
+
migration_name=self.migration_name,
|
|
116
|
+
)
|
|
117
|
+
self.write_migration_files(changes)
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
# Detect changes
|
|
121
|
+
changes = autodetector.changes(
|
|
122
|
+
graph=loader.graph,
|
|
123
|
+
trim_to_apps=app_labels or None,
|
|
124
|
+
convert_apps=app_labels or None,
|
|
125
|
+
migration_name=self.migration_name,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if not changes:
|
|
129
|
+
# No changes? Tell them.
|
|
130
|
+
if self.verbosity >= 1:
|
|
131
|
+
if len(app_labels) == 1:
|
|
132
|
+
self.stdout.write("No changes detected in app '%s'" % app_labels.pop())
|
|
133
|
+
elif len(app_labels) > 1:
|
|
134
|
+
self.stdout.write("No changes detected in apps '%s'" % ("', '".join(app_labels)))
|
|
135
|
+
else:
|
|
136
|
+
self.stdout.write("No changes detected")
|
|
137
|
+
|
|
138
|
+
if self.exit_code:
|
|
139
|
+
sys.exit(1)
|
|
140
|
+
else:
|
|
141
|
+
self.write_migration_files(changes)
|
|
142
|
+
if check_changes:
|
|
143
|
+
sys.exit(1)
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import django
|
|
2
|
+
from django.db.migrations.operations import RunSQL
|
|
3
|
+
from django.db.migrations.operations.base import Operation
|
|
4
|
+
if django.VERSION >= (5, 1):
|
|
5
|
+
from django.db.migrations.operations.base import OperationCategory
|
|
6
|
+
|
|
7
|
+
from .graph import SQLStateGraph
|
|
8
|
+
from .config import SQLItem
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MigrateSQLMixin(object):
|
|
12
|
+
def get_sql_state(self, state):
|
|
13
|
+
"""
|
|
14
|
+
Get SQLStateGraph from state.
|
|
15
|
+
"""
|
|
16
|
+
if not hasattr(state, 'sql_state'):
|
|
17
|
+
setattr(state, 'sql_state', SQLStateGraph())
|
|
18
|
+
return state.sql_state
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AlterSQLState(MigrateSQLMixin, Operation):
|
|
22
|
+
"""
|
|
23
|
+
Alters in-memory state of SQL item.
|
|
24
|
+
This operation is generated separately from others since it does not affect database.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
if django.VERSION >= (5, 1):
|
|
28
|
+
category = OperationCategory.PYTHON
|
|
29
|
+
|
|
30
|
+
def describe(self):
|
|
31
|
+
return 'Alter SQL state "{name}"'.format(name=self.name)
|
|
32
|
+
|
|
33
|
+
def deconstruct(self):
|
|
34
|
+
kwargs = {
|
|
35
|
+
'name': self.name,
|
|
36
|
+
}
|
|
37
|
+
if self.add_dependencies:
|
|
38
|
+
kwargs['add_dependencies'] = self.add_dependencies
|
|
39
|
+
if self.remove_dependencies:
|
|
40
|
+
kwargs['remove_dependencies'] = self.remove_dependencies
|
|
41
|
+
return (self.__class__.__name__, [], kwargs)
|
|
42
|
+
|
|
43
|
+
def state_forwards(self, app_label, state):
|
|
44
|
+
sql_state = self.get_sql_state(state)
|
|
45
|
+
key = (app_label, self.name)
|
|
46
|
+
|
|
47
|
+
if key not in sql_state.nodes:
|
|
48
|
+
# XXX: dummy for `migrate` command, that does not preserve state object.
|
|
49
|
+
# Should fail with error when fixed.
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
sql_item = sql_state.nodes[key]
|
|
53
|
+
|
|
54
|
+
for dep in self.add_dependencies:
|
|
55
|
+
# we are also adding relations to aggregated SQLItem, but only to restore
|
|
56
|
+
# original items. Still using graph for advanced node/arc manipulations.
|
|
57
|
+
|
|
58
|
+
# XXX: dummy `if` for `migrate` command, that does not preserve state object.
|
|
59
|
+
# Fail with error when fixed
|
|
60
|
+
if dep in sql_item.dependencies:
|
|
61
|
+
sql_item.dependencies.remove(dep)
|
|
62
|
+
sql_state.add_lazy_dependency(key, dep)
|
|
63
|
+
|
|
64
|
+
for dep in self.remove_dependencies:
|
|
65
|
+
sql_item.dependencies.append(dep)
|
|
66
|
+
sql_state.remove_lazy_dependency(key, dep)
|
|
67
|
+
|
|
68
|
+
def database_forwards(self, app_label, schema_editor, from_state, to_state):
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
def database_backwards(self, app_label, schema_editor, from_state, to_state):
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def reversible(self):
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
def __init__(self, name, add_dependencies=None, remove_dependencies=None):
|
|
79
|
+
"""
|
|
80
|
+
Args:
|
|
81
|
+
name (str): Name of SQL item in current application to alter state for.
|
|
82
|
+
add_dependencies (list):
|
|
83
|
+
Unordered list of dependencies to add to state.
|
|
84
|
+
remove_dependencies (list):
|
|
85
|
+
Unordered list of dependencies to remove from state.
|
|
86
|
+
"""
|
|
87
|
+
self.name = name
|
|
88
|
+
self.add_dependencies = add_dependencies or ()
|
|
89
|
+
self.remove_dependencies = remove_dependencies or ()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class BaseAlterSQL(MigrateSQLMixin, RunSQL):
|
|
93
|
+
"""
|
|
94
|
+
Base class for operations that alter database.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
if django.VERSION >= (5, 1):
|
|
98
|
+
category = OperationCategory.SQL
|
|
99
|
+
|
|
100
|
+
def __init__(self, name, sql, reverse_sql=None, state_operations=None, hints=None):
|
|
101
|
+
super(BaseAlterSQL, self).__init__(sql, reverse_sql=reverse_sql,
|
|
102
|
+
state_operations=state_operations, hints=hints)
|
|
103
|
+
self.name = name
|
|
104
|
+
|
|
105
|
+
def deconstruct(self):
|
|
106
|
+
name, args, kwargs = super(BaseAlterSQL, self).deconstruct()
|
|
107
|
+
kwargs['name'] = self.name
|
|
108
|
+
return (name, args, kwargs)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ReverseAlterSQL(BaseAlterSQL):
|
|
112
|
+
|
|
113
|
+
if django.VERSION >= (5, 1):
|
|
114
|
+
category = OperationCategory.ALTERATION
|
|
115
|
+
|
|
116
|
+
def describe(self):
|
|
117
|
+
return 'Reverse alter SQL "{name}"'.format(name=self.name)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class AlterSQL(BaseAlterSQL):
|
|
121
|
+
"""
|
|
122
|
+
Updates SQL item with a new version.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
if django.VERSION >= (5, 1):
|
|
126
|
+
category = OperationCategory.ALTERATION
|
|
127
|
+
|
|
128
|
+
def __init__(self, name, sql, reverse_sql=None, state_operations=None, hints=None,
|
|
129
|
+
state_reverse_sql=None):
|
|
130
|
+
"""
|
|
131
|
+
Args:
|
|
132
|
+
name (str): Name of SQL item in current application to alter state for.
|
|
133
|
+
sql (str/list): Forward SQL for item creation.
|
|
134
|
+
reverse_sql (str/list): Backward SQL for reversing create operation.
|
|
135
|
+
state_reverse_sql (str/list): Backward SQL used to alter state of backward SQL
|
|
136
|
+
*instead* of `reverse_sql`. Used for operations generated for items with
|
|
137
|
+
`replace` = `True`.
|
|
138
|
+
"""
|
|
139
|
+
super(AlterSQL, self).__init__(name, sql, reverse_sql=reverse_sql,
|
|
140
|
+
state_operations=state_operations, hints=hints)
|
|
141
|
+
self.state_reverse_sql = state_reverse_sql
|
|
142
|
+
|
|
143
|
+
def deconstruct(self):
|
|
144
|
+
name, args, kwargs = super(AlterSQL, self).deconstruct()
|
|
145
|
+
kwargs['name'] = self.name
|
|
146
|
+
if self.state_reverse_sql:
|
|
147
|
+
kwargs['state_reverse_sql'] = self.state_reverse_sql
|
|
148
|
+
return (name, args, kwargs)
|
|
149
|
+
|
|
150
|
+
def describe(self):
|
|
151
|
+
return 'Alter SQL "{name}"'.format(name=self.name)
|
|
152
|
+
|
|
153
|
+
def state_forwards(self, app_label, state):
|
|
154
|
+
super(AlterSQL, self).state_forwards(app_label, state)
|
|
155
|
+
sql_state = self.get_sql_state(state)
|
|
156
|
+
key = (app_label, self.name)
|
|
157
|
+
|
|
158
|
+
if key not in sql_state.nodes:
|
|
159
|
+
# XXX: dummy for `migrate` command, that does not preserve state object.
|
|
160
|
+
# Fail with error when fixed
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
sql_item = sql_state.nodes[key]
|
|
164
|
+
sql_item.sql = self.sql
|
|
165
|
+
sql_item.reverse_sql = self.state_reverse_sql or self.reverse_sql
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class CreateSQL(BaseAlterSQL):
|
|
169
|
+
"""
|
|
170
|
+
Creates new SQL item in database.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
if django.VERSION >= (5, 1):
|
|
174
|
+
category = OperationCategory.ADDITION
|
|
175
|
+
|
|
176
|
+
def describe(self):
|
|
177
|
+
return 'Create SQL "{name}"'.format(name=self.name)
|
|
178
|
+
|
|
179
|
+
def deconstruct(self):
|
|
180
|
+
name, args, kwargs = super(CreateSQL, self).deconstruct()
|
|
181
|
+
kwargs['name'] = self.name
|
|
182
|
+
if self.dependencies:
|
|
183
|
+
kwargs['dependencies'] = self.dependencies
|
|
184
|
+
return (name, args, kwargs)
|
|
185
|
+
|
|
186
|
+
def __init__(self, name, sql, reverse_sql=None, state_operations=None, hints=None,
|
|
187
|
+
dependencies=None):
|
|
188
|
+
super(CreateSQL, self).__init__(name, sql, reverse_sql=reverse_sql,
|
|
189
|
+
state_operations=state_operations, hints=hints)
|
|
190
|
+
self.dependencies = dependencies or ()
|
|
191
|
+
|
|
192
|
+
def state_forwards(self, app_label, state):
|
|
193
|
+
super(CreateSQL, self).state_forwards(app_label, state)
|
|
194
|
+
sql_state = self.get_sql_state(state)
|
|
195
|
+
|
|
196
|
+
sql_state.add_node(
|
|
197
|
+
(app_label, self.name),
|
|
198
|
+
SQLItem(self.name, self.sql, self.reverse_sql, list(self.dependencies)),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
for dep in self.dependencies:
|
|
202
|
+
sql_state.add_lazy_dependency((app_label, self.name), dep)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class DeleteSQL(BaseAlterSQL):
|
|
206
|
+
"""
|
|
207
|
+
Deltes SQL item from database.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
if django.VERSION >= (5, 1):
|
|
211
|
+
category = OperationCategory.REMOVAL
|
|
212
|
+
|
|
213
|
+
def describe(self):
|
|
214
|
+
return 'Delete SQL "{name}"'.format(name=self.name)
|
|
215
|
+
|
|
216
|
+
def state_forwards(self, app_label, state):
|
|
217
|
+
super(DeleteSQL, self).state_forwards(app_label, state)
|
|
218
|
+
sql_state = self.get_sql_state(state)
|
|
219
|
+
|
|
220
|
+
sql_state.remove_node((app_label, self.name))
|
|
221
|
+
sql_state.remove_lazy_for_child((app_label, self.name))
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-migrate-sql-deux
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Django Migrations for raw SQL
|
|
5
|
+
Author-email: Bogdan Klichuk <klichukb@gmail.com>, Bruno Alla <oss@browniebroke.com>
|
|
6
|
+
License: BSD
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/browniebroke/django-migrate-sql-deux/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/browniebroke/django-migrate-sql-deux/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: repository, https://github.com/browniebroke/django-migrate-sql-deux
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Natural Language :: English
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Framework :: Django
|
|
15
|
+
Classifier: Framework :: Django :: 4.2
|
|
16
|
+
Classifier: Framework :: Django :: 5.0
|
|
17
|
+
Classifier: Framework :: Django :: 5.1
|
|
18
|
+
Classifier: Framework :: Django :: 5.2
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: django>=4.2
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# django-migrate-sql-deux
|
|
32
|
+
|
|
33
|
+
<p align="center">
|
|
34
|
+
<a href="https://github.com/browniebroke/django-migrate-sql-deux/actions/workflows/ci.yml?query=branch%3Amain">
|
|
35
|
+
<img src="https://img.shields.io/github/actions/workflow/status/browniebroke/django-migrate-sql-deux/ci.yml?branch=main&label=CI&logo=github&style=flat-square" alt="CI Status" >
|
|
36
|
+
</a>
|
|
37
|
+
<a href="https://codecov.io/gh/browniebroke/django-migrate-sql-deux">
|
|
38
|
+
<img src="https://img.shields.io/codecov/c/github/browniebroke/django-migrate-sql-deux.svg?logo=codecov&logoColor=fff&style=flat-square" alt="Test coverage percentage">
|
|
39
|
+
</a>
|
|
40
|
+
</p>
|
|
41
|
+
<p align="center">
|
|
42
|
+
<a href="https://github.com/astral-sh/uv">
|
|
43
|
+
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json" alt="uv">
|
|
44
|
+
</a>
|
|
45
|
+
<a href="https://github.com/astral-sh/ruff">
|
|
46
|
+
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff">
|
|
47
|
+
</a>
|
|
48
|
+
<a href="https://github.com/pre-commit/pre-commit">
|
|
49
|
+
<img src="https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white&style=flat-square" alt="pre-commit">
|
|
50
|
+
</a>
|
|
51
|
+
</p>
|
|
52
|
+
<p align="center">
|
|
53
|
+
<a href="https://pypi.org/project/django-migrate-sql-deux/">
|
|
54
|
+
<img src="https://img.shields.io/pypi/v/django-migrate-sql-deux.svg?logo=python&logoColor=fff&style=flat-square" alt="PyPI Version">
|
|
55
|
+
</a>
|
|
56
|
+
<img src="https://img.shields.io/pypi/pyversions/django-migrate-sql-deux.svg?style=flat-square&logo=python&logoColor=fff" alt="Supported Python versions">
|
|
57
|
+
<img src="https://img.shields.io/pypi/l/django-migrate-sql-deux.svg?style=flat-square" alt="License">
|
|
58
|
+
</p>
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
**Source Code**: <a href="https://github.com/browniebroke/django-migrate-sql-deux" target="_blank">https://github.com/browniebroke/django-migrate-sql-deux </a>
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
Django Migrations support for raw SQL.
|
|
67
|
+
|
|
68
|
+
> [!NOTE]
|
|
69
|
+
> This package is a fork of the `django-migrate-sql` package, originally published by Bogdan Klichuk. This package appears unmaintained, so we decided to start a fork as we depended on it. Most of the code is from the original author.
|
|
70
|
+
|
|
71
|
+
## About
|
|
72
|
+
|
|
73
|
+
This tool implements mechanism for managing changes to custom SQL entities (functions, types, indices, triggers) using built-in migration mechanism. Technically creates a sophistication layer on top of the
|
|
74
|
+
`RunSQL` Django operation.
|
|
75
|
+
|
|
76
|
+
## What it does
|
|
77
|
+
|
|
78
|
+
- Makes maintaining your SQL functions, custom composite types, indices and triggers easier.
|
|
79
|
+
- Structures SQL into configuration of **SQL items**, that are
|
|
80
|
+
identified by names and divided among apps, just like models.
|
|
81
|
+
- Automatically gathers and persists changes of your custom SQL into
|
|
82
|
+
migrations using `makemigrations`.
|
|
83
|
+
- Properly executes backwards/forwards keeping integrity of database.
|
|
84
|
+
- Create -> Drop -> Recreate approach for changes to items that do not
|
|
85
|
+
support altering and require dropping and recreating.
|
|
86
|
+
- Dependencies system for SQL items, which solves the problem of
|
|
87
|
+
updating items, that rely on others (for example custom
|
|
88
|
+
types/functions that use other custom types), and require dropping all
|
|
89
|
+
dependency tree previously with further recreation.
|
|
90
|
+
|
|
91
|
+
## What it does not
|
|
92
|
+
|
|
93
|
+
- Does not parse SQL nor validate queries during `makemigrations` or
|
|
94
|
+
`migrate` because is database-agnostic. For this same reason setting
|
|
95
|
+
up proper dependencies is user's responsibility.
|
|
96
|
+
- Does not create `ALTER` queries for items that support this, for
|
|
97
|
+
example `ALTER TYPE` in PostgreSQL, because is database-agnostic. In
|
|
98
|
+
case your tools allow rolling all the changes through `ALTER` queries,
|
|
99
|
+
you can consider not using this app **or** restructure migrations
|
|
100
|
+
manually after creation by nesting generated operations into
|
|
101
|
+
`` `state_operations `` of [`RunSQL`](https://docs.djangoproject.com/en/1.8/ref/migration-operations/#runsql)
|
|
102
|
+
that does `ALTER`.
|
|
103
|
+
- (**TODO**)During `migrate` does not restore full state of items for
|
|
104
|
+
analysis, thus does not notify about existing changes to schema that
|
|
105
|
+
are not migrated **nor** does not recognize circular dependencies
|
|
106
|
+
during migration execution.
|
|
107
|
+
|
|
108
|
+
## Installation
|
|
109
|
+
|
|
110
|
+
Install from PyPi:
|
|
111
|
+
|
|
112
|
+
```shell
|
|
113
|
+
pip install django-migrate-sql-deux
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Add `django_migrate_sql` to `INSTALLED_APPS`:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
INSTALLED_APPS = [
|
|
120
|
+
# ...
|
|
121
|
+
'django_migrate_sql',
|
|
122
|
+
]
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
App defines a custom `makemigrations` command, that inherits from
|
|
126
|
+
Django's core one, so in order `django_migrate_sql` app to kick in put it
|
|
127
|
+
after any other apps that redefine `makemigrations` command too.
|
|
128
|
+
|
|
129
|
+
## Usage
|
|
130
|
+
|
|
131
|
+
1) Create `sql_config.py` module to root of a target app you want to
|
|
132
|
+
manage custom SQL for.
|
|
133
|
+
2) Define SQL items in it (`sql_items`), for example:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
# PostgreSQL example.
|
|
137
|
+
# Let's define a simple function and let `django_migrate_sql` manage its changes.
|
|
138
|
+
|
|
139
|
+
from django_migrate_sql.config import SQLItem
|
|
140
|
+
|
|
141
|
+
sql_items = [
|
|
142
|
+
SQLItem(
|
|
143
|
+
'make_sum', # name of the item
|
|
144
|
+
'create or replace function make_sum(a int, b int) returns int as $$ '
|
|
145
|
+
'begin return a + b; end; '
|
|
146
|
+
'$$ language plpgsql;', # forward sql
|
|
147
|
+
reverse_sql='drop function make_sum(int, int);', # sql for removal
|
|
148
|
+
),
|
|
149
|
+
]
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
3) Create migration `python manage.py makemigrations`:
|
|
153
|
+
|
|
154
|
+
Migrations for 'app_name':
|
|
155
|
+
0002_auto_xxxx.py:
|
|
156
|
+
- Create SQL "make_sum"
|
|
157
|
+
|
|
158
|
+
You can take a look at content this generated:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from django.db import migrations, models
|
|
162
|
+
import django_migrate_sql.operations
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class Migration(migrations.Migration):
|
|
166
|
+
dependencies = [
|
|
167
|
+
('app_name', '0001_initial'),
|
|
168
|
+
]
|
|
169
|
+
operations = [
|
|
170
|
+
django_migrate_sql.operations.CreateSQL(
|
|
171
|
+
name='make_sum',
|
|
172
|
+
sql='create or replace function make_sum(a int, b int) returns int as $$ begin return a + b; end; $$ language plpgsql;',
|
|
173
|
+
reverse_sql='drop function make_sum(int, int);',
|
|
174
|
+
),
|
|
175
|
+
]
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
4) Execute migration `python manage.py migrate`:
|
|
179
|
+
|
|
180
|
+
Operations to perform:
|
|
181
|
+
Apply all migrations: app_name
|
|
182
|
+
Running migrations:
|
|
183
|
+
Rendering model states... DONE
|
|
184
|
+
Applying app_name.0002_xxxx... OK
|
|
185
|
+
|
|
186
|
+
Check result in `python manage.py dbshell`:
|
|
187
|
+
|
|
188
|
+
db_name=# select make_sum(12, 15);
|
|
189
|
+
make_sum
|
|
190
|
+
----------
|
|
191
|
+
27
|
|
192
|
+
(1 row)
|
|
193
|
+
|
|
194
|
+
Now, say, you want to change the function implementation so that it
|
|
195
|
+
takes a custom type as argument:
|
|
196
|
+
|
|
197
|
+
5) Edit your `sql_config.py`:
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
# PostgreSQL example #2.
|
|
201
|
+
# Function and custom type.
|
|
202
|
+
|
|
203
|
+
from django_migrate_sql.config import SQLItem
|
|
204
|
+
|
|
205
|
+
sql_items = [
|
|
206
|
+
SQLItem(
|
|
207
|
+
'make_sum', # name of the item
|
|
208
|
+
'create or replace function make_sum(a mynum, b mynum) returns mynum as $$ '
|
|
209
|
+
'begin return (a.num + b.num, 'result')::mynum; end; '
|
|
210
|
+
'$$ language plpgsql;', # forward sql
|
|
211
|
+
reverse_sql='drop function make_sum(mynum, mynum);', # sql for removal
|
|
212
|
+
# depends on `mynum` since takes it as argument. we won't be able to drop function
|
|
213
|
+
# without dropping `mynum` first.
|
|
214
|
+
dependencies=[('app_name', 'mynum')],
|
|
215
|
+
),
|
|
216
|
+
SQLItem(
|
|
217
|
+
'mynum' # name of the item
|
|
218
|
+
'create type mynum as (num int, name varchar(20));', # forward sql
|
|
219
|
+
reverse_sql='drop type mynum;', # sql for removal
|
|
220
|
+
),
|
|
221
|
+
]
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
6) Generate migration `python manage.py makemigrations`:
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
Migrations for 'app_name':
|
|
228
|
+
0003_xxxx:
|
|
229
|
+
- Reverse alter SQL "make_sum"
|
|
230
|
+
- Create SQL "mynum"
|
|
231
|
+
- Alter SQL "make_sum"
|
|
232
|
+
- Alter SQL state "make_sum"
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
You can take a look at the content this generated:
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
from django.db import migrations, models
|
|
239
|
+
import django_migrate_sql.operations
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class Migration(migrations.Migration):
|
|
243
|
+
dependencies = [
|
|
244
|
+
('app_name', '0002_xxxx'),
|
|
245
|
+
]
|
|
246
|
+
operations = [
|
|
247
|
+
django_migrate_sql.operations.ReverseAlterSQL(
|
|
248
|
+
name='make_sum',
|
|
249
|
+
sql='drop function make_sum(int, int);',
|
|
250
|
+
reverse_sql='create or replace function make_sum(a int, b int) returns int as $$ begin return a + b; end; $$ language plpgsql;',
|
|
251
|
+
),
|
|
252
|
+
django_migrate_sql.operations.CreateSQL(
|
|
253
|
+
name='mynum',
|
|
254
|
+
sql='create type mynum as (num int, name varchar(20));',
|
|
255
|
+
reverse_sql='drop type mynum;',
|
|
256
|
+
),
|
|
257
|
+
django_migrate_sql.operations.AlterSQL(
|
|
258
|
+
name='make_sum',
|
|
259
|
+
sql='create or replace function make_sum(a mynum, b mynum) returns mynum as $$ begin return (a.num + b.num, \'result\')::mynum; end; $$ language plpgsql;',
|
|
260
|
+
reverse_sql='drop function make_sum(mynum, mynum);',
|
|
261
|
+
),
|
|
262
|
+
django_migrate_sql.operations.AlterSQLState(
|
|
263
|
+
name='make_sum',
|
|
264
|
+
add_dependencies=[('app_name', 'mynum')],
|
|
265
|
+
),
|
|
266
|
+
]
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
**NOTE:** Previous function is completely dropped before creation
|
|
270
|
+
because definition of it changed. `CREATE OR REPLACE` would create
|
|
271
|
+
another version of it, so `DROP` makes it clean.
|
|
272
|
+
|
|
273
|
+
**If you put `replace=True` as kwarg to an `SQLItem`
|
|
274
|
+
definition, it will NOT drop + create it, but just rerun forward SQL,
|
|
275
|
+
which is `CREATE OR REPLACE` in this example.**
|
|
276
|
+
|
|
277
|
+
7) Execute migration `python manage.py migrate`:
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
```
|
|
281
|
+
Operations to perform:
|
|
282
|
+
Apply all migrations: app_name
|
|
283
|
+
Running migrations:
|
|
284
|
+
Rendering model states... DONE
|
|
285
|
+
Applying brands.0003_xxxx... OK
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Check results:
|
|
289
|
+
|
|
290
|
+
```
|
|
291
|
+
db_name=# select make_sum((5, 'a')::mynum, (3, 'b')::mynum);
|
|
292
|
+
make_sum
|
|
293
|
+
------------
|
|
294
|
+
(8,result)
|
|
295
|
+
(1 row)
|
|
296
|
+
|
|
297
|
+
db_name=# select make_sum(12, 15);
|
|
298
|
+
ERROR: function make_sum(integer, integer) does not exist
|
|
299
|
+
LINE 1: select make_sum(12, 15);
|
|
300
|
+
^
|
|
301
|
+
HINT: No function matches the given name and argument types. You might need to add explicit type casts.
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
For more examples see `tests`.
|
|
305
|
+
|
|
306
|
+
Feel free to [open new issues](https://github.com/browniebroke/django-migrate-sql-deux/issues).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
django_migrate_sql/__init__.py,sha256=RsZjRjMprNcDm97wqRRSk6rTLgTX8N0GyicZyZ8OsBQ,22
|
|
2
|
+
django_migrate_sql/autodetector.py,sha256=e_y_xFtc1SKk34dJuuQ8a0NfaGjhsgdmuD9xrflbUUQ,13501
|
|
3
|
+
django_migrate_sql/config.py,sha256=uYms22MPVPt3obvb9-a7TvzFgZrSK1gR4KDMGZuQlfw,1391
|
|
4
|
+
django_migrate_sql/graph.py,sha256=MWKiZhzuWF4sCMV3elQ8kqV2yljL4PSHHykNV-YZHOo,4111
|
|
5
|
+
django_migrate_sql/operations.py,sha256=8j3FO4xO9i2KXQp3wag82crTVEl7W4Wh_gQMqOsrXKE,7577
|
|
6
|
+
django_migrate_sql/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
django_migrate_sql/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
django_migrate_sql/management/commands/makemigrations.py,sha256=EY3m265PDgx4MKz_sx8gczqd_24Q9MobgjjoD5l_ngI,5535
|
|
9
|
+
django_migrate_sql_deux-1.0.0.dist-info/licenses/LICENSE,sha256=AMfBy7--KzNJGEN6xLEy_-kdwvQP70Ck5y7jLGYmPrs,754
|
|
10
|
+
django_migrate_sql_deux-1.0.0.dist-info/METADATA,sha256=F84DGVO25FRWnRBzOVWtIVr1cd7b5HRDCuuiWVVFWTY,11064
|
|
11
|
+
django_migrate_sql_deux-1.0.0.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
|
|
12
|
+
django_migrate_sql_deux-1.0.0.dist-info/top_level.txt,sha256=At0os64aXXqAwg_WvAa6d9BIoiYXjhHeC84IaUVre3Q,19
|
|
13
|
+
django_migrate_sql_deux-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright (c) 2016, Bogdan Klichuk <klichukb@gmail.com>
|
|
2
|
+
|
|
3
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
4
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
5
|
+
copyright notice and this permission notice appear in all copies.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
8
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
9
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
10
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
11
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
12
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
13
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
django_migrate_sql
|