Opened 3 years ago

Closed 18 months ago

Last modified 18 months ago

#33137 closed New feature (needsinfo)

Decouple Field.unique from select_related

Reported by: Markus Holtermann Owned by: nobody
Component: Database layer (models, ORM) Version:
Severity: Normal Keywords:
Cc: Simon Charette Triage Stage: Unreviewed
Has patch: no Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description (last modified by Markus Holtermann)

When inheriting from a OneToOneField that automatically adds additional constraints to a model, the object-level relation might be a one-to-one relation, while the underlying implementation is still a many-to-one.

Let's say you have two models A and B where A.rel = MyOneToOneField(B) and A.deleted = BooleanField(). In a regular OneToOneField there would now be a unique index on A.rel. However, by adding a unique constraint to A._meta.constraints over rel with a condition on deleted=False, rel only needs to be unique among the "undeleted" objects. Doing so is possible by forcing MyOneToOneField.unique=False (otherwise migrations create a constraint). However, this will mean, SQLCompiler.get_related_selections() fails when trying to do B.objects.select_related("a"), since a is not a valid field. That is because SQLCompiler.get_related_selections._get_field_choices() uses f.field.unique instead of (f.field.many_to_one or f.field.one_to_one), I think. Because, essentially, opts.related_objects is build based on those (many|one)_to_(many|one) field attributes.

I think, what would fix the behavior, could be something like this:

class SQLCompiler:
    def get_related_selections(self, select, opts=None, root_alias=None, cur_depth=1,
                               requested=None, restricted=None):
        def _get_field_choices():
            direct_choices = (f.name for f in opts.fields if f.is_relation)
            reverse_choices = (
                f.field.related_query_name()
                for f in opts.related_objects if (f.field.many_to_one or f.field.one_to_one)
            )
            return chain(direct_choices, reverse_choices, self.query._filtered_relations)

Change History (4)

comment:1 by Markus Holtermann, 3 years ago

Description: modified (diff)

comment:2 by Simon Charette, 3 years ago

Cc: Simon Charette added
Triage Stage: UnreviewedAccepted

This use case looks very similar to the django-reverse-unique project by Anssi which didn't require any adjustments to the ORM and properly supported select_related.

class A(models.Model):
    b = models.ForeignKey('B')
    deleted = models.BooleanField()

    class Meta:
        constraints = [
            UniqueConstraint(fields=['b'], condition=Q(deleted=False)),
        ]

class B(models.Model):
    a = ReverseUnique(A, filter=Q(deleted=False))

I assume you're trying to create a subclass of OneToOneField that allows specifying a condition and avoids having to add all of this boilerplate?

class A(models.Model):
    b = ConditionalOneToOne('B', condition=Q(deleted=False))
    deleted = models.BooleanField()

class B(models.Model):
    pass

In all cases I'm not against changing the code to use the documented (many|one)_to_(many|one) attributes instead of unique assuming we write a proper regression tests for them.

comment:3 by Simon Charette, 18 months ago

Resolution: needsinfo
Status: newclosed

I'm not sure how actionable this ticket is anymore. The proposed change to _get_field_choices won't actually solve anything as this function is only used to provide a more helpful error message when passing an invalid value to select_related.

Without providing an implementation that is known to be faulty it's hard to understand which adjustments should even be made as there are examples in the wild of other to one fields working properly by subclassing ForeignObject directly.

Closing as needsinfo pending a demonstration of an implementation that is problematic. Hopefully documenting ForeignObject will make it easier to write proper implementation for this (#27617).

comment:4 by Mariusz Felisiak, 18 months ago

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