Django unique_together with nullable ForeignKey

自作多情 提交于 2019-11-29 05:39:42

UPDATE: previous version of my answer was functional but had bad design, this one takes in account some of the comments and other answers.

In SQL NULL does not equal NULL. This means if you have two objects where field_d == None and field_c == "somestring" they are not equal, so you can create both.

You can override Model.clean to add your check:

class ModelB(Model):
    #...
    def validate_unique(self, exclude=None):
        if ModelB.objects.exclude(id=self.id).filter(field_c=self.field_c, \
                                 field_d__isnull=True).exists():
            raise ValidationError("Duplicate ModelB")
        super(ModelB, self).validate_unique(exclude)

If used outside of forms you have to call full_clean or validate_unique.

Take care to handle the race condition though.

vvkuznetsov

@ivan, I don't think that there's a simple way for django to manage this situation. You need to think of all creation and update operations that don't always come from a form. Also, you should think of race conditions...

And because you don't force this logic on DB level, it's possible that there actually will be doubled records and you should check it while querying results.

And about your solution, it can be good for form, but I don't expect that save method can raise ValidationError.

If it's possible then it's better to delegate this logic to DB. In this particular case, you can use two partial indexes. There's a similar question on StackOverflow - Create unique constraint with null columns

So you can create Django migration, that adds two partial indexes to your DB

Example:

# Assume that app name is just `example`

CREATE_TWO_PARTIAL_INDEX = """
    CREATE UNIQUE INDEX model_b_2col_uni_idx ON example_model_b (field_c, field_d)
    WHERE field_d IS NOT NULL;

    CREATE UNIQUE INDEX model_b_1col_uni_idx ON example_model_b (field_c)
    WHERE field_d IS NULL;
"""

DROP_TWO_PARTIAL_INDEX = """
    DROP INDEX model_b_2col_uni_idx;
    DROP INDEX model_b_1col_uni_idx;
"""


class Migration(migrations.Migration):

    dependencies = [
        ('example', 'PREVIOUS MIGRATION NAME'),
    ]

    operations = [
        migrations.RunSQL(CREATE_TWO_PARTIAL_INDEX, DROP_TWO_PARTIAL_INDEX)
    ]

I think this is more clear way to do that for Django 1.2+

In forms it will be raised as non_field_error with no 500 error, in other cases, like DRF you have to check this case manual, because it will be 500 error. But it will always check for unique_together!

class BaseModelExt(models.Model):
is_cleaned = False

def clean(self):
    for field_tuple in self._meta.unique_together[:]:
        unique_filter = {}
        unique_fields = []
        null_found = False
        for field_name in field_tuple:
            field_value = getattr(self, field_name)
            if getattr(self, field_name) is None:
                unique_filter['%s__isnull' % field_name] = True
                null_found = True
            else:
                unique_filter['%s' % field_name] = field_value
                unique_fields.append(field_name)
        if null_found:
            unique_queryset = self.__class__.objects.filter(**unique_filter)
            if self.pk:
                unique_queryset = unique_queryset.exclude(pk=self.pk)
            if unique_queryset.exists():
                msg = self.unique_error_message(self.__class__, tuple(unique_fields))

                raise ValidationError(msg)

    self.is_cleaned = True

def save(self, *args, **kwargs):
    if not self.is_cleaned:
        self.clean()

    super().save(*args, **kwargs)
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!