Skip to content

Commit

Permalink
[2.0.x] Fixed #30023 -- Prevented SQLite schema alterations while for…
Browse files Browse the repository at this point in the history
…eign key checks are enabled.

Prior to this change foreign key constraint references could be left pointing
at tables dropped during operations simulating unsupported table alterations
because of an unexpected failure to disable foreign key constraint checks.

SQLite3 does not allow disabling such checks while in a transaction so they
must be disabled beforehand.

Thanks ezaquarii for the report and Carlton and Tim for the review.

Backport of 315357a from master.
  • Loading branch information
charettes committed Dec 17, 2018
1 parent 53b17d4 commit ecece1b
Show file tree
Hide file tree
Showing 7 changed files with 86 additions and 39 deletions.
12 changes: 7 additions & 5 deletions django/db/backends/sqlite3/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,11 +215,13 @@ def _set_autocommit(self, autocommit):
self.connection.isolation_level = level

def disable_constraint_checking(self):
if self.in_atomic_block:
# sqlite3 cannot disable constraint checking inside a transaction.
return False
self.cursor().execute('PRAGMA foreign_keys = OFF')
return True
with self.cursor() as cursor:
cursor.execute('PRAGMA foreign_keys = OFF')
# Foreign key constraints cannot be turned off while in a multi-
# statement transaction. Fetch the current state of the pragma
# to determine if constraints are effectively disabled.
enabled = cursor.execute('PRAGMA foreign_keys').fetchone()[0]
return not bool(enabled)

def enable_constraint_checking(self):
self.cursor().execute('PRAGMA foreign_keys = ON')
Expand Down
11 changes: 9 additions & 2 deletions django/db/backends/sqlite3/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,15 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):

def __enter__(self):
# Some SQLite schema alterations need foreign key constraints to be
# disabled. Enforce it here for the duration of the transaction.
self.connection.disable_constraint_checking()
# disabled. Enforce it here for the duration of the schema edition.
if not self.connection.disable_constraint_checking():
raise NotSupportedError(
'SQLite schema editor cannot be used while foreign key '
'constraint checks are enabled. Make sure to disable them '
'before entering a transaction.atomic() context because '
'SQLite3 does not support disabling them in the middle of '
'a multi-statement transaction.'
)
self.connection.cursor().execute('PRAGMA legacy_alter_table = ON')
return super().__enter__()

Expand Down
3 changes: 3 additions & 0 deletions docs/releases/2.0.10.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@ Bugfixes
* Fixed a schema corruption issue on SQLite 3.26+. You might have to drop and
rebuild your SQLite database if you applied a migration while using an older
version of Django with SQLite 3.26 or later (:ticket:`29182`).

* Prevented SQLite schema alterations while foreign key checks are enabled to
avoid the possibility of schema corruption (:ticket:`30023`).
64 changes: 47 additions & 17 deletions tests/backends/sqlite/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import unittest

from django.core.exceptions import ImproperlyConfigured
from django.db import connection
from django.db import connection, transaction
from django.db.models import Avg, StdDev, Sum, Variance
from django.db.models.fields import CharField
from django.db.utils import NotSupportedError
Expand All @@ -19,22 +19,6 @@
class Tests(TestCase):
longMessage = True

def test_autoincrement(self):
"""
auto_increment fields are created with the AUTOINCREMENT keyword
in order to be monotonically increasing (#10164).
"""
with connection.schema_editor(collect_sql=True) as editor:
editor.create_model(Square)
statements = editor.collected_sql
match = re.search('"id" ([^,]+),', statements[0])
self.assertIsNotNone(match)
self.assertEqual(
'integer NOT NULL PRIMARY KEY AUTOINCREMENT',
match.group(1),
'Wrong SQL used to create an auto-increment column on SQLite'
)

