Refs #27236 -- Added generic mechanism to handle the deprecation of migration operations.

This commit is contained in:
David Wobrock 2022-07-02 19:55:37 +02:00 committed by Mariusz Felisiak
parent 57793b4765
commit 41019e48bb
12 changed files with 230 additions and 24 deletions

View File

@ -19,6 +19,7 @@ import django.core.checks.caches # NOQA isort:skip
import django.core.checks.compatibility.django_4_0 # NOQA isort:skip
import django.core.checks.database # NOQA isort:skip
import django.core.checks.files # NOQA isort:skip
import django.core.checks.migrations # NOQA isort:skip
import django.core.checks.model_checks # NOQA isort:skip
import django.core.checks.security.base # NOQA isort:skip
import django.core.checks.security.csrf # NOQA isort:skip

View File

@ -0,0 +1,12 @@
from django.core.checks import Tags, register
@register(Tags.migrations)
def check_migration_operations(**kwargs):
from django.db.migrations.loader import MigrationLoader
errors = []
loader = MigrationLoader(None, ignore_no_migrations=True)
for migration in loader.disk_migrations.values():
errors.extend(migration.check())
return errors

View File

@ -15,6 +15,7 @@ class Tags:
compatibility = "compatibility"
database = "database"
files = "files"
migrations = "migrations"
models = "models"
security = "security"
signals = "signals"

View File

@ -219,6 +219,12 @@ class Migration:
name = new_name
return name
def check(self):
errors = []
for operation in self.operations:
errors.extend(operation.check_deprecation_details())
return errors
class SwappableTuple(tuple):
"""

View File

@ -1,7 +1,8 @@
from django.db import router
from django.utils.deprecation import DeprecationForHistoricalMigrationMixin
class Operation:
class Operation(DeprecationForHistoricalMigrationMixin):
"""
Base class for migration operations.
@ -33,6 +34,8 @@ class Operation:
serialization_expand_args = []
check_type = "migrations"
def __new__(cls, *args, **kwargs):
# We capture the arguments to make returning them trivial
self = object.__new__(cls)

View File

@ -83,6 +83,7 @@ Django's system checks are organized using the following tags:
you specify configured database aliases using the ``--database`` option when
calling the :djadmin:`check` command.
* ``files``: Checks files related configuration.
* ``migrations``: Checks of migration operations.
* ``models``: Checks of model, field, and manager definitions.
* ``security``: Checks security related configuration.
* ``signals``: Checks on signal declarations and handler registrations.
@ -94,6 +95,10 @@ Django's system checks are organized using the following tags:
Some checks may be registered with multiple tags.
.. versionchanged:: 4.2
The ``migrations`` tag was added.
Core system checks
==================

View File

@ -169,7 +169,8 @@ Management Commands
Migrations
~~~~~~~~~~
* ...
* The new :ref:`generic mechanism <migrations-removing-operation>` allows
handling the deprecation of migration operations.
Models
~~~~~~

View File

@ -128,18 +128,18 @@ The code below is equivalent to the code above::
.. _field-checking:
Field, model, manager, and database checks
------------------------------------------
Field, model, manager, migration, and database checks
-----------------------------------------------------
In some cases, you won't need to register your check function -- you can
piggyback on an existing registration.
Fields, models, model managers, and database backends all implement a
``check()`` method that is already registered with the check framework. If you
want to add extra checks, you can extend the implementation on the base class,
perform any extra checks you need, and append any messages to those generated
by the base class. It's recommended that you delegate each check to separate
methods.
Fields, models, model managers, migrations, and database backends all implement
a ``check()`` method that is already registered with the check framework. If
you want to add extra checks, you can extend the implementation on the base
class, perform any extra checks you need, and append any messages to those
generated by the base class. It's recommended that you delegate each check to
separate methods.
Consider an example where you are implementing a custom field named
``RangedIntegerField``. This field adds ``min`` and ``max`` arguments to the
@ -194,6 +194,10 @@ the only difference is that the check is a classmethod, not an instance method::
# ... your own checks ...
return errors
.. versionchanged:: 4.2
Migration checks were added.
Writing tests
-------------

View File

@ -503,6 +503,56 @@ database migrations such as ``__init__()``, ``deconstruct()``, and
which reference the field exist. For example, after squashing migrations and
removing the old ones, you should be able to remove the field completely.
.. _migrations-removing-operation:
Considerations when removing migration operations
=================================================
.. versionadded:: 4.2
Removing custom operations from your project or third-party app will cause a
problem if they are referenced in old migrations.
To help with this situation, Django provides some operation attributes to
assist with operation deprecation using the :doc:`system checks framework
</topics/checks>`.
Add the ``system_check_deprecated_details`` attribute to your operation similar
to the following::
class MyCustomOperation(Operation):
system_check_deprecated_details = {
"msg": (
"MyCustomOperation has been deprecated. Support for it "
"(except in historical migrations) will be removed in "
"Django 5.1."
),
"hint": "Use DifferentOperation instead.", # optional
"id": "migrations.W900", # pick a unique ID for your operation.
}
After a deprecation period of your choosing (two or three feature releases for
operations in Django itself), change the ``system_check_deprecated_details``
attribute to ``system_check_removed_details`` and update the dictionary similar
to::
class MyCustomOperation(Operation):
system_check_removed_details = {
"msg": (
"MyCustomOperation has been removed except for support in "
"historical migrations."
),
"hint': "Use DifferentOperation instead.",
"id": "migrations.E900", # pick a unique ID for your operation.
}
You should keep the operation's methods that are required for it to operate in
database migrations such as ``__init__()``, ``state_forwards()``,
``database_forwards()``, and ``database_backwards()``. Keep this stub operation
for as long as any migrations which reference the operation exist. For example,
after squashing migrations and removing the old ones, you should be able to
remove the operation completely.
.. _data-migrations:
Data Migrations

