Django unique_together с nullable ForeignKey

Я использую Django 1.8.4 в своей машине dev, используя Sqlite, и у меня есть эти модели:

class ModelA(Model):
    field_a = CharField(verbose_name='a', max_length=20)
    field_b = CharField(verbose_name='b', max_length=20)

    class Meta:
        unique_together = ('field_a', 'field_b',)


class ModelB(Model):
    field_c = CharField(verbose_name='c', max_length=20)
    field_d = ForeignKey(ModelA, verbose_name='d', null=True, blank=True)

    class Meta:
        unique_together = ('field_c', 'field_d',)

Я выполнил надлежащую миграцию и зарегистрировал их в Admin. Таким образом, используя Admin, я сделал следующие тесты:

  • Я могу создавать записи ModelA, и Django запрещает мне создавать повторяющиеся записи - как и ожидалось!
  • Я не могу создать идентичные записи ModelB, когда field_b не пуст
  • Но я могу создавать идентичные записи ModelB при использовании field_d как пустых

Мой вопрос: как я могу применить unique_together для nullable ForeignKey?

Самый последний ответ, который я нашел для этой проблемы, - 5 лет... Я думаю, что Django развился, и проблема может быть не такой.

Ответ 1

ОБНОВЛЕНИЕ: предыдущая версия моего ответа была функциональной, но имела плохую конструкцию, которая учитывает некоторые комментарии и другие ответы.

В SQL NULL не равен NULL. Это означает, что если у вас есть два объекта, где field_d == None and field_c == "somestring" они не равны, поэтому вы можете создать оба.

Вы можете переопределить Model.clean чтобы добавить чек:

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)

Если вы используете вне форм, вам нужно вызвать full_clean или validate_unique.

Однако будьте осторожны, чтобы справиться с состоянием гонки.

Ответ 2

@ivan, я не думаю, что у django есть простой способ справиться с этой ситуацией. Вам нужно подумать обо всех операциях создания и обновления, которые не всегда происходят из формы. Кроме того, вы должны думать об условиях гонки...

А поскольку вы не применяете эту логику на уровне БД, вполне возможно, что на самом деле будут дублированные записи, и вы должны проверить это при запросе результатов.

Что касается вашего решения, оно может быть полезным для формы, но я не ожидаю, что метод save может вызвать ValidationError.

Если это возможно, то лучше передать эту логику в БД. В этом конкретном случае вы можете использовать два частичных индекса. Там похожий вопрос на StackOverflow - создать уникальное ограничение с пустыми столбцами

Таким образом, вы можете создать Django-миграцию, которая добавит два частичных индекса в вашу БД

Пример:

# 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)
    ]

Ответ 3

Я думаю, что это более понятный способ сделать это для Django 1. 2+

В формах он будет вызван как non_field_error без ошибки 500, в других случаях, например, в DRF, вы должны проверить это руководство, потому что это будет ошибка 500. Но он всегда будет проверять уникальность!

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)