You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Django 3.2+PostgreSQL:如何为MediaFile添加唯一使用约束?

解决方案

一、数据库层面:PostgreSQL触发器(强约束,推荐)

Django内置约束无法跨表统计关联次数,因此我们可以利用PostgreSQL的触发器实现数据库级别的强制约束,确保每个MediaFile被引用次数不超过1。

步骤1:创建触发器函数

编写PL/pgSQL函数,用于检查MediaFile的引用计数:

CREATE OR REPLACE FUNCTION check_mediafile_usage()
RETURNS TRIGGER AS $$
BEGIN
    -- 校验新的profile_picture是否已被使用
    IF NEW.profile_picture_id IS NOT NULL THEN
        IF (SELECT COUNT(*) FROM customer
            WHERE profile_picture_id = NEW.profile_picture_id OR id_picture_id = NEW.profile_picture_id) >= 1 THEN
            RAISE EXCEPTION 'MediaFile % is already in use', NEW.profile_picture_id;
        END IF;
    END IF;

    -- 校验新的id_picture是否已被使用
    IF NEW.id_picture_id IS NOT NULL THEN
        IF (SELECT COUNT(*) FROM customer
            WHERE profile_picture_id = NEW.id_picture_id OR id_picture_id = NEW.id_picture_id) >= 1 THEN
            RAISE EXCEPTION 'MediaFile % is already in use', NEW.id_picture_id;
        END IF;
    END IF;

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

步骤2:创建触发器

给customer表添加INSERT和UPDATE触发器,触发上述函数:

CREATE TRIGGER trigger_check_mediafile_usage
BEFORE INSERT OR UPDATE OF profile_picture_id, id_picture_id
ON customer
FOR EACH ROW
EXECUTE FUNCTION check_mediafile_usage();

步骤3:在Django迁移中执行SQL

创建空迁移文件,通过RunSQL执行上述SQL:

from django.db import migrations, models

class Migration(migrations.Migration):
    dependencies = [
        # 替换为你的app名和上一个迁移编号,例如('myapp', '0001_initial')
        ('myapp', '0002_xxxx'),
    ]

    operations = [
        migrations.RunSQL("""
            CREATE OR REPLACE FUNCTION check_mediafile_usage()
            RETURNS TRIGGER AS $$
            BEGIN
                IF NEW.profile_picture_id IS NOT NULL THEN
                    IF (SELECT COUNT(*) FROM customer
                        WHERE profile_picture_id = NEW.profile_picture_id OR id_picture_id = NEW.profile_picture_id) >= 1 THEN
                        RAISE EXCEPTION 'MediaFile % is already in use', NEW.profile_picture_id;
                    END IF;
                END IF;

                IF NEW.id_picture_id IS NOT NULL THEN
                    IF (SELECT COUNT(*) FROM customer
                        WHERE profile_picture_id = NEW.id_picture_id OR id_picture_id = NEW.id_picture_id) >= 1 THEN
                        RAISE EXCEPTION 'MediaFile % is already in use', NEW.id_picture_id;
                    END IF;
                END IF;

                RETURN NEW;
            END;
            $$ LANGUAGE plpgsql;
        """),
        migrations.RunSQL("""
            CREATE TRIGGER trigger_check_mediafile_usage
            BEFORE INSERT OR UPDATE OF profile_picture_id, id_picture_id
            ON customer
            FOR EACH ROW
            EXECUTE FUNCTION check_mediafile_usage();
        """),
    ]

二、Django层面:模型校验(补充拦截)

数据库触发器是强约束,但可以在应用层增加校验,提前拦截错误提升体验:

方法1:重写Customer的clean方法

from django.core.exceptions import ValidationError
from django.db.models import Q

class Customer(models.Model):
    profile_picture = models.ForeignKey('MediaFile', on_delete=models.PROTECT, related_name='profile_uses')
    id_picture = models.ForeignKey('MediaFile', on_delete=models.PROTECT, related_name='id_uses')

    def clean(self):
        super().clean()
        # 校验自身两个字段是否引用同一文件
        if self.profile_picture == self.id_picture:
            raise ValidationError("头像和证件照不能是同一个文件")
        
        # 校验profile_picture是否已被使用
        if self.profile_picture:
            usage_count = Customer.objects.filter(
                Q(profile_picture=self.profile_picture) | Q(id_picture=self.profile_picture)
            ).exclude(pk=self.pk).count()
            if usage_count >= 1:
                raise ValidationError(f"文件ID {self.profile_picture.id}已被使用")
        
        # 校验id_picture是否已被使用
        if self.id_picture:
            usage_count = Customer.objects.filter(
                Q(profile_picture=self.id_picture) | Q(id_picture=self.id_picture)
            ).exclude(pk=self.pk).count()
            if usage_count >= 1:
                raise ValidationError(f"文件ID {self.id_picture.id}已被使用")

方法2:使用pre_save信号

若需统一校验逻辑,可通过信号实现:

from django.db.models.signals import pre_save
from django.dispatch import receiver
from django.core.exceptions import ValidationError
from django.db.models import Q

@receiver(pre_save, sender=Customer)
def check_mediafile_usage(sender, instance, **kwargs):
    if instance.profile_picture == instance.id_picture:
        raise ValidationError("头像和证件照不能是同一个文件")
    
    if instance.profile_picture:
        usage_count = Customer.objects.filter(
            Q(profile_picture=instance.profile_picture) | Q(id_picture=instance.profile_picture)
        ).exclude(pk=instance.pk).count()
        if usage_count >= 1:
            raise ValidationError(f"文件ID {instance.profile_picture.id}已被使用")
    
    if instance.id_picture:
        usage_count = Customer.objects.filter(
            Q(profile_picture=instance.id_picture) | Q(id_picture=instance.id_picture)
        ).exclude(pk=instance.pk).count()
        if usage_count >= 1:
            raise ValidationError(f"文件ID {instance.id_picture.id}已被使用")

原方案失效原因

Django的CheckConstraint仅能基于当前表的物理字段做约束,无法引用关联表的动态统计数据(例如number_of_usages这类注解字段是查询时生成的临时数据,并非数据库表的固定字段),因此无法实现跨表的引用次数限制。

内容的提问来源于stack exchange,提问作者javad

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.07 20:03:23