DRF: Validate nested serializer data when creating, but not when updating

Question:

When using writable nested serializers in DRF there is the known problem with validating eventual unique fields and preventing the updating of the parent serializer. This issue has been asked many times in questions like these:

  1. Unique validation on nested serializer on Django Rest Framework
  2. Django rest framework not creating object with FK to a model with unique=True field

For simplicity let’s take the example from the first question:

class GenreSerializer(serializers.ModelSerializer):
    class Meta:
        fields = ('name',) #This field is unique
        model = Genre
        extra_kwargs = {
            'name': {'validators': []},
        }

class BookSerializer(serializers.ModelSerializer):
    genre = GenreSerializer()

    class Meta:
        model = Book
        fields = ('name', 'genre')

    def create(self, validated_data):
        # implement creating

    def update(self, instance, validated_data):
        # implement updating

Now the problem is that the uniqueness validation is dropped out also for creating. This could be intercepted in the view, for example:

class BookViewSet(viewsets.ModelViewSet):
    queryset = Book.objects.all()
    serializer = BookSerializer

    def perform_create(self):
        # implement logic and raise ValidationError

However this doesn’t feel really right because we’re validating the uniqueness of Genre in BookViewSet.

The other option is to implement the validation in the create() method of BookSerializer as explained in the second question (see list above).

What I really miss in both solutions is that the validation error is not attached to the field name of the model Genre and the user input in the form is lost.

What I’d like is to add the validation error for Genre.name to the existing validation errors, keep the user input and do this only for creating, not for updating.

My ideas were something like this:

class GenreSerializer(serializers.ModelSerializer):
    # ...
    def validate_name(self, value):
        # is it possible to check here if it is create or update?
        if create: # this is a placeholder for the logic
             if self.Meta.model.objects.filter(name=value).exists():
                 raise ValidationError('A genre with this name already exists.')
        return value

    # or to override the __init__ method

    def __init__(self, *args, **kwargs):
        super(GenreSerializer, self).__init__(*args, **kwargs)
        # check if create or update
        if create:
            self.fields['name'].validators.append('validation logic')

Is this possible or is there any other way to achieve the before mentioned goal – keep user input and add validation error attached to the field name to the list of existing validation errors when creating new instance?

Asked By: cezar

||

Answers:

What I really miss in both solutions is that the validation error is not attached to the field name of the model Genre and the user input in the form is lost.

This is straight forward:

from rest_framework import exceptions

class BookViewSet(viewsets.ModelViewSet):
    ....
    def perform_create(self, serializer):
        if check_failed():
            raise exceptions.ValidationError(
                exceptions._get_error_details({
                   'genre': {
                        'name': ['must be unique']
                    }
                })
            )
Answered By: Linovia

This is how I did it:

class GenreSerializer(serializers.ModelSerializer):
    # ... snip ...
    def validate_name(self, value):
       if self.context['request']._request.method == 'POST':
           if self.Meta.model.objects.filter(name=value).exists():
               raise ValidationError('A genre with this name already exists.')
        return value

In this way the validation is triggered only when a new Genre object is created (POST), not when it is updated (PUT).
When a new Book object is created, the validation for Genre is propagated to the nested serializer.
All form inputs are preserved after validation and the error message is attached to the field name.

That actually fulfills all my criteria. Although I don’t have the feeling that this is the right way of doing it. I’d still like to know how could I call manually the UniqueValidator in validate_name, instead of reinventing that validation.

EDIT:

I found out a way how to call the UniqueValidator in the method:

def validate_name(self, value):
    if self.context['request']._request.method == 'POST':
        unique = UniqueValidator(
            self.Meta.model.objects.all(),
            message='Genre with this name already exists.'
        )
        unique.set_context(self.fields['name'])
        unique(value)
    return value

EDIT (2022-09-09):

Instead of self.context['request']._request.method it’s much simpler and cleaner to use self.context['request'].method.

Tried and tested with Django REST Framework 3.13.

Answered By: cezar

In addition to @cezar’s answer I was wondering how to not just override based on a condition, but to completely override and ignore any of the fields.

It might be possible to "skip" children serializer validation, or write custom serializer validation to override child serializers, by overriding to_internal_value of the ParentSerialzer:

class ParentSerializer(serializers.ModelSerializer):
    children = ChildSerializer(many=True)

    def to_internal_value(self, *args, **kwargs):
        try:
            # runs the child serializers
            return super().to_internal_value(*args, **kwargs)
        except ValidationError as e:
            # fails, and then overrides the child errors with the parent error
            return self.validate(self.initial_data)

    def validate(self, attrs):
        errors = {}
        errors['custom_override_error'] = 'this ignores and overrides the children serializer errors'
        if len(errors):
            raise ValidationError(errors)
        return attrs
    class Meta:
        model = Parent
Answered By: jmunsch