Skip to content

Commit 72ef10c

Browse files
committed
add RunMQL operation
1 parent c90a00c commit 72ef10c

File tree

4 files changed

+207
-0
lines changed

4 files changed

+207
-0
lines changed

django_mongodb/migrations.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from django.db import router
2+
from django.db.migrations.operations.base import Operation
3+
4+
5+
class RunMQL(Operation):
6+
"""
7+
Run some raw MQL. A reverse MQL statement may be provided.
8+
9+
Also accept a list of operations that represent the state change effected
10+
by this MQL change, in case it's custom column/table creation/deletion.
11+
"""
12+
13+
noop = ""
14+
15+
def __init__(self, code, reverse_code=None, state_operations=None, hints=None, elidable=False):
16+
# Forwards code
17+
if not callable(code):
18+
raise ValueError("RunMQL must be supplied with a callable")
19+
self.code = code
20+
# Reverse code
21+
self.reverse_code = reverse_code
22+
if reverse_code is not None and not callable(reverse_code):
23+
raise ValueError("RunMQL must be supplied with callable arguments")
24+
self.state_operations = state_operations or []
25+
self.hints = hints or {}
26+
self.elidable = elidable
27+
28+
def deconstruct(self):
29+
kwargs = {
30+
"code": self.code,
31+
}
32+
if self.reverse_code is not None:
33+
kwargs["reverse_code"] = self.reverse_code
34+
if self.state_operations:
35+
kwargs["state_operations"] = self.state_operations
36+
if self.hints:
37+
kwargs["hints"] = self.hints
38+
return (self.__class__.__qualname__, [], kwargs)
39+
40+
@property
41+
def reversible(self):
42+
return self.reverse_code is not None
43+
44+
def state_forwards(self, app_label, state):
45+
for state_operation in self.state_operations:
46+
state_operation.state_forwards(app_label, state)
47+
48+
def database_forwards(self, app_label, schema_editor, from_state, to_state):
49+
if router.allow_migrate(schema_editor.connection.alias, app_label, **self.hints):
50+
self.code(schema_editor, schema_editor.get_database())
51+
52+
def database_backwards(self, app_label, schema_editor, from_state, to_state):
53+
if self.reverse_code is None:
54+
raise NotImplementedError("You cannot reverse this operation")
55+
if router.allow_migrate(schema_editor.connection.alias, app_label, **self.hints):
56+
self.reverse_code(schema_editor, schema_editor.get_database())
57+
58+
def describe(self):
59+
return "Raw MQL operation"

docs/source/migrations.rst

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
Migrations API reference
2+
========================
3+
4+
You can use PyMongo operations in your migrations. In lieu of the ``RunMQL``,
5+
use ``RunMQL``.
6+
7+
8+
9+
``RunMQL``
10+
----------
11+
12+
.. class:: RunMQL(code, reverse_code=None, state_operations=None, hints=None, elidable=False)
13+
14+
Allows running of arbitrary MQL on the database - useful for more advanced
15+
features of database backends that Django doesn't support directly.
16+
17+
``sql``, and ``reverse_sql`` if provided, should be strings of MQL to run on
18+
the database. On most database backends (all but PostgreMQL), Django will
19+
split the MQL into individual statements prior to executing them.
20+
21+
If you want to include literal percent signs in the query, you have to double
22+
them if you are passing parameters.
23+
24+
The ``reverse_sql`` queries are executed when the migration is unapplied. They
25+
should undo what is done by the ``sql`` queries. For example, to undo the above
26+
insertion with a deletion::
27+
28+
migrations.RunMQL(
29+
forward_func=[("INSERT INTO musician (name) VALUES (%s);", ["Reinhardt"])],
30+
reverse_func=[("DELETE FROM musician where name=%s;", ["Reinhardt"])],
31+
)
32+
33+
If ``reverse_sql`` is ``None`` (the default), the ``RunMQL`` operation is
34+
irreversible.
35+
36+
The ``state_operations`` argument allows you to supply operations that are
37+
equivalent to the MQL in terms of project state. For example, if you are
38+
manually creating a column, you should pass in a list containing an ``AddField``
39+
operation here so that the autodetector still has an up-to-date state of the
40+
model. If you don't, when you next run ``makemigrations``, it won't see any
41+
operation that adds that field and so will try to run it again. For example::
42+
43+
migrations.RunMQL(
44+
"ALTER TABLE musician ADD COLUMN name varchar(255) NOT NULL;",
45+
state_operations=[
46+
migrations.AddField(
47+
"musician",
48+
"name",
49+
models.CharField(max_length=255),
50+
),
51+
],
52+
)
53+
54+
The optional ``hints`` argument will be passed as ``**hints`` to the
55+
:meth:`allow_migrate` method of database routers to assist them in making
56+
routing decisions. See :ref:`topics-db-multi-db-hints` for more details on
57+
database hints.
58+
59+
The optional ``elidable`` argument determines whether or not the operation will
60+
be removed (elided) when :ref:`squashing migrations <migration-squashing>`.
61+
62+
.. attribute:: RunMQL.noop
63+
64+
Pass the ``RunMQL.noop`` attribute to ``sql`` or ``reverse_sql`` when you
65+
want the operation not to do anything in the given direction. This is
66+
especially useful in making the operation reversible.
67+
68+
69+
70+
71+
72+
def forwards_func(apps, schema_editor, database):