View File

@ -0,0 +1,101 @@
from django.core import checks
from django.db import migrations
from django.db.migrations.operations.base import Operation
from django.test import TestCase
class DeprecatedMigrationOperationTests(TestCase):
def test_default_operation(self):
class MyOperation(Operation):
system_check_deprecated_details = {}
my_operation = MyOperation()
class Migration(migrations.Migration):
operations = [my_operation]
self.assertEqual(
Migration("name", "app_label").check(),
[
checks.Warning(
msg="MyOperation has been deprecated.",
obj=my_operation,
id="migrations.WXXX",
)
],
)
def test_user_specified_details(self):
class MyOperation(Operation):
system_check_deprecated_details = {
"msg": "This operation is deprecated and will be removed soon.",
"hint": "Use something else.",
"id": "migrations.W999",
}
my_operation = MyOperation()
class Migration(migrations.Migration):
operations = [my_operation]
self.assertEqual(
Migration("name", "app_label").check(),
[
checks.Warning(
msg="This operation is deprecated and will be removed soon.",
obj=my_operation,
hint="Use something else.",
id="migrations.W999",
)
],
)
class RemovedMigrationOperationTests(TestCase):
def test_default_operation(self):
class MyOperation(Operation):
system_check_removed_details = {}
my_operation = MyOperation()
class Migration(migrations.Migration):
operations = [my_operation]
self.assertEqual(
Migration("name", "app_label").check(),
[
checks.Error(
msg=(
"MyOperation has been removed except for support in historical "
"migrations."
),
obj=my_operation,
id="migrations.EXXX",
)
],
)
def test_user_specified_details(self):
class MyOperation(Operation):
system_check_removed_details = {
"msg": "Support for this operation is gone.",
"hint": "Use something else.",
"id": "migrations.E999",
}
my_operation = MyOperation()
class Migration(migrations.Migration):
operations = [my_operation]
self.assertEqual(
Migration("name", "app_label").check(),
[
checks.Error(
msg="Support for this operation is gone.",
obj=my_operation,
hint="Use something else.",
id="migrations.E999",
)
],
)

View File

@ -1,11 +1,22 @@
from unittest import mock
from django.db import migrations
from django.db.migrations.operations.base import Operation
class DummyOperation(Operation):
def state_forwards(self, app_label, state):
pass
def database_forwards(self, app_label, schema_editor, from_state, to_state):
pass
def database_backwards(self, app_label, schema_editor, from_state, to_state):
pass
try:
from django.contrib.postgres.operations import CryptoExtension
except ImportError:
CryptoExtension = mock.Mock()
CryptoExtension = DummyOperation
class Migration(migrations.Migration):

View File

@ -1,6 +1,17 @@
from unittest import mock
from django.db import connection, migrations
from django.db.migrations.operations.base import Operation
class DummyOperation(Operation):
def state_forwards(self, app_label, state):
pass
def database_forwards(self, app_label, schema_editor, from_state, to_state):
pass
def database_backwards(self, app_label, schema_editor, from_state, to_state):
pass
try:
from django.contrib.postgres.operations import (
@ -15,14 +26,14 @@ try:
UnaccentExtension,
)
except ImportError:
BloomExtension = mock.Mock()
BtreeGinExtension = mock.Mock()
BtreeGistExtension = mock.Mock()
CITextExtension = mock.Mock()
CreateExtension = mock.Mock()
HStoreExtension = mock.Mock()
TrigramExtension = mock.Mock()
UnaccentExtension = mock.Mock()
BloomExtension = DummyOperation
BtreeGinExtension = DummyOperation
BtreeGistExtension = DummyOperation
CITextExtension = DummyOperation
CreateExtension = DummyOperation
HStoreExtension = DummyOperation
TrigramExtension = DummyOperation
UnaccentExtension = DummyOperation
needs_crypto_extension = False
else:
needs_crypto_extension = (
@ -41,7 +52,7 @@ class Migration(migrations.Migration):
# dash in its name.
CreateExtension("uuid-ossp"),
# CryptoExtension is required for RandomUUID() on PostgreSQL < 13.
CryptoExtension() if needs_crypto_extension else mock.Mock(),
CryptoExtension() if needs_crypto_extension else DummyOperation(),
HStoreExtension(),
TrigramExtension(),
UnaccentExtension(),