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:
1 parent
3a557cbd7c
commit
18a39cb3fc
12 files changed
+127
-13
No files matched your search
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Whitespace-only changes.
+30
@@ -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()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
+21
@@ -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()),
|
||||||
|
),
|
||||||
|
]
|
||||||
Whitespace-only changes.
@@ -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(),
|
||||||
|
)
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;',
|
||||||
|
|||||||
Reference in new issue
Block a user