Opened 9 months ago

Closed 9 months ago

#22921 closed Cleanup/optimization (invalid)

Model.clean_fields incorrectly skips validation for fields where null value is not allowed

Reported by: silveroff@… Owned by: nobody
Component: Database layer (models, ORM) Version: master
Severity: Normal Keywords: field validation, model
Cc: Triage Stage: Unreviewed
Has patch: no Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description

I'm curious why does this method skips validation for a field with None value. As a side effect if field has null=False, skipping validation will result in DatabaseError saying that coulmn cannot be null. Why don't we check if None is a valid value for a field, taking into account current status of null option? This would save developers from nasty bugs and boilerplate in their model forms.

def clean_fields(self, exclude=None):
        """
        Cleans all fields and raises a ValidationError containing message_dict
        of all validation errors if any occur.
        """
        if exclude is None:
            exclude = []

        errors = {}
        for f in self._meta.fields:
            if f.name in exclude:
                continue
            # Skip validation for empty fields with blank=True. The developer
            # is responsible for making sure they have a valid value.
            raw_value = getattr(self, f.attname)
            if f.blank and raw_value in f.empty_values:
                continue
            try:
                setattr(self, f.attname, f.clean(raw_value, self))
            except ValidationError as e:
                errors[f.name] = e.error_list

        if errors:
            raise ValidationError(errors)

Change History (8)

comment:1 Changed 9 months ago by timo

  • Needs documentation unset
  • Needs tests unset
  • Patch needs improvement unset

Could you give a concrete example of a form that exhibits the unexpected behavior?

comment:2 Changed 9 months ago by anonymous

Sure,

# Model
class Example(models.Model):
    optional_decimal = models.DecimalField(
        blank=True,
        null=False,   # so I want this field to be optional in my forms, but I dont want null values in db
        default=Decimal(0),
        validators=[MinValueValidator(0)],
        max_digits=8,
        decimal_places=2,
    )

# Form
class ExampleForm(ModelForm):
    class Meta:
        model = Example

Now if one create object using this form without providing value for optional_decimal field this would result in IntegrityError: (1048, "Column 'optional_decimal' cannot be null")
So developer has to create ExampleForm.clean_optional_decimal method to handle this issue, and thats not DRY and wouldn't be needed if model.clean_fields run validation at the first place. Something like this
would suffice:

def clean_fields(self, exclude=None):
        """
        Cleans all fields and raises a ValidationError containing message_dict
        of all validation errors if any occur.
        """
        if exclude is None:
            exclude = []

        errors = {}
        for f in self._meta.fields:
            if f.name in exclude:
                continue
            # Skip validation for empty fields with blank=True. The developer
            # is responsible for making sure they have a valid value.
            raw_value = getattr(self, f.attname)
            if f.blank and raw_value in f.empty_values:
                # do not skip validation if raw_value is None and field does not accept null values to be saved!
                if raw_value is None and not f.null:
                    pass
                else:
                    continue
            try:
                setattr(self, f.attname, f.clean(raw_value, self))
            except ValidationError as e:
                errors[f.name] = e.error_list

        if errors:
            raise ValidationError(errors)

comment:3 Changed 9 months ago by timo

What does validating the field do?

comment:4 Changed 9 months ago by silveroff@…

@timo,

Currently

In [1]: obj = Example()

In [2]: obj.optional_decimal = None

In [3]: obj.full_clean()  # no ValidationError, so it looks like model is valid which is not true :(

In [4]: obj.save()
---------------------------------------------------------------------------
...
Warning: Column 'optional_decimal' cannot be null

With my fix proposal for django.db.models.base.Model#clean_fields:

In [1]: obj = Example()

In [2]: obj.optional_decimal = None

In [3]: obj.full_clean()
---------------------------------------------------------------------------
...
ValidationError: {'optional_decimal': [u'This field cannot be null.']}

Latter looks like proper behaviour, don't you think?

comment:5 Changed 9 months ago by timo

Did you try to run Django's test suite with your proposed change? I suspect there will be failures. If you want the field to be optional, you need to set a value before it's saved as noted in the comment: "Skip validation for empty fields with blank=True. The developer is responsible for making sure they have a valid value."

comment:6 Changed 9 months ago by pysilver

Yes, and there are just a few simple tests fails.

I've seen that "Skip validation for empty" many times, and yet many times I've seen bugs caused by this behaviour... This is just does not feel right – docs says "use model.full_clean() to make sure model is valid" and turns out thats not true.

======================================================================
FAIL: test_abstract_inherited_unique (model_forms.tests.UniqueTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/Silver/Projects/Interesting Repos/django-repo/tests/model_forms/tests.py", line 721, in test_abstract_inherited_unique
    self.assertEqual(len(form.errors), 1)
AssertionError: 3 != 1

======================================================================
FAIL: test_inherited_unique (model_forms.tests.UniqueTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/Silver/Projects/Interesting Repos/django-repo/tests/model_forms/tests.py", line 702, in test_inherited_unique
    self.assertEqual(len(form.errors), 1)
AssertionError: 3 != 1

======================================================================
FAIL: test_inherited_unique_together (model_forms.tests.UniqueTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/Silver/Projects/Interesting Repos/django-repo/tests/model_forms/tests.py", line 712, in test_inherited_unique_together
    self.assertEqual(len(form.errors), 1)
AssertionError: 3 != 1

======================================================================
FAIL: test_validation_with_empty_blank_field (validation.tests.ModelFormsTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/Silver/Projects/Interesting Repos/django-repo/tests/validation/tests.py", line 104, in test_validation_with_empty_blank_field
    self.assertEqual(list(form.errors), [])
AssertionError: Lists differ: ['pub_date'] != []

First list contains 1 additional elements.
First extra element 0:
pub_date

- ['pub_date']
+ []

----------------------------------------------------------------------
Ran 7142 tests in 321.586s

FAILED (failures=4, skipped=571, expected failures=8)

comment:7 Changed 9 months ago by timo

It is valid based on blank=True though. A use case for blank=True, null=False would be a field that gets populated in save(), for example. If we changed the behavior, it would be backwards incompatible and wouldn't allow this use case anymore. If you want the field required for model validation but not for form validation, then you should drop blank=True on the model and customize the form.

comment:8 Changed 9 months ago by timo

  • Resolution set to invalid
  • Status changed from new to closed

Please reopen if you believe my analysis is incorrect.

Note: See TracTickets for help on using tickets.
Back to Top