Repository: adamchainz/django-mysql

Issue:
Checks with warnings for a saner MySQL world
Add checks that:
1. `utf8mb4` charset is used for connections, not `utf8`.
2. The database is built with `utf8mb4`
3. `sql_mode` contains `STRICT_TRANS_TABLES` or `STRICT_ALL_TABLES`
4. ... anything else that makes MySQL better for everyone 



Discussion:

Resolving patch:
diff --git a/django_mysql/apps.py b/django_mysql/apps.py
--- a/django_mysql/apps.py
+++ b/django_mysql/apps.py
@@ -3,6 +3,8 @@
 from django.conf import settings
 from django.utils.translation import ugettext_lazy as _
 
+from .checks import register_checks
+
 
 class MySQLConfig(AppConfig):
     name = 'django_mysql'
@@ -11,6 +13,7 @@ class MySQLConfig(AppConfig):
     def ready(self):
         self.perform_monkey_patches()
         self.add_lookups()
+        register_checks()
 
     def perform_monkey_patches(self):
         from django.db.backends.mysql.base import DatabaseWrapper
diff --git a/django_mysql/checks.py b/django_mysql/checks.py
new file mode 100644
--- /dev/null
+++ b/django_mysql/checks.py
@@ -0,0 +1,97 @@
+# -*- coding:utf-8 -*-
+from __future__ import unicode_literals
+
+import django
+from django.core.checks import Tags, Warning, register
+from django.db import DEFAULT_DB_ALIAS, connections
+
+from django_mysql.utils import collapse_spaces
+
+
+def register_checks():
+    if django.VERSION[:2] >= (1, 8):
+        # These checks connect to the DB, which only works on Django 1.8+
+        register(Tags.compatibility)(check_variables)
+
+
+def check_variables():
+    errors = []
+
+    for alias, connection in mysql_connections():
+        with connection.cursor() as cursor:
+            cursor.execute("""SELECT @@sql_mode,
+                                     @@innodb_strict_mode,
+                                     @@character_set_connection""")
+            variables = cursor.fetchone()
+            sql_mode, innodb_strict_mode, character_set_connection = variables
+
+        modes = set(sql_mode.split(','))
+        if not (modes & {'STRICT_TRANS_TABLES', 'STRICT_ALL_TABLES'}):
+            errors.append(strict_mode_warning(alias))
+
+        if not innodb_strict_mode:
+            errors.append(innodb_strict_mode_warning(alias))
+
+        if character_set_connection != 'utf8mb4':
+            errors.append(utf8mb4_warning(alias))
+
+    return errors
+
+
+def strict_mode_warning(alias):
+    message = "MySQL Strict Mode is not set for database connection '{}'"
+    hint = collapse_spaces("""
+        MySQL's Strict Mode fixes many data integrity problems in MySQL, such
+        as data truncation upon insertion, by escalating warnings into errors.
+        It is strongly recommended you activate it. See:
+        http://django-mysql.readthedocs.org/en/latest/checks.html#django-mysql-w001-strict-mode
+    """)
+    return Warning(
+        message.format(alias),
+        hint=hint,
+        id='django_mysql.W001',
+    )
+
+
+def innodb_strict_mode_warning(alias):
+    message = "InnoDB Strict Mode is not set for database connection '{}'"
+    hint = collapse_spaces("""
+        InnoDB Strict Mode escalates several warnings around InnoDB-specific
+        statements into errors. It's recommended you activate this, but it's
+        not very likely to affect you if you don't. See:
+        http://django-mysql.readthedocs.org/en/latest/checks.html#django-mysql-w002-innodb-strict-mode
+    """)
+
+    return Warning(
+        message.format(alias),
+        hint=hint,
+        id='django_mysql.W002',
+    )
+
+
+def utf8mb4_warning(alias):
+    message = "The character set is not utf8mb4 for database connection '{}'"
+    hint = collapse_spaces("""
+        The default 'utf8' character set does not include support for all
+        Unicode characters. It's strongly recommended you move to use
+        'utf8mb4'. See:
+        http://django-mysql.readthedocs.org/en/latest/checks.html#django-mysql-w003-utf8mb4
+    """)
+
+    return Warning(
+        message.format(alias),
+        hint=hint,
+        id='django_mysql.W003',
+    )
+
+
+def mysql_connections():
+    conn_names = [DEFAULT_DB_ALIAS] + list(
+        set(connections) - {DEFAULT_DB_ALIAS}
+    )
+    for alias in conn_names:
+        connection = connections[alias]
+        if not hasattr(connection, 'mysql_version'):
+            continue  # pragma: no cover
+
+        yield alias, connection


Test patch:
diff --git a/tests/settings.py b/tests/settings.py
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -19,7 +19,8 @@
         'PORT': '',
         'OPTIONS': {
             'charset': 'utf8mb4',
-            'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
+            'init_command': "SET sql_mode='STRICT_TRANS_TABLES', "
+                            "innodb_strict_mode=1",
         },
         'TEST': {
             'COLLATION': "utf8mb4_general_ci",
@@ -35,7 +36,8 @@
         'PORT': '',
         'OPTIONS': {
             'charset': 'utf8mb4',
-            'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
+            'init_command': "SET sql_mode='STRICT_TRANS_TABLES', "
+                            "innodb_strict_mode=1",
         },
         'TEST': {
             'COLLATION': "utf8mb4_general_ci",
diff --git a/tests/testapp/test_checks.py b/tests/testapp/test_checks.py
new file mode 100644
--- /dev/null
+++ b/tests/testapp/test_checks.py
@@ -0,0 +1,41 @@
+# -*- coding:utf-8 -*-
+from unittest import skipIf
+
+import django
+from django.test import TransactionTestCase
+
+from django_mysql.checks import check_variables
+from django_mysql.test.utils import override_mysql_variables
+
+requiresNoDBConnection = skipIf(
+    django.VERSION[:2] < (1, 8),
+    "Requires nodb_connection from Django 1.8+"
+)
+
+
+@requiresNoDBConnection
+class VariablesTests(TransactionTestCase):
+
+    def test_passes(self):
+        assert check_variables() == []
+
+    @override_mysql_variables(sql_mode="")
+    def test_fails_if_no_strict(self):
+        errors = check_variables()
+        assert len(errors) == 1
+        assert errors[0].id == 'django_mysql.W001'
+        assert "MySQL Strict Mode" in errors[0].msg
+
+    @override_mysql_variables(innodb_strict_mode=0)
+    def test_fails_if_no_innodb_strict(self):
+        errors = check_variables()
+        assert len(errors) == 1
+        assert errors[0].id == 'django_mysql.W002'
+        assert "InnoDB Strict Mode" in errors[0].msg
+
+    @override_mysql_variables(character_set_connection='utf8')
+    def test_fails_if_not_utf8mb4(self):
+        errors = check_variables()
+        assert len(errors) == 1
+        assert errors[0].id == 'django_mysql.W003'
+        assert "utf8mb4" in errors[0].msg