tests/migrations_/__init__.py

Whitespace-only changes.

tests/migrations_/test_operations.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from django.db import connection
2+
from django.db.migrations.state import ProjectState
3+
from migrations.test_base import OperationTestBase
4+
5+
from django_mongodb.migrations import RunMQL
6+
7+
8+
def create_collection(schema_editor, database): # noqa: ARG001
9+
database.create_collection("test_runmql")
10+
11+
12+
def drop_collection(schema_editor, database): # noqa: ARG001
13+
database.drop_collection("test_runmql")
14+
15+
16+
class RunMQLTests(OperationTestBase):
17+
available_apps = ["migrations_"]
18+
19+
def test_basic(self):
20+
project_state = ProjectState()
21+
operation = RunMQL(create_collection, reverse_code=drop_collection)
22+
self.assertEqual(operation.describe(), "Raw MQL operation")
23+
# Test the state alteration does nothing
24+
new_state = project_state.clone()
25+
operation.state_forwards("test_runmql", new_state)
26+
self.assertEqual(new_state, project_state)
27+
# Test the database alteration
28+
self.assertTableNotExists("test_runmql")
29+
with connection.schema_editor() as editor:
30+
operation.database_forwards("test_runmql", editor, project_state, new_state)
31+
self.assertTableExists("test_runmql")
32+
# Now test reversal
33+
self.assertTrue(operation.reversible)
34+
with connection.schema_editor() as editor:
35+
operation.database_backwards("test_runmql", editor, project_state, new_state)
36+
self.assertTableNotExists("test_runmql")
37+
# Test deconstruction
38+
definition = operation.deconstruct()
39+
self.assertEqual(definition[0], "RunMQL")
40+
self.assertEqual(definition[1], [])
41+
self.assertEqual(sorted(definition[2]), ["code", "reverse_code"])
42+
# Also test reversal fails, with an operation identical to above but
43+
# without reverse_code set.
44+
no_reverse_operation = RunMQL(create_collection)
45+
self.assertFalse(no_reverse_operation.reversible)
46+
with connection.schema_editor() as editor:
47+
no_reverse_operation.database_forwards("test_runmql", editor, project_state, new_state)
48+
with self.assertRaises(NotImplementedError):
49+
no_reverse_operation.database_backwards(
50+
"test_runmql", editor, new_state, project_state
51+
)
52+
self.assertTableExists("test_runmql")
53+
54+
def test_run_msql_no_reverse(self):
55+
project_state = ProjectState()
56+
new_state = project_state.clone()
57+
operation = RunMQL(create_collection)
58+
self.assertTableNotExists("test_runmql")
59+
with connection.schema_editor() as editor:
60+
operation.database_forwards("test_runmql", editor, project_state, new_state)
61+
self.assertTableExists("test_runmql")
62+
# And deconstruction
63+
definition = operation.deconstruct()
64+
self.assertEqual(definition[0], "RunMQL")
65+
self.assertEqual(definition[1], [])
66+
self.assertEqual(sorted(definition[2]), ["code"])
67+
68+
def test_elidable(self):
69+
operation = RunMQL(create_collection)
70+
self.assertIs(operation.reduce(operation, []), False)
71+
elidable_operation = RunMQL(create_collection, elidable=True)
72+
self.assertEqual(elidable_operation.reduce(operation, []), [operation])
73+
74+
def test_run_mql_invalid_code(self):
75+
with self.assertRaisesMessage(ValueError, "RunMQL must be supplied with a callable"):
76+
RunMQL("print 'ahahaha'")

0 commit comments

Comments
 (0)