ListField.run_child_validation does not skip empty child fields
#10051
Replies: 2 comments 1 reply
|
There are two semantics to separate here: skipping an absent serializer field, and removing a supplied list element. Your example reaches For application code, I would make element omission explicit in a custom field or normalize your internal sentinel values before handing the list to DRF. If you implement a For an upstream change, useful regression cases beyond the one above would be:
In particular, filtering inside |
|
I ran this against DRF master (3.18.1, commit The case your diff doesn't cover is
|
| case | result with the patch |
|---|---|
[empty] with allow_empty=True |
[] |
outer allow_empty=False, inner ListField all skipped |
[[]], outer check passed because the incoming list had one element |
min_length=2, ["a", SkipField] |
min_length error |
min_length and max_length are registered as field validators in ListField.__init__ and run against the input length in Field.run_validation, so they see the pre-filter length while allow_empty sees nothing. Whichever way "all children skipped" is meant to resolve, the check belongs at the end of run_child_validation, after the loop:
if not self.allow_empty and not result:
self.fail('empty')with a DictField counterpart. I'd rather that decision be explicit in a test than be implied by allow_empty's current name.
On the in-tree producers of empty
Worth knowing before deciding whether this is reachable without custom code. SkipField from Field.run_validation only happens when the field itself is absent from the input: data is empty, or partial=True on the root. Neither applies to a list item. I checked each path:
nullisNone, which goes through theallow_nullbranch and never reachesget_default().ListField(child=CharField(required=False, allow_null=True)).run_validation(["hello", None])returns['hello', None].- A required child raises
ValidationError({1: 'This field is required.'}), notSkipField, so the patch doesn't weaken required-child validation. - A child with an explicit
defaultreturns it:ListField(child=CharField(default='D')).run_validation(['hello', empty])is['hello', 'D']either way. - A
read_onlychild already disappears silently, viavalidate_empty_valuesreturningget_default(); with the patchListField(child=CharField(read_only=True)).run_validation(['hello'])returns[]instead of raising. Nobody does that on purpose, but it shows the loop is already dropping results. - HTML form input yields
''for a blank key, notempty:QueryDict('x=a&x=').getlist('x')is['a', '']. partial=Trueon the root:ListField.get_valuereturnsemptyfor the whole list, andvalidate_empty_valuesshort-circuits before the loop.- A nested
Serializeraschild=doesn't produce it either.Child(partial=True).run_validation({})returns{}, notSkipField.
So with stock fields there is no in-tree way to get empty into a ListField item. It's your custom child raising SkipField, or a custom get_value returning empty per item. That doesn't argue against the fix, since Serializer.to_internal_value sets the precedent, but it does mean this is currently undefined behaviour rather than a bug a client can hit, which is why I'd lean towards adding the regression cases in the PR rather than treating it as urgent.
Regression coverage
class Skipping(serializers.CharField):
def run_validation(self, data=empty):
if data == "skipme":
raise SkipField()
if data == "boom":
self.fail("invalid")
return super().run_validation(data)
def test_listfield_skips_empty_child():
f = serializers.ListField(child=serializers.CharField(required=False))
assert f.run_validation(["hello", empty]) == ["hello"]
def test_listfield_error_index_is_preserved():
f = serializers.ListField(child=Skipping())
with pytest.raises(serializers.ValidationError) as exc:
f.run_validation(["skipme", "skipme", "boom"])
assert exc.value.detail == {2: [ErrorDetail("Not a valid string.", code="invalid")]}
def test_required_child_still_errors_on_empty():
f = serializers.ListField(child=serializers.CharField())
with pytest.raises(serializers.ValidationError) as exc:
f.run_validation(["hello", empty])
assert exc.value.detail == {1: [ErrorDetail("This field is required.", code="required")]}
def test_child_with_default_is_unaffected():
f = serializers.ListField(child=serializers.CharField(default="D"))
assert f.run_validation(["hello", empty]) == ["hello", "D"]
def test_dictfield_skips_empty_child():
d = serializers.DictField(child=serializers.CharField(required=False))
assert d.run_validation({"a": "x", "b": empty}) == {"a": "x"}The index case is the one that matters most for correctness: errors[idx] is keyed by input position, so a skipped element must not shift the indices of errors that follow it. continue before errors[idx] = ... preserves that; using a separate list and zipping would not.
For the application side in the meantime
Overriding run_child_validation in a subclass gets the behaviour without depending on the patch, and lets you make the all-skipped policy explicit:
class SkippingListField(serializers.ListField):
def run_child_validation(self, data):
result, errors = [], {}
for idx, item in enumerate(data):
try:
result.append(self.child.run_validation(item))
except serializers.ValidationError as exc:
errors[idx] = exc.detail
except SkipField:
continue
if errors:
raise serializers.ValidationError(errors)
return resultFor your use case I'd still normalise the sentinel before handing the list to DRF, so the API contract doesn't depend on how DRF resolves a missing field. empty inside a list element is a SkipField by accident of get_default() semantics, not a documented statement that a list element may be absent.
Environment: DRF master (3.18.1, commit 41764f1), Django 5.2, Python 3.11, SQLite. No database schema beyond Django's test setup.
Uh oh!
There was an error while loading. Please reload this page.
In:
django-rest-framework/rest_framework/fields.py
Line 1723 in b92edf5
When a child of a list filter raises SkipField, the exception is propagated and entire list is discarded, even if there is valid entries.
Following test does not pass because
run_validationraises SkipField for entire list:adding a SkipField check like so fixes this and does not break any tests:
A similar case probably exists for DictField too.
I wonder what are our options here. I'm designing an API around
emptyandSkipFieldinteractions and this caught me off guard. I propose following:SkipFieldlike the diff above. It does not break any tests but it might break some potential third party code.ListField.__init__(something likeallow_empty_child)All reactions