Support Django 5.0 db_default attribute.

A DB default is not considered backward incompatible, as the default value is kept on DB-level.
See https://docs.djangoproject.com/en/dev/releases/5.0/#database-computed-default-values
This commit is contained in:
David Wobrock authored and David Wobrock committed 2024-03-29 12:39:30 +01:00
1 parent 3a557cbd7c
commit 18a39cb3fc
12 files changed
+127 -13

No files matched your search

+3
View File
@@ -1,5 +1,8 @@
## 5.x.y ## 5.x.y
Feature:
- Support Django 5.0 `db_default` attribute (issue #275)
Bug: Bug:
- Don't detect 'IS NOT NULL' as backward incompatible changes (issue #263) - Don't detect 'IS NOT NULL' as backward incompatible changes (issue #263)
+2 -1
View File
@@ -71,7 +71,7 @@ Only rolling back the code will make all new insertions crash because Django doe
One would think that adding a default value in Django will prevent these errors. One would think that adding a default value in Django will prevent these errors.
A common misconception is that the Django default value is translated to a database default. A common misconception is that the Django default value is translated to a database default.
But Django actually uses the default value to fill new new column on existing rows and to set an unspecified column value to its default. But Django actually uses the default value to fill the new column on existing rows and to set an unspecified column value to its default.
The latter is done at the application level, by Django and not by the database because the default value was dropped during migration. The latter is done at the application level, by Django and not by the database because the default value was dropped during migration.
You can read more about this in the [Django and its default values blog post](https://medium.com/botify-labs/django-and-its-default-values-c21a13cff9f). You can read more about this in the [Django and its default values blog post](https://medium.com/botify-labs/django-and-its-default-values-c21a13cff9f).
@@ -90,6 +90,7 @@ You can read more about this in the [Django and its default values blog post](ht
:white_check_mark: **Solutions**: :white_check_mark: **Solutions**:
- Make the column nullable - Make the column nullable
- Set a database default using the Django 5.0 attribute `db_default`. See the [Django docs](https://docs.djangoproject.com/en/dev/releases/5.0/#database-computed-default-values)
- Set a database default using Django's [RunSQL](https://docs.djangoproject.com/en/dev/ref/migration-operations/#django.db.migrations.operations.RunSQL) - Set a database default using Django's [RunSQL](https://docs.djangoproject.com/en/dev/ref/migration-operations/#django.db.migrations.operations.RunSQL)
- Set a database default using [django-add-default-value](https://github.com/3YOURMIND/django-add-default-value/) - Set a database default using [django-add-default-value](https://github.com/3YOURMIND/django-add-default-value/)
@@ -32,20 +32,22 @@ def update_migration_checks(
def has_not_null_column(sql_statements: list[str], **kwargs) -> bool: def has_not_null_column(sql_statements: list[str], **kwargs) -> bool:
# TODO: improve to detect that the same column is concerned # TODO: improve to detect that the same column is concerned
ends_with_default = False not_null_column = False
has_default_value = False
for sql in sql_statements: for sql in sql_statements:
if re.search("(?<!DROP )(?<!IS )NOT NULL", sql) and not (
sql.startswith("CREATE TABLE") or sql.startswith("CREATE INDEX")
):
not_null_column = True
if re.search("DEFAULT.*NOT NULL", sql):
has_default_value = True
if "SET DEFAULT" in sql: if "SET DEFAULT" in sql:
ends_with_default = True has_default_value = True
elif "DROP DEFAULT" in sql: if "DROP DEFAULT" in sql:
ends_with_default = False has_default_value = False
return (
any( return not_null_column and not has_default_value
re.search("(?<!DROP )(?<!IS )NOT NULL", sql)
and not (sql.startswith("CREATE TABLE") or sql.startswith("CREATE INDEX"))
for sql in sql_statements
)
and ends_with_default is False
)
def has_add_unique(sql_statements: list[str], **kwargs) -> bool: def has_add_unique(sql_statements: list[str], **kwargs) -> bool:
+3
View File
@@ -8,6 +8,9 @@ RENAME_COLUMN = "app_rename_column"
RENAME_TABLE = "app_rename_table" RENAME_TABLE = "app_rename_table"
IGNORE_MIGRATION = "app_ignore_migration" IGNORE_MIGRATION = "app_ignore_migration"
ADD_NOT_NULL_COLUMN_FOLLOWED_BY_DEFAULT = "app_add_not_null_column_followed_by_default" ADD_NOT_NULL_COLUMN_FOLLOWED_BY_DEFAULT = "app_add_not_null_column_followed_by_default"
ADD_NOT_NULL_COLUMN_FOLLOWED_BY_DB_DEFAULT = (
"app_add_not_null_column_followed_by_db_default"
)
ALTER_COLUMN = "app_alter_column" ALTER_COLUMN = "app_alter_column"
ALTER_COLUMN_DROP_NOT_NULL = "app_alter_column_drop_not_null" ALTER_COLUMN_DROP_NOT_NULL = "app_alter_column_drop_not_null"
DROP_UNIQUE_TOGETHER = "app_unique_together" DROP_UNIQUE_TOGETHER = "app_unique_together"
+12
View File
@@ -2,7 +2,9 @@ from __future__ import annotations
import os import os
import unittest import unittest
from unittest import skipIf
import django
from django.conf import settings from django.conf import settings
from django.core.management import call_command from django.core.management import call_command
@@ -88,6 +90,11 @@ class BaseBackwardCompatibilityDetection:
app = fixtures.ADD_NOT_NULL_COLUMN_FOLLOWED_BY_DEFAULT app = fixtures.ADD_NOT_NULL_COLUMN_FOLLOWED_BY_DEFAULT
self._test_linter_finds_no_errors(app) self._test_linter_finds_no_errors(app)
@skipIf(django.VERSION[0] < 5, "db_default was implemented in Django 5.0")
def test_accept_not_null_column_followed_by_adding_db_default(self):
app = fixtures.ADD_NOT_NULL_COLUMN_FOLLOWED_BY_DB_DEFAULT
self._test_linter_finds_no_errors(app)
def test_detect_alter_column(self): def test_detect_alter_column(self):
app = fixtures.ALTER_COLUMN app = fixtures.ALTER_COLUMN
self._test_linter_finds_no_errors(app) self._test_linter_finds_no_errors(app)
@@ -128,6 +135,11 @@ class SqliteBackwardCompatibilityDetectionTestCase(
app = fixtures.MAKE_NOT_NULL_WITH_DJANGO_DEFAULT app = fixtures.MAKE_NOT_NULL_WITH_DJANGO_DEFAULT
self._test_linter_finds_errors(app) self._test_linter_finds_errors(app)
@skipIf(django.VERSION[0] < 5, "db_default was implemented in Django 5.0")
def test_accept_not_null_column_followed_by_adding_db_default(self):
app = fixtures.ADD_NOT_NULL_COLUMN_FOLLOWED_BY_DB_DEFAULT
self._test_linter_finds_errors(app)
def test_detect_make_column_not_null_with_lib_default(self): def test_detect_make_column_not_null_with_lib_default(self):
# The 'django-add-default-value' doesn't handle sqlite correctly # The 'django-add-default-value' doesn't handle sqlite correctly
app = fixtures.MAKE_NOT_NULL_WITH_LIB_DEFAULT app = fixtures.MAKE_NOT_NULL_WITH_LIB_DEFAULT
@@ -0,0 +1,30 @@
# Generated by Django 5.0.1 on 2024-02-04 20:07
from __future__ import annotations
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="A",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("field", models.IntegerField()),
],
),
]
@@ -0,0 +1,21 @@
# Generated by Django 5.0.1 on 2024-02-04 20:07
from __future__ import annotations
import django.db.models.functions.math
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("app_add_not_null_column_followed_by_db_default", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="a",
name="not_null_field_db_default",
field=models.IntegerField(db_default=django.db.models.functions.math.Pi()),
),
]
@@ -0,0 +1,12 @@
from __future__ import annotations
from django.db import models
from django.db.models.functions import Pi
class A(models.Model):
field = models.IntegerField()
not_null_field_db_default = models.IntegerField(
null=False,
db_default=Pi(),
)
+8
View File
@@ -14,6 +14,8 @@ from __future__ import annotations
import os import os
import django
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -59,6 +61,12 @@ INSTALLED_APPS = [
"tests.test_project.app_create_index_exclusive", "tests.test_project.app_create_index_exclusive",
] ]
if django.VERSION[0] >= 5:
# db_default attribute was only added in Django 5.0
INSTALLED_APPS.append(
"tests.test_project.app_add_not_null_column_followed_by_db_default"
)
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
+22
View File
@@ -66,6 +66,15 @@ class MySqlAnalyserTestCase(SqlAnalyserTestCase):
] ]
self.assertBackwardIncompatibleSql(sql) self.assertBackwardIncompatibleSql(sql)
def test_add_not_null_without_dropping_default(self):
sql = (
"ALTER TABLE `app_add_not_null_column_a` ADD COLUMN `new_not_null_field` integer DEFAULT 1 NOT NULL;",
)
self.assertValidSql(sql)
sql = "ALTER TABLE `app_add_not_null_column_followed_by_default_a` ADD COLUMN `not_null_field_db_default` integer DEFAULT (PI()) NOT NULL;"
self.assertValidSql(sql)
def test_add_not_null_followed_by_default(self): def test_add_not_null_followed_by_default(self):
sql = [ sql = [
"ALTER TABLE `app_add_not_null_column_followed_by_default_a` ADD COLUMN `new_not_null_field` integer DEFAULT 1 NOT NULL;", "ALTER TABLE `app_add_not_null_column_followed_by_default_a` ADD COLUMN `new_not_null_field` integer DEFAULT 1 NOT NULL;",
@@ -147,6 +156,15 @@ class SqliteAnalyserTestCase(SqlAnalyserTestCase):
] ]
self.assertBackwardIncompatibleSql(sql) self.assertBackwardIncompatibleSql(sql)
def test_add_not_null_without_dropping_default(self):
sql = [
'CREATE TABLE "new__app_add_not_null_column_followed_by_default_a" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "not_null_field_db_default" integer DEFAULT (PI()) NOT NULL, "field" integer NOT NULL, "not_null_field" integer NOT NULL);',
'INSERT INTO "new__app_add_not_null_column_followed_by_default_a" ("id", "field", "not_null_field") SELECT "id", "field", "not_null_field" FROM "app_add_not_null_column_followed_by_default_a";',
'DROP TABLE "app_add_not_null_column_followed_by_default_a";',
'ALTER TABLE "new__app_add_not_null_column_followed_by_default_a" RENAME TO "app_add_not_null_column_followed_by_default_a";',
]
self.assertBackwardIncompatibleSql(sql)
def test_create_table_with_not_null(self): def test_create_table_with_not_null(self):
sql = 'CREATE TABLE "app_create_table_with_not_null_column_a" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "field" varchar(150) NOT NULL);' sql = 'CREATE TABLE "app_create_table_with_not_null_column_a" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "field" varchar(150) NOT NULL);'
self.assertValidSql(sql) self.assertValidSql(sql)
@@ -207,6 +225,10 @@ class PostgresqlAnalyserTestCase(SqlAnalyserTestCase):
sql = 'ALTER TABLE "app_alter_column_a" ALTER COLUMN "field" TYPE varchar(10) USING "field"::varchar(10);' sql = 'ALTER TABLE "app_alter_column_a" ALTER COLUMN "field" TYPE varchar(10) USING "field"::varchar(10);'
self.assertBackwardIncompatibleSql(sql) self.assertBackwardIncompatibleSql(sql)
def test_add_not_null_without_dropping_default(self):
sql = 'ALTER TABLE "app_add_not_null_column_followed_by_default_a" ADD COLUMN "not_null_field_db_default" integer DEFAULT (PI()) NOT NULL;'
self.assertValidSql(sql)
def test_not_null_followed_by_default(self): def test_not_null_followed_by_default(self):
sql = [ sql = [
'ALTER TABLE "app_add_not_null_column_followed_by_default_a" ADD COLUMN "not_null_field" integer DEFAULT 1 NOT NULL;', 'ALTER TABLE "app_add_not_null_column_followed_by_default_a" ADD COLUMN "not_null_field" integer DEFAULT 1 NOT NULL;',