Skip to content

Skip unnecessary unique together checking #9154

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged

Conversation

yuekui
Copy link
Contributor

@yuekui yuekui commented Oct 31, 2023

Description

Skip unnecessary unique-together validations when all unique field values remain unchanged.

Currently, the validator checks the unique-together fields by querying the database before saving, regardless of whether the field values have changed or not. This approach incurs unnecessary costs. Therefore, I compared the new and existing values, and now, the unique validation is performed only if the related fields have changed.

]
if None not in checked_values and qs_exists(queryset):
if serializer.instance is None:
checked_values = [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will this change have any effect on schema migrations? thanks for the patch

Copy link
Contributor Author

@yuekui yuekui Oct 31, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @auvipy I believe there won't be any changes to the schema or any impact on migrations.

@auvipy auvipy requested a review from a team November 2, 2023 12:16
@auvipy auvipy added this to the 3.15 milestone Nov 25, 2023
Copy link
Member

@auvipy auvipy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to know more context and use cases here and if this change will remain backward compatible? if not we should document that and make a strong case for the inclusion of the change

@yuekui
Copy link
Contributor Author

yuekui commented Nov 25, 2023

I would like to know more context and use cases here and if this change will remain backward compatible? if not we should document that and make a strong case for the inclusion of the change

Hi @auvipy, thanks for reviewing. First, I can confirm that this change will remain backward compatible. Currently, the validator checks the unique-together fields by querying the database before saving, regardless of whether the field values have changed or not. This approach incurs unnecessary costs. Therefore, I compared the new and existing values, and now, the unique validation is performed only if the related fields have changed.

@auvipy
Copy link
Member

auvipy commented Nov 25, 2023

That sounds good

@auvipy
Copy link
Member

auvipy commented Nov 25, 2023

Is it possible to add additional tests for this?

@@ -447,6 +447,22 @@ def test_do_not_ignore_validation_for_null_fields(self):
serializer = NullUniquenessTogetherSerializer(data=data)
assert not serializer.is_valid()

def test_ignore_validation_for_unchanged_fields(self):
Copy link
Contributor Author

@yuekui yuekui Nov 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@auvipy This is a new test I added for the scenario where none of the fields in the unique-together constraint are changed. For other cases, such as when any of the field values change, they are already covered by other test cases.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was asking for if more additional tests could be provided?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, though I believe all the cases have already been covered, I might have overlooked something. Could you suggest what types of additional tests might be needed?

@auvipy auvipy merged commit 41edb3b into encode:master Jan 26, 2024
@blag
Copy link
Contributor

blag commented Apr 19, 2024

Upgrading from 3.14.0 to 3.15.1 caused tests for a project of mine to break, and I think it's related to or because of this PR.

However, I don't know that I would consider that a regression for DRF, and I was able to find a workaround (see below).

Example Code

I apologize in advance, I can only post redacted example code.

Model:

class RedactedModel(models.Model):
    ...
    fk = models.ForeignKey(...)
    status = models.CharField(..., default="not-primary-1")
    usage = models.CharField(...)

    class Meta:
        constraints = [
            models.UniqueConstraint(
                name="usage_unique_status_idx",
                fields=["fk", "usage"],
                condition=models.Q(status="primary"),
            ),
        ]

Serializer:

class RedactedModelSerializer(serializers.HyperlinkedModelSerializer):
    ...
    fk = serializers.HyperlinkedRelatedField(
        queryset=FKModel.objects.all(),
        ...,
    )
    status = serializers.ReadOnlyField(default="not-primary-1")

    class Meta:
        model = RedactedModel
        fields = ['fk', 'status', 'usage', ...]

ViewSet:

class RedactedModelViewSet(viewsets.ModelViewSet):
    queryset = RedactedModel.objects.all()
    serializer_class = RedactedModelSerializer
    ...

    def perform_create(self, serializer, *args, **kwargs):
        with transaction.atomic():
            try:
                RedactedModel.objects.filter(
                    fk=serializer.validated_data['fk'],
                    status="primary",
                    usage=serializer.validated_data['usage'],
                ).update(status="not-primary-2")
            except IntegrityError:
                ...
            serializer.save()

I'm not 100% sure that I am doing everything correctly, so if there's something obviously amiss, or even a better way to implement this, please let me know.

The Problem

For quick reference, here's some relevant DRF code:

class CreateModelMixin:
    def create(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        self.perform_create(serializer)
        headers = self.get_success_headers(serializer.data)
        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

Originally (eg: with DRF 3.14.0), serializer validation seems to have skipped checking the unique together fields, which allowed my custom perform_create method to update the previous primary RedactedModel object before saving the new object, avoiding violating the usage_unique_status_idx unique together constraint.

After updating to 3.15.1, the serializer validation no longer skips checking the unique together fields, so the serializer never validates the data, previous RedactedModel objects are never updated, and a new RedactedModel is not created.

The Solution

I found a clue in ModelSerializer.get_validators:

class ModelSerializer(Serializer):
    def get_validators(self):
        """
        Determine the set of validators to use when instantiating serializer.
        """
        # If the validators have been declared explicitly then use that.
        validators = getattr(getattr(self, 'Meta', None), 'validators', None)
        if validators is not None:
            return list(validators)

        # Otherwise use the default set of validators.
        return (
            self.get_unique_together_validators() +
            self.get_unique_for_date_validators()
        )

It's a bit of an imperfect workaround, but I set the validators to an empty list in the serializer Meta class:

class RedactedModelSerializer(serializers.HyperlinkedModelSerializer):
    ...

    class Meta:
        model = RedactedModel
        # Turn off unique together validators so we can validate serialized data
        # and handle non-unique situations manually during creation
        validators = []
        ...

This bypasses serializer validation for the unique constraints so the update code in perform_create have its affect.


I'm not sure that this workaround is reasonable for other use cases, but I'm also not sure if this is uncovering a bug in a previous version (3.14.0), if it is uncovering a regression somewhere between 3.14.0 and 3.15.1, or if it is uncovering a bug in my codebase. But I hope this might help somebody else who runs into this.

@carltongibson
Copy link
Collaborator

@blag I'd suggest opening a new issue with this report (otherwise it will get lost). If you could reduce it to a test case against the DRF test suite that shows the change in behaviour that would help significantly. (It's not immediately clear from the code you've posted, so needs further investigation to be actionable.)

Thanks.

gergosimonyi added a commit to goauthentik/django-rest-framework that referenced this pull request Apr 14, 2025
gergosimonyi added a commit to goauthentik/authentik that referenced this pull request Apr 14, 2025
upgrade `django-rest-framework` to `3.16.0`

The reverted commit is purely an optimization which unfortunately breaks authentik, specifically Blueprints. It adds `getattr(serializer.instance, field)` to a validator. If `field` is a `RelatedObject`, that invocation queries the database.

When authentik creates objects using Blueprints, it doesn't place related objects into the database before the validator tries to get them from there, so with the reverted commit, it produces `RelatedObjectDoesNotExist`.

Perhaps a long-term solution is to revise how Blueprints work, or perhaps it is to change upstream. But in the meantime, Django 5.0 support ended and upgrading to Django 5.1 requires an upgrade of `django-rest-framework` to `3.16.0`, hence this workaround.

See
- encode/django-rest-framework#9154
- encode/django-rest-framework#9358
- encode/django-rest-framework#9482
- encode/django-rest-framework#9483
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants