Repository: adamchainz/django-mysql

Issue:
Migration Operations - Install Plugin
`django.contrib.postgres` has extension loading operations, mysql/mariadb syntax should be supported too!



Discussion:

Resolving patch:
diff --git a/django_mysql/operations.py b/django_mysql/operations.py
new file mode 100644
--- /dev/null
+++ b/django_mysql/operations.py
@@ -0,0 +1,60 @@
+from django.db.migrations.operations.base import Operation
+
+
+class InstallPlugin(Operation):
+    reduces_to_sql = False
+
+    reversible = True
+
+    def __init__(self, name, soname):
+        self.name = name
+        self.soname = soname
+
+    def state_forwards(self, app_label, state):
+        pass  # pragma: no cover
+
+    def database_forwards(self, app_label, schema_editor, from_st, to_st):
+        if not self.plugin_installed(schema_editor):
+            schema_editor.execute(
+                "INSTALL PLUGIN {} SONAME %s".format(self.name),
+                (self.soname,),
+            )
+
+    def database_backwards(self, app_label, schema_editor, from_st, to_st):
+        if self.plugin_installed(schema_editor):
+            schema_editor.execute("UNINSTALL PLUGIN %s" % self.name)
+
+    def plugin_installed(self, schema_editor):
+        with schema_editor.connection.cursor() as cursor:
+            cursor.execute(
+                """SELECT COUNT(*)
+                FROM INFORMATION_SCHEMA.PLUGINS
+                WHERE PLUGIN_NAME LIKE %s""",
+                (self.name,)
+            )
+            count = cursor.fetchone()[0]
+            return (count > 0)
+
+    def describe(self):
+        return "Installs plugin %s from %s" % (self.name, self.soname)
+
+
+class InstallSOName(Operation):
+    reduces_to_sql = True
+
+    reversible = True
+
+    def __init__(self, soname):
+        self.soname = soname
+
+    def state_forwards(self, app_label, state):
+        pass  # pragma: no cover
+
+    def database_forwards(self, app_label, schema_editor, from_st, to_st):
+        schema_editor.execute("INSTALL SONAME %s", (self.soname,))
+
+    def database_backwards(self, app_label, schema_editor, from_st, to_st):
+        schema_editor.execute("UNINSTALL SONAME %s", (self.soname,))
+
+    def describe(self):
+        return "Installs library %s" % (self.soname)


Test patch:
diff --git a/tests/django_mysql_tests/test_operations.py b/tests/django_mysql_tests/test_operations.py
new file mode 100644
--- /dev/null
+++ b/tests/django_mysql_tests/test_operations.py
@@ -0,0 +1,89 @@
+from unittest import SkipTest
+
+from django.db import connection
+from django.db.migrations.state import ProjectState
+from django.test import TransactionTestCase
+
+from django_mysql.operations import InstallPlugin, InstallSOName
+
+
+class OperationTests(TransactionTestCase):
+
+    @classmethod
+    def setUpClass(cls):
+        super(OperationTests, cls).setUpClass()
+        has_metadata_lock_plugin = (
+            (connection.is_mariadb and connection.mysql_version >= (10, 0, 7))
+        )
+        if not has_metadata_lock_plugin:
+            raise SkipTest("The metadata_lock_info plugin is required")
+
+    def test_install_plugin(self):
+        """
+        Test we can load the example plugin that every version of MySQL ships
+        with.
+        """
+        self.assertPluginNotExists("metadata_lock_info")
+
+        state = ProjectState()
+        operation = InstallPlugin("metadata_lock_info",
+                                  "metadata_lock_info.so")
+        self.assertEqual(
+            operation.describe(),
+            "Installs plugin metadata_lock_info from metadata_lock_info.so"
+        )
+        new_state = state.clone()
+        with connection.schema_editor() as editor:
+            operation.database_forwards("django_mysql_tests", editor,
+                                        state, new_state)
+
+        self.assertPluginExists("metadata_lock_info")
+
+        new_state = state.clone()
+        with connection.schema_editor() as editor:
+            operation.database_backwards("django_mysql_tests", editor,
+                                         new_state, state)
+
+        self.assertPluginNotExists("metadata_lock_info")
+
+    def test_install_soname(self):
+        """
+        Test we can load the 'metadata_lock_info' library.
+        """
+        self.assertPluginNotExists("metadata_lock_info")
+
+        state = ProjectState()
+        operation = InstallSOName("metadata_lock_info.so")
+        self.assertEqual(
+            operation.describe(),
+            "Installs library metadata_lock_info.so"
+        )
+
+        new_state = state.clone()
+        with connection.schema_editor() as editor:
+            operation.database_forwards("django_mysql_tests", editor,
+                                        state, new_state)
+        self.assertPluginExists("metadata_lock_info")
+
+        new_state = state.clone()
+        with connection.schema_editor() as editor:
+            operation.database_backwards("django_mysql_tests", editor,
+                                         new_state, state)
+        self.assertPluginNotExists("metadata_lock_info")
+
+    def assertPluginExists(self, plugin_name):
+        self.assertEqual(self.plugin_count(plugin_name), 1,
+                         "Plugin %s is not loaded!" % plugin_name)
+
+    def assertPluginNotExists(self, plugin_name):
+        self.assertEqual(self.plugin_count(plugin_name), 0,
+                         "Plugin %s is loaded!" % plugin_name)
+
+    def plugin_count(self, plugin_name):
+        with connection.cursor() as cursor:
+            cursor.execute(
+                """SELECT COUNT(*) FROM INFORMATION_SCHEMA.PLUGINS
+                   WHERE PLUGIN_NAME = %s""",
+                (plugin_name,)
+            )
+            return cursor.fetchone()[0]