From f21882a68ab0418f9d2d2b4a39dfcfd9df569df5 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Tue, 4 Nov 2025 09:55:53 +0100 Subject: [PATCH] Fixed #24920 -- Added support for DecimalField with no precision. Thanks Lily for the review. --- django/core/management/commands/inspectdb.py | 24 ++-- django/db/backends/base/features.py | 3 + django/db/backends/oracle/base.py | 8 +- django/db/backends/oracle/features.py | 1 + django/db/backends/oracle/introspection.py | 11 +- django/db/backends/postgresql/base.py | 8 +- django/db/backends/postgresql/features.py | 1 + .../db/backends/postgresql/introspection.py | 6 +- django/db/backends/sqlite3/features.py | 1 + django/db/models/fields/__init__.py | 114 +++++++++++------- docs/ref/checks.txt | 2 + docs/ref/models/fields.txt | 17 ++- docs/releases/6.1.txt | 5 + tests/inspectdb/models.py | 9 ++ tests/inspectdb/tests.py | 16 +-- .../test_ordinary_fields.py | 57 ++++++++- 16 files changed, 205 insertions(+), 78 deletions(-) diff --git a/django/core/management/commands/inspectdb.py b/django/core/management/commands/inspectdb.py index e44edcfe91..71fb6fc133 100644 --- a/django/core/management/commands/inspectdb.py +++ b/django/core/management/commands/inspectdb.py @@ -359,21 +359,15 @@ class Command(BaseCommand): if field_type in {"CharField", "TextField"} and row.collation: field_params["db_collation"] = row.collation - if field_type == "DecimalField": - if row.precision is None or row.scale is None: - field_notes.append( - "max_digits and decimal_places have been guessed, as this " - "database handles decimal fields as float" - ) - field_params["max_digits"] = ( - row.precision if row.precision is not None else 10 - ) - field_params["decimal_places"] = ( - row.scale if row.scale is not None else 5 - ) - else: - field_params["max_digits"] = row.precision - field_params["decimal_places"] = row.scale + if field_type == "DecimalField" and ( + # This can generate DecimalField with only one of max_digits and + # decimal_fields specified. This configuration would be incorrect, + # but nothing more correct that could be generated. + row.precision is not None + or row.scale is not None + ): + field_params["max_digits"] = row.precision + field_params["decimal_places"] = row.scale return field_type, field_params, field_notes diff --git a/django/db/backends/base/features.py b/django/db/backends/base/features.py index 2ada5177be..ecc283ff6b 100644 --- a/django/db/backends/base/features.py +++ b/django/db/backends/base/features.py @@ -383,6 +383,9 @@ class BaseDatabaseFeatures: # Does the backend support unlimited character columns? supports_unlimited_charfield = False + # Does the backend support numeric columns with no precision? + supports_no_precision_decimalfield = False + # Does the backend support native tuple lookups (=, >, <, IN)? supports_tuple_lookups = True diff --git a/django/db/backends/oracle/base.py b/django/db/backends/oracle/base.py index 48fa01ebee..9114777030 100644 --- a/django/db/backends/oracle/base.py +++ b/django/db/backends/oracle/base.py @@ -106,6 +106,12 @@ class _UninitializedOperatorsDescriptor: return instance.__dict__["operators"] +def _get_decimal_column(data): + if data["max_digits"] is None and data["decimal_places"] is None: + return "NUMBER" + return "NUMBER(%(max_digits)s, %(decimal_places)s)" % data + + class DatabaseWrapper(BaseDatabaseWrapper): vendor = "oracle" display_name = "Oracle" @@ -125,7 +131,7 @@ class DatabaseWrapper(BaseDatabaseWrapper): "CharField": "NVARCHAR2(%(max_length)s)", "DateField": "DATE", "DateTimeField": "TIMESTAMP", - "DecimalField": "NUMBER(%(max_digits)s, %(decimal_places)s)", + "DecimalField": _get_decimal_column, "DurationField": "INTERVAL DAY(9) TO SECOND(6)", "FileField": "NVARCHAR2(%(max_length)s)", "FilePathField": "NVARCHAR2(%(max_length)s)", diff --git a/django/db/backends/oracle/features.py b/django/db/backends/oracle/features.py index c07d9f1ed0..dd356181e3 100644 --- a/django/db/backends/oracle/features.py +++ b/django/db/backends/oracle/features.py @@ -79,6 +79,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): supports_json_negative_indexing = False supports_collation_on_textfield = False supports_on_delete_db_default = False + supports_no_precision_decimalfield = True test_now_utc_template = "CURRENT_TIMESTAMP AT TIME ZONE 'UTC'" django_test_expected_failures = { # A bug in Django/oracledb with respect to string handling (#23843). diff --git a/django/db/backends/oracle/introspection.py b/django/db/backends/oracle/introspection.py index 6a0947f8ab..07b7cad840 100644 --- a/django/db/backends/oracle/introspection.py +++ b/django/db/backends/oracle/introspection.py @@ -194,14 +194,21 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): comment, ) = field_map[name] name %= {} # oracledb, for some reason, doubles percent signs. + if desc[1] == oracledb.NUMBER and desc[5] == -127 and desc[4] == 0: + # DecimalField with no precision. + precision = None + scale = None + else: + precision = desc[4] or 0 + scale = desc[5] or 0 description.append( FieldInfo( self.identifier_converter(name), desc[1], display_size, desc[3], - desc[4] or 0, - desc[5] or 0, + precision, + scale, *desc[6:], default, collation, diff --git a/django/db/backends/postgresql/base.py b/django/db/backends/postgresql/base.py index e2728afea5..42b37ab3c2 100644 --- a/django/db/backends/postgresql/base.py +++ b/django/db/backends/postgresql/base.py @@ -89,6 +89,12 @@ def _get_varchar_column(data): return "varchar(%(max_length)s)" % data +def _get_decimal_column(data): + if data["max_digits"] is None and data["decimal_places"] is None: + return "numeric" + return "numeric(%(max_digits)s, %(decimal_places)s)" % data + + class DatabaseWrapper(BaseDatabaseWrapper): vendor = "postgresql" display_name = "PostgreSQL" @@ -105,7 +111,7 @@ class DatabaseWrapper(BaseDatabaseWrapper): "CharField": _get_varchar_column, "DateField": "date", "DateTimeField": "timestamp with time zone", - "DecimalField": "numeric(%(max_digits)s, %(decimal_places)s)", + "DecimalField": _get_decimal_column, "DurationField": "interval", "FileField": "varchar(%(max_length)s)", "FilePathField": "varchar(%(max_length)s)", diff --git a/django/db/backends/postgresql/features.py b/django/db/backends/postgresql/features.py index 5bbf4b86cb..5e4ed320be 100644 --- a/django/db/backends/postgresql/features.py +++ b/django/db/backends/postgresql/features.py @@ -68,6 +68,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): supports_covering_indexes = True supports_stored_generated_columns = True supports_nulls_distinct_unique_constraints = True + supports_no_precision_decimalfield = True can_rename_index = True test_collations = { "deterministic": "C", diff --git a/django/db/backends/postgresql/introspection.py b/django/db/backends/postgresql/introspection.py index 791d729ea4..69dd776aa3 100644 --- a/django/db/backends/postgresql/introspection.py +++ b/django/db/backends/postgresql/introspection.py @@ -137,8 +137,10 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): # display_size is always None on psycopg2. line.internal_size if line.display_size is None else line.display_size, line.internal_size, - line.precision, - line.scale, + # precision and scale are always 2^16 - 1 on psycopg2 for + # DecimalFields with no precision. + None if line.precision == 2**16 - 1 else line.precision, + None if line.scale == 2**16 - 1 else line.scale, *field_map[line.name], ) for line in cursor.description diff --git a/django/db/backends/sqlite3/features.py b/django/db/backends/sqlite3/features.py index 4fa6ab831b..09a0b7fc22 100644 --- a/django/db/backends/sqlite3/features.py +++ b/django/db/backends/sqlite3/features.py @@ -55,6 +55,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): insert_test_table_with_defaults = 'INSERT INTO {} ("null") VALUES (1)' supports_default_keyword_in_insert = False supports_unlimited_charfield = True + supports_no_precision_decimalfield = True can_return_columns_from_insert = True can_return_rows_from_bulk_insert = True can_return_rows_from_update = True diff --git a/django/db/models/fields/__init__.py b/django/db/models/fields/__init__.py index 3e2258e064..ef2e04fa1f 100644 --- a/django/db/models/fields/__init__.py +++ b/django/db/models/fields/__init__.py @@ -1725,54 +1725,84 @@ class DecimalField(Field): return errors def _check_decimal_places(self): - try: - decimal_places = int(self.decimal_places) - if decimal_places < 0: - raise ValueError() - except TypeError: - return [ - checks.Error( - "DecimalFields must define a 'decimal_places' attribute.", - obj=self, - id="fields.E130", - ) - ] - except ValueError: - return [ - checks.Error( - "'decimal_places' must be a non-negative integer.", - obj=self, - id="fields.E131", - ) - ] + if self.decimal_places is None: + if not ( + connection.features.supports_no_precision_decimalfield + or "supports_no_precision_decimalfield" + in self.model._meta.required_db_features + ): + return [ + checks.Error( + "DecimalFields must define a 'decimal_places' attribute.", + obj=self, + id="fields.E130", + ) + ] + elif self.max_digits is not None: + return [ + checks.Error( + "DecimalField’s max_digits and decimal_places must both " + "be defined or both omitted.", + obj=self, + id="fields.E135", + ), + ] else: - return [] + try: + decimal_places = int(self.decimal_places) + if decimal_places < 0: + raise ValueError() + except ValueError: + return [ + checks.Error( + "'decimal_places' must be a non-negative integer.", + obj=self, + id="fields.E131", + ) + ] + return [] def _check_max_digits(self): - try: - max_digits = int(self.max_digits) - if max_digits <= 0: - raise ValueError() - except TypeError: - return [ - checks.Error( - "DecimalFields must define a 'max_digits' attribute.", - obj=self, - id="fields.E132", - ) - ] - except ValueError: - return [ - checks.Error( - "'max_digits' must be a positive integer.", - obj=self, - id="fields.E133", - ) - ] + if self.max_digits is None: + if not ( + connection.features.supports_no_precision_decimalfield + or "supports_no_precision_decimalfield" + in self.model._meta.required_db_features + ): + return [ + checks.Error( + "DecimalFields must define a 'max_digits' attribute.", + obj=self, + id="fields.E132", + ) + ] + elif self.decimal_places is not None: + return [ + checks.Error( + "DecimalField’s max_digits and decimal_places must both " + "be defined or both omitted.", + obj=self, + id="fields.E135", + ), + ] else: - return [] + try: + max_digits = int(self.max_digits) + if max_digits <= 0: + raise ValueError() + except ValueError: + return [ + checks.Error( + "'max_digits' must be a positive integer.", + obj=self, + id="fields.E133", + ) + ] + return [] def _check_decimal_places_and_max_digits(self, **kwargs): + if self.decimal_places is None or self.max_digits is None: + return [] if int(self.decimal_places) > int(self.max_digits): return [ checks.Error( diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt index c297938f45..1a79023b89 100644 --- a/docs/ref/checks.txt +++ b/docs/ref/checks.txt @@ -196,6 +196,8 @@ Model fields * **fields.E133**: ``max_digits`` must be a positive integer. * **fields.E134**: ``max_digits`` must be greater or equal to ``decimal_places``. +* **fields.E135**: ``DecimalField``’s ``max_digits`` and ``decimal_places`` + must both be defined or both omitted. * **fields.E140**: ``FilePathField``\s must have either ``allow_files`` or ``allow_folders`` set to True. * **fields.E150**: ``GenericIPAddressField``\s cannot have ``blank=True`` if diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index 649073b708..9aacbd2922 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -862,16 +862,22 @@ A fixed-precision decimal number, represented in Python by a :class:`~decimal.Decimal` instance. It validates the input using :class:`~django.core.validators.DecimalValidator`. -Has the following **required** arguments: +Has the following arguments: .. attribute:: DecimalField.max_digits The maximum number of digits allowed in the number. Note that this number - must be greater than or equal to ``decimal_places``. + must be greater than or equal to ``decimal_places``. It's always required + on MySQL because this database doesn't support numeric fields with no + precision. It's also required for all database backends when + :attr:`~DecimalField.decimal_places` is provided. .. attribute:: DecimalField.decimal_places - The number of decimal places to store with the number. + The number of decimal places to store with the number. It's always required + on MySQL because this database doesn't support numeric fields with no + precision. It's also required for all database backends when + :attr:`~DecimalField.max_digits` is provided. For example, to store numbers up to ``999.99`` with a resolution of 2 decimal places, you'd use:: @@ -895,6 +901,11 @@ when :attr:`~django.forms.Field.localize` is ``False`` or should also be aware of :ref:`SQLite limitations ` of decimal fields. +.. versionchanged:: 6.1 + + Support for ``DecimalField`` with no precision was added on Oracle, + PostgreSQL, and SQLite. + ``DurationField`` ----------------- diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index 8c5594d5b6..425419529b 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -247,6 +247,11 @@ Models top-level or nested JSON ``null`` values. See :ref:`storing-and-querying-for-none` for usage examples and some caveats. +* :attr:`DecimalField.max_digits ` + and :attr:`DecimalField.decimal_places + ` are no longer required to be + set on Oracle, PostgreSQL, and SQLite. + Pagination ~~~~~~~~~~ diff --git a/tests/inspectdb/models.py b/tests/inspectdb/models.py index 3d6388a7be..fbe1df8a95 100644 --- a/tests/inspectdb/models.py +++ b/tests/inspectdb/models.py @@ -121,6 +121,15 @@ class CharFieldUnlimited(models.Model): required_db_features = {"supports_unlimited_charfield"} +class DecimalFieldNoPrec(models.Model): + decimal_field_no_precision = models.DecimalField( + max_digits=None, decimal_places=None + ) + + class Meta: + required_db_features = {"supports_no_precision_decimalfield"} + + class UniqueTogether(models.Model): field1 = models.IntegerField() field2 = models.CharField(max_length=10) diff --git a/tests/inspectdb/tests.py b/tests/inspectdb/tests.py index 9104671b4f..c16258b0eb 100644 --- a/tests/inspectdb/tests.py +++ b/tests/inspectdb/tests.py @@ -202,6 +202,13 @@ class InspectDBTestCase(TestCase): output = out.getvalue() self.assertIn("char_field = models.CharField()", output) + @skipUnlessDBFeature("supports_no_precision_decimalfield") + def test_decimal_field_no_precision(self): + out = StringIO() + call_command("inspectdb", "inspectdb_decimalfieldnoprec", stdout=out) + output = out.getvalue() + self.assertIn("decimal_field_no_precision = models.DecimalField()", output) + def test_number_field_types(self): """Test introspection of various Django field types""" assertFieldType = self.make_field_type_asserter() @@ -228,13 +235,8 @@ class InspectDBTestCase(TestCase): assertFieldType( "decimal_field", "models.DecimalField(max_digits=6, decimal_places=1)" ) - else: # Guessed arguments on SQLite, see #5014 - assertFieldType( - "decimal_field", - "models.DecimalField(max_digits=10, decimal_places=5) " - "# max_digits and decimal_places have been guessed, " - "as this database handles decimal fields as float", - ) + else: + assertFieldType("decimal_field", "models.DecimalField()") assertFieldType("float_field", "models.FloatField()") assertFieldType( diff --git a/tests/invalid_models_tests/test_ordinary_fields.py b/tests/invalid_models_tests/test_ordinary_fields.py index 1fcf3f708d..2c2653a538 100644 --- a/tests/invalid_models_tests/test_ordinary_fields.py +++ b/tests/invalid_models_tests/test_ordinary_fields.py @@ -599,15 +599,16 @@ class DateTimeFieldTests(SimpleTestCase): @isolate_apps("invalid_models_tests") -class DecimalFieldTests(SimpleTestCase): - def test_required_attributes(self): +class DecimalFieldTests(TestCase): + def test_both_attributes_omitted(self): class Model(models.Model): field = models.DecimalField() field = Model._meta.get_field("field") - self.assertEqual( - field.check(), - [ + if connection.features.supports_no_precision_decimalfield: + expected = [] + else: + expected = [ Error( "DecimalFields must define a 'decimal_places' attribute.", obj=field, @@ -618,6 +619,52 @@ class DecimalFieldTests(SimpleTestCase): obj=field, id="fields.E132", ), + ] + self.assertEqual(field.check(), expected) + + def test_both_attributes_omitted_required_db_features(self): + class Model(models.Model): + field = models.DecimalField() + + class Meta: + required_db_features = {"supports_no_precision_decimalfield"} + + field = Model._meta.get_field("field") + self.assertEqual(field.check(databases=self.databases), []) + + @skipUnlessDBFeature("supports_no_precision_decimalfield") + def test_only_max_digits_defined(self): + class Model(models.Model): + field = models.DecimalField(max_digits=13) + + field = Model._meta.get_field("field") + self.assertEqual( + field.check(), + [ + Error( + "DecimalField’s max_digits and decimal_places must both " + "be defined or both omitted.", + obj=field, + id="fields.E135", + ), + ], + ) + + @skipUnlessDBFeature("supports_no_precision_decimalfield") + def test_only_decimal_places_defined(self): + class Model(models.Model): + field = models.DecimalField(decimal_places=5) + + field = Model._meta.get_field("field") + self.assertEqual( + field.check(), + [ + Error( + "DecimalField’s max_digits and decimal_places must both " + "be defined or both omitted.", + obj=field, + id="fields.E135", + ), ], )