Chapter 11 · Database backends
11.19 MySQL and MariaDB
Django’s backend django serves two databases, MySQL and MariaDB, through one driver, MySQLdb. It asks the server which of the two it is, of what version and with what settings, keeps the answer as mysql_server_data, and turns much of what its features, its operations object and its schema editor say on that answer.
Django gives the two databases one package and one vendor: DatabaseWrapper is "mysql" for both, and an expression’s as_mysql method serves both. What sets the backend apart from the base classes comes from the server, which says which of the two it is and has settings Django reads; from what Django asks of each connection as it opens; and from the database’s own SQL and limits.
One backend for two databases
DatabaseWrapper is a cached property. The first time anything asks it of a connection, it sends one "SELECT" through BaseDatabaseWrapper, which opens the connection for the question if it is closed and closes it again after, and it keeps the answer for the rest of the connection object’s life, through every close and reopening. The statement asks for the server’s version and four of its variables, and whether "CONVERT_TZ" from UTC to UTC gives anything but null, which the comment says it does only where the server’s time zone tables are loaded. Each connection object asks for itself, so two threads using one alias each ask. And since opening a connection reads the reply too, in DatabaseWrapper, a first question put to a closed connection sends the statement twice, once for the opening and once for the question.
Asked of a connection not yet open, on a stand-in for the driver’s connection that answers as MySQL 8.4, with no server running and lines left out; in this recording, the lines set in under a line of Python are the calls it made, nested as they were made, and a line SQL is a statement as Django handed it to its cursor:
server = mysql_asked.mysql_server_data
DatabaseWrapper.mysql_server_data (on "mysql")
BaseDatabaseWrapper.temporary_connection (on "mysql")
BaseDatabaseWrapper.connect (on "mysql")
...
DatabaseWrapper.init_connection_state (on "mysql")
BaseDatabaseWrapper.init_connection_state (on "mysql")
BaseDatabaseWrapper.check_database_version_supported (on "mysql")
DatabaseWrapper.mysql_server_data (on "mysql")
BaseDatabaseWrapper.temporary_connection (on "mysql")
SQL (on "mysql") SELECT VERSION(), @@sql_mode, @@default_storage_engine, @@sql_auto_is_null, @@lower_case_table_names, CONVERT_TZ('2001-01-01 01:00:00', 'UTC', 'UTC') IS NOT NULL
...
Signal.send (connection_created, sender=DatabaseWrapper)
SQL (on "mysql") SELECT VERSION(), @@sql_mode, @@default_storage_engine, @@sql_auto_is_null, @@lower_case_table_names, CONVERT_TZ('2001-01-01 01:00:00', 'UTC', 'UTC') IS NOT NULL
BaseDatabaseWrapper.temporary_connection resumes
BaseDatabaseWrapper.close (on "mysql")
The version is read from the reply by cached properties too. DatabaseWrapper matches the start of the version string, DatabaseWrapper, against server_version_re, three numbers of one or two digits joined by dots, so that "8.4.6" becomes (8, 4, 6) and "10 becomes (10, 11, 8). DatabaseWrapper is whether the string, in lower case, contains "mariadb", and nothing else tells the two databases apart.
DatabaseWrapper returns mysql_version, and DatabaseFeatures is (10, 11) where the server is MariaDB and (8, 4) where it is MySQL, so the check of the version, made once for each alias as its first connection opens, asks the server before it can compare (Opening and closing a connection). DatabaseWrapper, a class attribute on the other backends, is here a cached property that says "MariaDB" or "MySQL" by the same test, so that the refusal of a server too old, and every warning of the checks, name the database the server is.
Which of the two it is decides much else: most of the features that ask the server; the operations object’s regular expressions and conflict clause; how the schema editor drops a check and defaults a text column; DatabaseWrapper, a cached property here, which maps "UUIDField" to the column "uuid" on MariaDB alone (Column types: from a field to a column); and expressions written for the vendor, such as Cast.as_mysql. The version itself, beyond the oldest supported, is read in this backend only by the features of the UUID functions on MariaDB.
The driver, MySQLdb, and opening a connection
The backend’s base module imports MySQLdb, from the package mysqlclient, as DatabaseWrapper, and raises ImproperlyConfigured where the import fails or MySQLdb is older than 2.2.1. The driver takes Django’s "%s" placeholders as they are, and the backend rewrites none (Cursors: executing a statement, the query log and execute wrappers).
DatabaseWrapper builds the keyword arguments for MySQLdb.connect. It passes on whichever of the user, database, password, host and port are set, a "HOST" that begins with a slash as a Unix socket, with django_conversions, the driver’s table of conversions (Values in and out: adapters, converters and time zones), "utf8mb4" for the "charset", and the client flag FOUND_ROWS. Then it lays "OPTIONS", less "isolation_level", over the arguments, so that anything MySQLdb.connect accepts can be given there and wins over the settings.
The comment on the flag says that Django needs the number of rows an "UPDATE" could affect, not the number it changed. That number is what QuerySet.update returns, and on this backend, which cannot return rows from an update, it is how Model ordinarily learns whether the row it meant to update exists, and so whether to insert it instead (Saving an instance).
The isolation level is "read committed" unless "OPTIONS" names another. get_connection_params puts it in lower case and refuses with ImproperlyConfigured a level that is not one of DatabaseWrapper, the four of SQL; None leaves the server’s own. The level is kept on the connection for DatabaseWrapper, so a bad one is refused when a connection opens, not when the settings are read.
DatabaseWrapper runs once the driver’s connection is open and its autocommit set. It calls the base class’s, which checks the version once for each alias in the process, and then sends, joined in one call through Django’s cursor, up to two statements: "SET SQL_AUTO_IS_NULL = 0" where the features say the server has that variable on, and "SET SESSION TRANSACTION ISOLATION LEVEL" with the level in capitals unless the level is None. The comment on the first says that the variable decides whether the "AUTO_INCREMENT" column of a row just inserted is found by a test for null, and that turning it off brings MySQL in line with the SQL standard. Whichever is made is sent at every opening, the variable read from the reply kept since the first; the server is not asked about itself again. A connection opened once, closed and opened again, on the same stand-in, which reports the variable on, with no server running and lines left out:
mysql_opened.close()
mysql_opened.cursor()
BaseDatabaseWrapper.connect (on "mysql")
DatabaseWrapper.init_connection_state (on "mysql")
BaseDatabaseWrapper.init_connection_state (on "mysql")
SQL (on "mysql") SET SQL_AUTO_IS_NULL = 0; SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED
DatabaseWrapper pings the server and answers false on any error of the driver (Opening and closing a connection). DatabaseWrapper ignores a name, and on this backend is given none: it keeps BaseDatabaseWrapper, which asks for an ordinary cursor, so QuerySet reads through one; MySQL does not stream results, the documentation says, so the driver loads the whole result into memory.
The cursor wrapper and codes_for_integrityerror
MySQL’s CursorWrapper stands between Django’s cursor and the driver’s: DatabaseWrapper wraps the driver’s cursor in it, and Django’s own cursor wraps that. Its docstring gives the reason for a wrapper and not a subclass: it is not bound to whatever class the driver’s connection hands out. CursorWrapper and CursorWrapper give the statement and its parameters to the driver unchanged, and CursorWrapper passes every other attribute through.
What the class adds is a translation. It catches MySQLdb and, where the error’s code is in CursorWrapper, raises Django’s IntegrityError with the same arguments in its place, the comment saying these errors seem misclassified. By the comments beside them the codes are 1048, a column that cannot be null; 1690, a "BIGINT UNSIGNED" value out of range; and 3819 and 4025, a check constraint violated and failed. The exception is Django’s already, so the DatabaseErrorWrapper around the call, which replaces only the driver’s classes, lets it through as it is, without setting the connection’s errors_occurred as it would for an OperationalError; after such an error, then, the end of the request does not ping the server.
sql_mode, strict mode and the checks
DatabaseWrapper is the server’s "@@sql_mode" from the reply, split at its commas into a set. Django reads the mode and never sets one: nothing in the backend sends it.
Strict mode is a check’s concern. DatabaseValidation, which DatabaseValidation adds to the base class’s checks, warns "mysql.W002" where the set holds neither "STRICT_TRANS_TABLES" nor "STRICT_ALL_TABLES", its hint saying that strict mode turns warnings, such as data truncated on insertion, into errors. The per-field half, DatabaseValidation, warns "mysql.W003" of a unique field whose column is a "varchar" longer than 255 or of no length, which its docstring says MySQL does not allow with a unique index, and "fields.W162" of an indexed field whose column type is one of DatabaseWrapper, the text and blob types and "json", which the comment on that tuple says the two databases cannot index at full width (The connection object: BaseDatabaseWrapper and what a backend provides).
The features read the mode too. DatabaseFeatures is whether it holds "NO_AUTO_VALUE_ON_ZERO"; where it does not, DatabaseOperations refuses 0 for an auto field with ValueError, the comment saying that a zero in an "AUTO_INCREMENT" column does not work without it. DatabaseFeatures is true on MySQL, and on MariaDB only where the mode lacks "ONLY_FULL_GROUP_BY"; where it is true the compiler may group by a table’s primary key and leave out the table’s other columns (GROUP BY and HAVING: set_group_by, get_group_by and split_having_qualify).
Foreign key checks switched off, and check_constraints
MySQL checks a foreign key as each statement runs: DatabaseFeatures is false, the flag whose comment asks whether foreign keys are checked at the end of a transaction or at each save. Rows that refer to rows not yet written cannot then go in one at a time, so the backend switches the checks off. DatabaseWrapper sends "SET foreign_key_checks=0" through Django’s cursor and returns True, so that BaseDatabaseWrapper switches them on again after; DatabaseWrapper sends "SET foreign_key_checks=1" with BaseDatabaseWrapper cleared while it runs, which the comment says is for constraint_checks_disabled inside an atomic block, where Django’s cursor would otherwise refuse the statement.
DatabaseWrapper finds what went in broken (The connection object: BaseDatabaseWrapper and what a backend provides). For each foreign key DatabaseIntrospection reports, it selects with a "LEFT JOIN" the rows whose column refers to no row, and raises IntegrityError at the first, naming the row, its value and what it should refer to. DatabaseOperations puts the same switch around its "TRUNCATE" or "DELETE FROM" statements (Introspection: reading the schema back).
The features
MySQL’s DatabaseFeatures holds two kinds of answer. Most of those that do not depend on the server are class attributes, and some of them change SQL that other classes write. DatabaseFeatures is true, so a foreign key to a positive integer key takes that key’s unsigned type (Column types: from a field to a column). DatabaseFeatures is false and DatabaseFeatures true, so OrderBy.as_sql puts "IS NULL" or "IS NOT NULL" before a column to move its nulls, unless the database’s own order already puts them where asked.
The answers that depend on the server are cached properties, each worked out from the reply the first time it is read, and two properties that read one of them. The engine they ask about, DatabaseFeatures, is the server’s default, not any one table’s; the documentation notes that Django creates tables without naming an engine, so they get the default.
DatabaseFeatures that ask the server, and what each turns on. supports_transactions replaces the base class’s, which creates a table to find out (DatabaseFeatures.supports_transactions ); who reads each flag is in What a database can do: BaseDatabaseFeatures.| feature | turns on | answer |
|---|---|---|
minimum_ |
turns on which database | answer (8, 4) for MySQL, (10, 11) for MariaDB |
can_, can_ |
turns on which database | answer true on MariaDB only |
update_, has_ |
turns on which database | answer true on MariaDB only |
has_, supports_, supports_ |
turns on which database | answer true on MySQL only |
supported_ |
turns on which database | answer "JSON", "TEXT" and "TRADITIONAL", with "TREE" on MySQL |
supports_, supports_ |
turns on which database, and its version | answer true on MariaDB 11.7 and later only |
supports_ |
turns on which database, and the engine | answer true on MySQL unless the engine is MyISAM; false on MariaDB |
allows_ |
turns on which database, and sql_mode |
answer true on MySQL; on MariaDB, unless the mode holds "ONLY_ |
allows_auto_pk_0 |
turns on sql_mode |
answer true where the mode holds "NO_ |
supports_, can_ |
turns on the engine | answer true unless it is MyISAM |
supports_ |
turns on the engine | answer true where it is InnoDB |
ignores_, is_ |
turns on a variable of the server | answer "@@lower_ and "@@sql_, as true or false |
has_ |
turns on the time zone tables | answer whether "CONVERT_TZ" gave a value |
The SQL it writes
MySQL’s DatabaseOperations writes most of what makes a statement MySQL’s. DatabaseOperations puts a name in backticks unless it stands in them already, and DatabaseOperations is 64. A slice with an offset and no end still needs a limit in BaseDatabaseOperations, whose BaseDatabaseOperations asks DatabaseOperations for one: 2⁶⁴ − 1, which the comment says the MySQL documentation recommends. Asked of the recording’s MySQL connection:
mysql.ops.quote_name('fleet_ship') -> '`fleet_ship`'
mysql.ops.limit_offset_sql(5, 15) -> 'LIMIT 10 OFFSET 5'
mysql.ops.limit_offset_sql(5, None) -> 'LIMIT 18446744073709551615 OFFSET 5'
mysql.ops.no_limit_value() -> 18446744073709551615
DatabaseOperations gives the compiler an ordering of "NULL" for a statement that has a "GROUP BY" and an empty ordering, so that it ends "ORDER BY NULL"; the docstring says this keeps MySQL from ordering by the grouped columns of its own accord.
To ignore a conflict on insert, DatabaseOperations begins the statement "INSERT IGNORE INTO"; to update on one, DatabaseOperations writes "ON DUPLICATE KEY UPDATE" and sets each field from the row that was to go in, on MySQL by naming that row "AS new", since, the comment says, MySQL does not support "VALUES()" there, and on MariaDB with "VALUE()" of the column. The clause names no columns to match on: the features lack BaseDatabaseFeatures, and QuerySet refuses to be given unique fields with NotSupportedError (Inserts: returned keys, conflicts and batch sizes).
The lookups that match text are in DatabaseWrapper: the case-insensitive ones a plain "LIKE", and "contains", "startswith" and "endswith" a "LIKE BINARY". Whether a plain "LIKE", or "=", ignores case is then the column’s collation’s to say, and the documentation notes that MySQL’s default collation for UTF-8 ignores it. DatabaseOperations writes "REGEXP_LIKE" with the match type 'c' or 'i' on MySQL, and on MariaDB, where the comment says that function does not exist, "REGEXP BINARY" or "REGEXP". DatabaseOperations wraps the column of a JSON field in "JSON_UNQUOTE", for every lookup on MariaDB, and on MySQL for the case-insensitive match, the pattern lookups and the regular expressions.
Dates and times are written with MySQL’s own functions, a datetime’s truncation as "DATE_FORMAT" cast back, and a change of zone with "CONVERT_TZ" from the connection’s time zone to the one asked for where USE_TZ is on and the two differ, which gives null on a server without its time zone tables (Values in and out: adapters, converters and time zones). A truncation to the month, asked of the recording’s MySQL connection:
mysql.ops.datetime_trunc_sql('month', '"crew_sailor"."signed_on"', (), 'Europe/Oslo') -> ('CAST(DATE_FORMAT(CONVERT_TZ("crew_sailor"."signed_on", %s, %s), %s) AS DATETIME)', ('UTC', 'Europe/Oslo', '%Y-%m-01 00:00:00'))
DatabaseOperations names a module of the backend’s own, whose SQLDeleteCompiler deletes over joined tables as "DELETE" of the base table "FROM" the joins, and whose SQLUpdateCompiler adds the update’s ordering where it is of the table’s own columns; both are told, with PostgreSQL’s insert compiler, in Inserts: returned keys, conflicts and batch sizes.
The schema editor
MySQL’s DatabaseSchemaEditor starts from templates of its own, among them MySQL’s ways of dropping an index or a foreign key and renaming a table. A column’s type and its null are changed with "MODIFY", which restates its whole definition, so DatabaseSchemaEditor adds the old field’s database default and its "NULL" or "NOT NULL" to the new type, to keep them. A check is dropped with "DROP CHECK" on MySQL and "DROP CONSTRAINT IF EXISTS" on MariaDB. The base class and its order of work, and MySQL’s departures from it in altering a column, are told in The schema editor: BaseDatabaseSchemaEditor and Altering a column, and SQLite’s table remake.
A text or blob column takes a default its own way. On MySQL DatabaseSchemaEditor writes the default of such a column, or of a JSON one, in parentheses, the comment saying MySQL takes it only as an expression, and DatabaseSchemaEditor is true, since it takes none in an "ALTER COLUMN". So BaseDatabaseSchemaEditor, which drops a default once the column is added, leaves that one in place, and BaseDatabaseSchemaEditor writes it only where the column is not null. On both databases DatabaseSchemaEditor is true for a text or blob column whose default is empty, which is then made without one; where the field declares that default, DatabaseSchemaEditor follows with an "UPDATE" that sets it in every row.
On InnoDB the index of a foreign key with its constraint is the database’s. DatabaseSchemaEditor asks DatabaseIntrospection for the table’s engine and makes no index for a ForeignKey with its constraint on InnoDB, the comment saying the constraint brings one, nor for a column of a limited type. That index can go missing: the docstring of DatabaseSchemaEditor says MySQL may drop it when another index has the key’s column first, and then the other index cannot be removed. So before DatabaseSchemaEditor, DatabaseSchemaEditor of a unique constraint, or DatabaseSchemaEditor drops an index that starts with a foreign key’s column, the method reads the table’s constraints and, where that is the only index to start so, creates the key’s own index first.
The client
MySQL’s DatabaseClient runs "mysql". DatabaseClient takes the database, user, password, host and port from "OPTIONS" before the settings; passes an option file in "read_default_file" as --defaults-file, a host with a slash as --socket, and the files of "ssl" and the "charset" as flags; and puts the password in the environment as "MYSQL_PWD", which the comment prefers to --password, an argument exposed on more systems (The connection object: BaseDatabaseWrapper and what a backend provides). A set of settings, and the command line made of it with no client run:
shore -> {'NAME': 'harbour', 'USER': 'purser', 'PASSWORD': 'tide', 'HOST': 'db.harbour.example', 'PORT': '5432', 'OPTIONS': {}}
...
MySQLClient = load_backend('django.db.backends.mysql').DatabaseWrapper.client_class
full(MySQLClient.settings_to_cmd_args_env(shore, [])) -> (['mysql', '--user=purser', '--host=db.harbour.example', '--port=5432', 'harbour'], {'MYSQL_PWD': 'tide'})