def test_aggregation(self):
"""
Raise NotImplementedError when aggregating on date/time fields (#19360).
Expand Down Expand Up @@ -80,6 +64,52 @@ class SchemaTests(TransactionTestCase):

available_apps = ['backends']

def test_autoincrement(self):
"""
auto_increment fields are created with the AUTOINCREMENT keyword
in order to be monotonically increasing (#10164).
"""
with connection.schema_editor(collect_sql=True) as editor:
editor.create_model(Square)
statements = editor.collected_sql
match = re.search('"id" ([^,]+),', statements[0])
self.assertIsNotNone(match)
self.assertEqual(
'integer NOT NULL PRIMARY KEY AUTOINCREMENT',
match.group(1),
'Wrong SQL used to create an auto-increment column on SQLite'
)

def test_disable_constraint_checking_failure_disallowed(self):
"""
SQLite schema editor is not usable within an outer transaction if
foreign key constraint checks are not disabled beforehand.
"""
msg = (
'SQLite schema editor cannot be used while foreign key '
'constraint checks are enabled. Make sure to disable them '
'before entering a transaction.atomic() context because '
'SQLite3 does not support disabling them in the middle of '
'a multi-statement transaction.'
)
with self.assertRaisesMessage(NotSupportedError, msg):
with transaction.atomic(), connection.schema_editor(atomic=True):
pass

def test_constraint_checks_disabled_atomic_allowed(self):
"""
SQLite3 schema editor is usable within an outer transaction as long as
foreign key constraints checks are disabled beforehand.
"""
def constraint_checks_enabled():
with connection.cursor() as cursor:
return bool(cursor.execute('PRAGMA foreign_keys').fetchone()[0])
with connection.constraint_checks_disabled(), transaction.atomic():
with connection.schema_editor(atomic=True):
self.assertFalse(constraint_checks_enabled())
self.assertFalse(constraint_checks_enabled())
self.assertTrue(constraint_checks_enabled())

def test_field_rename_inside_atomic_block(self):
"""
NotImplementedError is raised when a model field rename is attempted
Expand Down
24 changes: 12 additions & 12 deletions tests/indexes/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ def test_index_name_hash(self):
"""
Index names should be deterministic.
"""
with connection.schema_editor() as editor:
index_name = editor._create_index_name(
table_name=Article._meta.db_table,
column_names=("c1",),
suffix="123",
)
editor = connection.schema_editor()
index_name = editor._create_index_name(
table_name=Article._meta.db_table,
column_names=("c1",),
suffix="123",
)
self.assertEqual(index_name, "indexes_article_c1_a52bd80b123")

def test_index_name(self):
Expand All @@ -33,12 +33,12 @@ def test_index_name(self):
* Include a deterministic hash.
"""
long_name = 'l%sng' % ('o' * 100)
with connection.schema_editor() as editor:
index_name = editor._create_index_name(
table_name=Article._meta.db_table,
column_names=('c1', 'c2', long_name),
suffix='ix',
)
editor = connection.schema_editor()
index_name = editor._create_index_name(
table_name=Article._meta.db_table,
column_names=('c1', 'c2', long_name),
suffix='ix',
)
expected = {
'mysql': 'indexes_article_c1_c2_looooooooooooooooooo_255179b2ix',
'oracle': 'indexes_a_c1_c2_loo_255179b2ix',
Expand Down
7 changes: 5 additions & 2 deletions tests/model_options/test_tablespaces.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from django.apps import apps
from django.conf import settings
from django.db import connection
from django.test import TestCase, skipIfDBFeature, skipUnlessDBFeature
from django.test import (
TransactionTestCase, skipIfDBFeature, skipUnlessDBFeature,
)

from .models.tablespaces import (
Article, ArticleRef, Authors, Reviewers, Scientist, ScientistRef,
Expand All @@ -21,7 +23,8 @@ def sql_for_index(model):
# We can't test the DEFAULT_TABLESPACE and DEFAULT_INDEX_TABLESPACE settings
# because they're evaluated when the model class is defined. As a consequence,
# @override_settings doesn't work, and the tests depend
class TablespacesTests(TestCase):
class TablespacesTests(TransactionTestCase):
available_apps = ['model_options']

def setUp(self):
# The unmanaged models need to be removed after the test in order to
Expand Down
4 changes: 3 additions & 1 deletion tests/test_runner/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.core.management import call_command
from django.core.management.base import SystemCheckError
from django.test import (
TestCase, TransactionTestCase, skipUnlessDBFeature, testcases,
)
Expand Down Expand Up @@ -201,8 +202,9 @@ def test_ticket_17477(self):
self.assertNoOutput(err)


class Sqlite3InMemoryTestDbs(TestCase):
class Sqlite3InMemoryTestDbs(TransactionTestCase):
multi_db = True
available_apps = ['test_runner']

@unittest.skipUnless(all(db.connections[conn].vendor == 'sqlite' for conn in db.connections),
"This is an sqlite-specific issue")
Expand Down

0 comments on commit ecece1b

Please sign in to comment.