diff --git a/.github/workflows/check-migrations.yml b/.github/workflows/check-migrations.yml new file mode 100644 index 000000000000..8b29b2bb34f7 --- /dev/null +++ b/.github/workflows/check-migrations.yml @@ -0,0 +1,68 @@ +name: Check migrations + +on: + pull_request: + paths: + - 'tests/**/models.py' + - 'tests/**/migrations/**' + - 'tests/**/*migrations/**/*.py' + - 'scripts/check_migrations.py' + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + check-migrations: + name: Check test migrations + runs-on: ubuntu-latest + timeout-minutes: 60 + + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_DB: django + POSTGRES_USER: user + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.14' + cache: 'pip' + cache-dependency-path: 'tests/requirements/py3.txt' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip wheel + python -m pip install -e . psycopg[binary] + + - name: Create PostgreSQL settings file + run: mv ./.github/workflows/data/test_postgres.py.tpl ./tests/test_postgres.py + + - name: Check for missing migrations + env: + DJANGO_SETTINGS_MODULE: test_postgres + PYTHONPATH: ${{ github.workspace }}/tests:${{ github.workspace }} + run: | + python scripts/check_migrations.py diff --git a/django/db/models/expressions.py b/django/db/models/expressions.py index 6b90a42cf1d2..baa91cc2c173 100644 --- a/django/db/models/expressions.py +++ b/django/db/models/expressions.py @@ -1141,7 +1141,7 @@ def allowed_default(self): @deconstructible(path="django.db.models.Value") -class Value(SQLiteNumericMixin, Expression): +class Value(Expression): """Represent a wrapped value as a node within an expression.""" # Provide a default value for `for_save` in order to allow unresolved @@ -1182,6 +1182,18 @@ def as_sql(self, compiler, connection): return "NULL", [] return "%s", [val] + def as_sqlite(self, compiler, connection, **extra_context): + sql, params = self.as_sql(compiler, connection, **extra_context) + try: + if self.output_field.get_internal_type() == "DecimalField": + if isinstance(self.value, Decimal): + sql = "(CAST(%s AS REAL))" % sql + else: + sql = "(CAST(%s AS NUMERIC))" % sql + except FieldError: + pass + return sql, params + def resolve_expression( self, query=None, allow_joins=True, reuse=None, summarize=False, for_save=False ): diff --git a/scripts/check_migrations.py b/scripts/check_migrations.py new file mode 100644 index 000000000000..70d187e14433 --- /dev/null +++ b/scripts/check_migrations.py @@ -0,0 +1,31 @@ +import sys +from pathlib import Path + + +def main(): + repo_root = Path(__file__).resolve().parent.parent + sys.path[:0] = [str(repo_root / "tests"), str(repo_root)] + + from runtests import ALWAYS_INSTALLED_APPS, get_apps_to_install, get_test_modules + + import django + from django.apps import apps + from django.core.management import call_command + + django.setup() + + test_modules = list(get_test_modules(gis_enabled=False)) + installed_apps = list(ALWAYS_INSTALLED_APPS) + for app in get_apps_to_install(test_modules): + # Check against the list to prevent duplicate errors. + if app not in installed_apps: + installed_apps.append(app) + apps.set_installed_apps(installed_apps) + + # Note: We don't use check=True here because --check calls sys.exit(1) + # instead of raising CommandError when migrations are missing. + call_command("makemigrations", "--check", verbosity=3) + + +if __name__ == "__main__": + main() diff --git a/tests/db_functions/migrations/0002_create_test_models.py b/tests/db_functions/migrations/0002_create_test_models.py index 6c4626e7ea44..2fa924cbe005 100644 --- a/tests/db_functions/migrations/0002_create_test_models.py +++ b/tests/db_functions/migrations/0002_create_test_models.py @@ -10,6 +10,15 @@ class Migration(migrations.Migration): migrations.CreateModel( name="Author", fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ("name", models.CharField(max_length=50)), ("alias", models.CharField(max_length=50, null=True, blank=True)), ("goes_by", models.CharField(max_length=50, null=True, blank=True)), @@ -19,6 +28,15 @@ class Migration(migrations.Migration): migrations.CreateModel( name="Article", fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ( "authors", models.ManyToManyField( @@ -37,6 +55,15 @@ class Migration(migrations.Migration): migrations.CreateModel( name="Fan", fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ("name", models.CharField(max_length=50)), ("age", models.PositiveSmallIntegerField(default=30)), ( @@ -51,6 +78,15 @@ class Migration(migrations.Migration): migrations.CreateModel( name="DTModel", fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ("name", models.CharField(max_length=32)), ("start_datetime", models.DateTimeField(null=True, blank=True)), ("end_datetime", models.DateTimeField(null=True, blank=True)), @@ -64,6 +100,15 @@ class Migration(migrations.Migration): migrations.CreateModel( name="DecimalModel", fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ("n1", models.DecimalField(decimal_places=2, max_digits=6)), ( "n2", @@ -76,6 +121,15 @@ class Migration(migrations.Migration): migrations.CreateModel( name="IntegerModel", fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ("big", models.BigIntegerField(null=True, blank=True)), ("normal", models.IntegerField(null=True, blank=True)), ("small", models.SmallIntegerField(null=True, blank=True)), @@ -84,6 +138,15 @@ class Migration(migrations.Migration): migrations.CreateModel( name="FloatModel", fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ("f1", models.FloatField(null=True, blank=True)), ("f2", models.FloatField(null=True, blank=True)), ], @@ -91,6 +154,15 @@ class Migration(migrations.Migration): migrations.CreateModel( name="UUIDModel", fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ("uuid", models.UUIDField(null=True)), ("shift", models.DurationField(null=True)), ], diff --git a/tests/expressions/tests.py b/tests/expressions/tests.py index 981d84e9e8ec..02126fa89612 100644 --- a/tests/expressions/tests.py +++ b/tests/expressions/tests.py @@ -137,6 +137,16 @@ def test_annotate_values_aggregate(self): ) self.assertEqual(companies["result"], 2395) + def test_decimal_division_literal_value(self): + """ + Division with a literal Decimal value preserves precision. + """ + num = Number.objects.create(integer=2) + obj = Number.objects.annotate( + val=F("integer") / Value(Decimal("3.0"), output_field=DecimalField()) + ).get(pk=num.pk) + self.assertAlmostEqual(obj.val, Decimal("0.6667"), places=4) + def test_annotate_values_filter(self): companies = ( Company.objects.annotate( diff --git a/tests/gis_tests/gdal_tests/test_driver.py b/tests/gis_tests/gdal_tests/test_driver.py index 71ff5a5aab35..5c3c0e5c8d88 100644 --- a/tests/gis_tests/gdal_tests/test_driver.py +++ b/tests/gis_tests/gdal_tests/test_driver.py @@ -9,7 +9,7 @@ "MapInfo File", "S57", "DGN", - "Memory" if GDAL_VERSION <= (3, 10) else "MEM", + "Memory" if GDAL_VERSION[:2] <= (3, 10) else "MEM", "CSV", "GML", "KML", diff --git a/tests/gis_tests/rasterapp/migrations/0002_rastermodels.py b/tests/gis_tests/rasterapp/migrations/0002_rastermodels.py index bd2a72ab45ad..c47cd044c82a 100644 --- a/tests/gis_tests/rasterapp/migrations/0002_rastermodels.py +++ b/tests/gis_tests/rasterapp/migrations/0002_rastermodels.py @@ -14,7 +14,7 @@ class Migration(migrations.Migration): fields=[ ( "id", - models.AutoField( + models.BigAutoField( auto_created=True, primary_key=True, serialize=False, @@ -49,7 +49,7 @@ class Migration(migrations.Migration): fields=[ ( "id", - models.AutoField( + models.BigAutoField( auto_created=True, primary_key=True, serialize=False, diff --git a/tests/migration_test_data_persistence/migrations/0002_add_book.py b/tests/migration_test_data_persistence/migrations/0002_add_book.py index c355428f34bc..325ac6668452 100644 --- a/tests/migration_test_data_persistence/migrations/0002_add_book.py +++ b/tests/migration_test_data_persistence/migrations/0002_add_book.py @@ -1,4 +1,4 @@ -from django.db import migrations +from django.db import migrations, models def add_book(apps, schema_editor): @@ -16,4 +16,29 @@ class Migration(migrations.Migration): migrations.RunPython( add_book, ), + migrations.CreateModel( + name="Unmanaged", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=100)), + ], + options={ + "managed": False, + }, + ), + migrations.AlterField( + model_name="book", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), ] diff --git a/tests/postgres_tests/migrations/0002_create_test_models.py b/tests/postgres_tests/migrations/0002_create_test_models.py index d2f9eceb9931..9e1831b5e901 100644 --- a/tests/postgres_tests/migrations/0002_create_test_models.py +++ b/tests/postgres_tests/migrations/0002_create_test_models.py @@ -108,7 +108,7 @@ class Migration(migrations.Migration): ("tags", ArrayField(TagField(), blank=True, null=True)), ( "json", - ArrayField(models.JSONField(default=dict), default=list), + ArrayField(models.JSONField(default=dict), default=list, null=True), ), ("int_ranges", ArrayField(IntegerRangeField(), null=True, blank=True)), ( @@ -179,7 +179,7 @@ class Migration(migrations.Migration): ), ( "field", - ArrayField(models.FloatField(), size=2, null=True, blank=True), + ArrayField(models.FloatField(), size=3), ), ], options={ diff --git a/tests/sites_framework/migrations/0001_initial.py b/tests/sites_framework/migrations/0001_initial.py index c5721ee08e38..dbd491027639 100644 --- a/tests/sites_framework/migrations/0001_initial.py +++ b/tests/sites_framework/migrations/0001_initial.py @@ -1,3 +1,5 @@ +import django.contrib.sites.managers +import django.db.models.manager from django.db import migrations, models @@ -12,11 +14,11 @@ class Migration(migrations.Migration): fields=[ ( "id", - models.AutoField( - verbose_name="ID", - serialize=False, + models.BigAutoField( auto_created=True, primary_key=True, + serialize=False, + verbose_name="ID", ), ), ("title", models.CharField(max_length=50)), @@ -28,6 +30,15 @@ class Migration(migrations.Migration): options={ "abstract": False, }, + managers=[ + ("objects", django.db.models.manager.Manager()), + ( + "on_site", + django.contrib.sites.managers.CurrentSiteManager( + "places_this_article_should_appear" + ), + ), + ], bases=(models.Model,), ), migrations.CreateModel( @@ -35,11 +46,11 @@ class Migration(migrations.Migration): fields=[ ( "id", - models.AutoField( - verbose_name="ID", - serialize=False, + models.BigAutoField( auto_created=True, primary_key=True, + serialize=False, + verbose_name="ID", ), ), ("title", models.CharField(max_length=50)), @@ -48,6 +59,10 @@ class Migration(migrations.Migration): options={ "abstract": False, }, + managers=[ + ("objects", django.db.models.manager.Manager()), + ("on_site", django.contrib.sites.managers.CurrentSiteManager()), + ], bases=(models.Model,), ), migrations.CreateModel( @@ -55,11 +70,11 @@ class Migration(migrations.Migration): fields=[ ( "id", - models.AutoField( - verbose_name="ID", - serialize=False, + models.BigAutoField( auto_created=True, primary_key=True, + serialize=False, + verbose_name="ID", ), ), ("title", models.CharField(max_length=50)), @@ -68,6 +83,10 @@ class Migration(migrations.Migration): options={ "abstract": False, }, + managers=[ + ("objects", django.db.models.manager.Manager()), + ("on_site", django.contrib.sites.managers.CurrentSiteManager()), + ], bases=(models.Model,), ), ]