From 83622b824b7014977dfc7086bbc2628ea53f4cd0 Mon Sep 17 00:00:00 2001 From: Markus Holtermann Date: Fri, 23 Jan 2026 14:45:33 +0100 Subject: [PATCH 1/3] Fixed #36878 -- Unified data type for *_together options in ModelState. Ever since the beginning of Django's migration framework, there's been a bit of an inconsistency on how index_together and unique_together values have been stored on the ModelState[^1]. It's only really obvious, when looking at the current code for `from_model()`[^2] and the `rename_field()` state alteration code[^3]. The problem in the autodetector's detection of the `*_together` options as raised in the ticket, reinforces the inconsistency[^4]: the old value is being normalized to a set of tuples, whereas the new value is taken as-is. Why this hasn't been caught before, is likely to the fact, that we never really look at a `to_state` that comes from migration operations in the autodetector. Instead, in both usages in Django[^5], [^6] the `to_state` is a `ProjectState.from_apps()`. And that state is consistently using sets of tuples and not lists of lists. [^1]: https://github.com/django/django/commit/67dcea711e92025d0e8676b869b7ef15dbc6db73#diff-5dd147e9e978e645313dd99eab3a7bab1f1cb0a53e256843adb68aeed71e61dcR85-R87 [^2]: https://github.com/django/django/blob/b1ffa9a9d78b0c2c5ad6ed5a1d84e380d5cfd010/django/db/migrations/state.py#L842 [^3]: https://github.com/django/django/blob/b1ffa9a9d78b0c2c5ad6ed5a1d84e380d5cfd010/django/db/migrations/state.py#L340-L345 [^4]: https://github.com/django/django/blob/b1ffa9a9d78b0c2c5ad6ed5a1d84e380d5cfd010/django/db/migrations/autodetector.py#L1757-L1771 [^5]: https://github.com/django/django/blob/2351c1b12cc9cf82d642f769c774bc3ea0cc4006/django/core/management/commands/makemigrations.py#L215-L219 [^6]: https://github.com/django/django/blob/2351c1b12cc9cf82d642f769c774bc3ea0cc4006/django/core/management/commands/migrate.py#L329-L332 --- django/db/migrations/state.py | 13 ++++---- tests/migrations/test_autodetector.py | 45 +++++++++++++++++++++++++++ tests/migrations/test_base.py | 7 ++--- tests/migrations/test_operations.py | 21 ++++++++----- 4 files changed, 69 insertions(+), 17 deletions(-) diff --git a/django/db/migrations/state.py b/django/db/migrations/state.py index 802aeb0b5e66..9e9cc58fae13 100644 --- a/django/db/migrations/state.py +++ b/django/db/migrations/state.py @@ -192,9 +192,10 @@ def alter_model_options(self, app_label, model_name, options, option_keys=None): def remove_model_options(self, app_label, model_name, option_name, value_to_remove): model_state = self.models[app_label, model_name] if objs := model_state.options.get(option_name): - model_state.options[option_name] = [ - obj for obj in objs if tuple(obj) != tuple(value_to_remove) - ] + new_value = [obj for obj in objs if tuple(obj) != tuple(value_to_remove)] + if option_name in {"index_together", "unique_together"}: + new_value = set(normalize_together(new_value)) + model_state.options[option_name] = new_value self.reload_model(app_label, model_name, delay=True) def alter_model_managers(self, app_label, model_name, managers): @@ -339,10 +340,10 @@ def rename_field(self, app_label, model_name, old_name, new_name): options = model_state.options for option in ("index_together", "unique_together"): if option in options: - options[option] = [ - [new_name if n == old_name else n for n in together] + options[option] = { + tuple(new_name if n == old_name else n for n in together) for together in options[option] - ] + } # Fix to_fields to refer to the new field. delay = True references = get_references(self, model_key, (old_name, found)) diff --git a/tests/migrations/test_autodetector.py b/tests/migrations/test_autodetector.py index e33362185555..7a66e500cb89 100644 --- a/tests/migrations/test_autodetector.py +++ b/tests/migrations/test_autodetector.py @@ -5590,6 +5590,51 @@ def test_remove_composite_pk(self): preserve_default=True, ) + def test_does_not_crash_after_rename_on_unique_together(self): + fields = ("first", "second") + before = self.make_project_state( + [ + ModelState( + "app", + "Foo", + [ + ("id", models.AutoField(primary_key=True)), + ("first", models.IntegerField()), + ("second", models.IntegerField()), + ], + options={"unique_together": {fields}}, + ), + ] + ) + after = before.clone() + after.rename_field("app", "foo", "first", "first_renamed") + + changes = MigrationAutodetector( + before, after, MigrationQuestioner({"ask_rename": True}) + )._detect_changes() + + self.assertNumberMigrations(changes, "app", 1) + self.assertOperationTypes( + changes, "app", 0, ["RenameField", "AlterUniqueTogether"] + ) + self.assertOperationAttributes( + changes, + "app", + 0, + 0, + model_name="foo", + old_name="first", + new_name="first_renamed", + ) + self.assertOperationAttributes( + changes, + "app", + 0, + 1, + name="foo", + unique_together={("first_renamed", "second")}, + ) + class MigrationSuggestNameTests(SimpleTestCase): def test_no_operations(self): diff --git a/tests/migrations/test_base.py b/tests/migrations/test_base.py index 24ae59ca1b9a..31967f9ed49c 100644 --- a/tests/migrations/test_base.py +++ b/tests/migrations/test_base.py @@ -290,14 +290,13 @@ def set_up_test_model( ): """Creates a test model state and database table.""" # Make the "current" state. - model_options = { - "swappable": "TEST_SWAP_MODEL", - "unique_together": [["pink", "weight"]] if unique_together else [], - } + model_options = {"swappable": "TEST_SWAP_MODEL"} if options: model_options["permissions"] = [("can_groom", "Can groom")] if db_table: model_options["db_table"] = db_table + if unique_together: + model_options["unique_together"] = {("pink", "weight")} operations = [ migrations.CreateModel( "Pony", diff --git a/tests/migrations/test_operations.py b/tests/migrations/test_operations.py index 00c76538e0fb..e859b62a101b 100644 --- a/tests/migrations/test_operations.py +++ b/tests/migrations/test_operations.py @@ -3336,11 +3336,11 @@ def test_rename_field_unique_together(self): # unique_together has the renamed column. self.assertIn( "blue", - new_state.models["test_rnflut", "pony"].options["unique_together"][0], + list(new_state.models["test_rnflut", "pony"].options["unique_together"])[0], ) self.assertNotIn( "pink", - new_state.models["test_rnflut", "pony"].options["unique_together"][0], + list(new_state.models["test_rnflut", "pony"].options["unique_together"])[0], ) # Rename field. self.assertColumnExists("test_rnflut_pony", "pink") @@ -3377,7 +3377,7 @@ def test_rename_field_index_together(self): ("weight", models.FloatField()), ], options={ - "index_together": [("weight", "pink")], + "index_together": {("weight", "pink")}, }, ), ] @@ -3390,10 +3390,12 @@ def test_rename_field_index_together(self): self.assertNotIn("pink", new_state.models["test_rnflit", "pony"].fields) # index_together has the renamed column. self.assertIn( - "blue", new_state.models["test_rnflit", "pony"].options["index_together"][0] + "blue", + list(new_state.models["test_rnflit", "pony"].options["index_together"])[0], ) self.assertNotIn( - "pink", new_state.models["test_rnflit", "pony"].options["index_together"][0] + "pink", + list(new_state.models["test_rnflit", "pony"].options["index_together"])[0], ) # Rename field. @@ -3952,7 +3954,7 @@ def test_rename_index_unnamed_index(self): ("weight", models.FloatField()), ], options={ - "index_together": [("weight", "pink")], + "index_together": {("weight", "pink")}, }, ), ] @@ -3972,6 +3974,11 @@ def test_rename_index_unnamed_index(self): ) new_state = project_state.clone() operation.state_forwards(app_label, new_state) + # Ensure the model state has the correct type for the index_together + # option. + self.assertIsInstance( + new_state.models[app_label, "pony"].options["index_together"], set + ) # Rename index. with connection.schema_editor() as editor: operation.database_forwards(app_label, editor, project_state, new_state) @@ -4079,7 +4086,7 @@ def test_rename_index_state_forwards_unnamed_index(self): ("weight", models.FloatField()), ], options={ - "index_together": [("weight", "pink")], + "index_together": {("weight", "pink")}, }, ), ] From e61a54d306ec069826b53bfec0b56d9ab3199257 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Wed, 28 Jan 2026 16:51:29 -0500 Subject: [PATCH 2/3] Doc'd the minimum version of bash for releasing Django. --- docs/internals/howto-release-django.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/internals/howto-release-django.txt b/docs/internals/howto-release-django.txt index 7d3d6ded2648..78347b864ba2 100644 --- a/docs/internals/howto-release-django.txt +++ b/docs/internals/howto-release-django.txt @@ -63,7 +63,7 @@ permissions. * A Unix environment with these tools installed (in alphabetical order): - * bash + * bash (version 4.0+) * git * GPG * make From 2831eaed797627e6e6410b06f74dadeb63316e09 Mon Sep 17 00:00:00 2001 From: Samriddha9619 Date: Tue, 20 Jan 2026 01:38:34 +0530 Subject: [PATCH 3/3] Fixed #36233 -- Avoided quantizing integers stored in DecimalField on SQLite. Co-authored-by: Simon Charette Co-authored-by: Jacob Walls --- django/db/backends/sqlite3/operations.py | 11 ++++++++--- tests/model_fields/models.py | 1 + tests/model_fields/test_decimalfield.py | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/django/db/backends/sqlite3/operations.py b/django/db/backends/sqlite3/operations.py index 23c17054d260..18ff204ae37b 100644 --- a/django/db/backends/sqlite3/operations.py +++ b/django/db/backends/sqlite3/operations.py @@ -300,10 +300,15 @@ def convert_timefield_value(self, value, expression, connection): value = parse_time(value) return value + @staticmethod + def _create_decimal(value): + if isinstance(value, (int, str)): + return decimal.Decimal(value) + return decimal.Context(prec=15).create_decimal_from_float(value) + def get_decimalfield_converter(self, expression): # SQLite stores only 15 significant digits. Digits coming from # float inaccuracy must be removed. - create_decimal = decimal.Context(prec=15).create_decimal_from_float if isinstance(expression, Col): quantize_value = decimal.Decimal(1).scaleb( -expression.output_field.decimal_places @@ -311,7 +316,7 @@ def get_decimalfield_converter(self, expression): def converter(value, expression, connection): if value is not None: - return create_decimal(value).quantize( + return self._create_decimal(value).quantize( quantize_value, context=expression.output_field.context ) @@ -319,7 +324,7 @@ def converter(value, expression, connection): def converter(value, expression, connection): if value is not None: - return create_decimal(value) + return self._create_decimal(value) return converter diff --git a/tests/model_fields/models.py b/tests/model_fields/models.py index cdf3c00080c9..1d8a447dee8b 100644 --- a/tests/model_fields/models.py +++ b/tests/model_fields/models.py @@ -102,6 +102,7 @@ def get_choices(): class BigD(models.Model): d = models.DecimalField(max_digits=32, decimal_places=30) + large_int = models.DecimalField(max_digits=16, decimal_places=0, null=True) class FloatModel(models.Model): diff --git a/tests/model_fields/test_decimalfield.py b/tests/model_fields/test_decimalfield.py index 17f59674e872..bab9a39c19d7 100644 --- a/tests/model_fields/test_decimalfield.py +++ b/tests/model_fields/test_decimalfield.py @@ -5,6 +5,7 @@ from django.core import validators from django.core.exceptions import ValidationError from django.db import connection, models +from django.db.models import Max from django.test import TestCase from .models import BigD, Foo @@ -140,3 +141,20 @@ def test_roundtrip_with_trailing_zeros(self): obj = Foo.objects.create(a="bar", d=Decimal("8.320")) obj.refresh_from_db() self.assertEqual(obj.d.compare_total(Decimal("8.320")), Decimal("0")) + + def test_large_integer_precision(self): + large_int_val = Decimal("9999999999999999") + obj = BigD.objects.create(large_int=large_int_val, d=Decimal("0")) + obj.refresh_from_db() + self.assertEqual(obj.large_int, large_int_val) + + def test_large_integer_precision_aggregation(self): + large_int_val = Decimal("9999999999999999") + BigD.objects.create(large_int=large_int_val, d=Decimal("0")) + result = BigD.objects.aggregate(max_val=Max("large_int")) + self.assertEqual(result["max_val"], large_int_val) + + def test_roundtrip_integer_with_trailing_zeros(self): + obj = Foo.objects.create(a="bar", d=Decimal("8")) + obj.refresh_from_db() + self.assertEqual(obj.d.compare_total(Decimal("8.000")), Decimal("0"))