Fixed #24920 -- Added support for DecimalField with no precision.

Thanks Lily for the review.
This commit is contained in:
Mariusz Felisiak 2025-11-04 09:55:53 +01:00
parent 5c60763561
commit f21882a68a
16 changed files with 205 additions and 78 deletions

View file

@ -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

View file

@ -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

View file

@ -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)",

View file

@ -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).

View file

@ -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,

View file

@ -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)",

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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(
"DecimalFields 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(
"DecimalFields 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(

View file

@ -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

View file

@ -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 <sqlite-decimal-handling>`
of decimal fields.
.. versionchanged:: 6.1
Support for ``DecimalField`` with no precision was added on Oracle,
PostgreSQL, and SQLite.
``DurationField``
-----------------

View file

@ -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 <django.db.models.DecimalField.max_digits>`
and :attr:`DecimalField.decimal_places
<django.db.models.DecimalField.decimal_places>` are no longer required to be
set on Oracle, PostgreSQL, and SQLite.
Pagination
~~~~~~~~~~

View file

@ -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)

View file

@ -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(

View file

@ -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(
"DecimalFields 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(
"DecimalFields max_digits and decimal_places must both "
"be defined or both omitted.",
obj=field,
id="fields.E135",
),
],
)