Django 中 ORM 外键使用

外键 (Foreign Key)是用于建立和加强两个表数据之间的链接的一列或多列。通过将保存表中主键值的一列或多列添加到另一个表中,可创建两个表之间的连接,这个列就成为第二个表的外键。外键的作用如下:

保持数据一致性,完整性,主要目的是控制存储在外键表中的数据。 使两张表形成关联,就是当你对一个表的数据进行操作,和他有关联的一个或更多表的数据能够同时发生改变。

外键可以是一对一的,一个表的记录只能与另一个表的一条记录连接,或者是一对多的,一个表的记录与另一个表的多条记录连接。

在 MySQL 种想使用外键需要具备一定条件的:

  • MySQL 重需要关联的表必须都使用 InnoDB 引擎创建,MyISAM 表暂时不支持外键;
  • 外键列必须建立了索引,MySQL 4.1.2 以后的版本在建立外键时会自动创建索引,但如果在较早的版本则需要显式建立;
  • 外键关系的两个表的列必须是数据类型相似,也就是可以相互转换类型的列,比如 int 和 tinyint 可以,而 int和char 则不可以。

最后我们来了解下在 MySQL 中创建外键的用法,如下:

[CONSTRAINT symbol] FOREIGN KEY [id] (index_col_name, ...)
REFERENCES tbl_name (index_col_name, ...)
[ON DELETE {RESTRICT | CASCADE | SET NULL | NO ACTION | SET DEFAULT}]
[ON UPDATE {RESTRICT | CASCADE | SET NULL | NO ACTION | SET DEFAULT}]

该语法可以在 CREATE TABLE 和 ALTER TABLE 时使用,如果不指定 CONSTRAINT symbol,MySQL 会自动生成一个名字。其中 ON DELETE、ON UPDATE 表示事件触发限制,可设参数:

  • RESTRICT:限制外表中的外键改动,默认值;
  • CASCADE:跟随外键改动;
  • SET NULL:设空值;
  • SET DEFAULT:设默认值;
  • NO ACTION:无动作,默认的。

例如下面的 SQL 语句是由 Django 来帮我们自动生成 nember 和 vip_level 的:

CREATE TABLE `member` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(30) NOT NULL,
  `age` varchar(30) NOT NULL,
  `sex` smallint(6) NOT NULL,
  `occupation` varchar(30) NOT NULL,
  `phone_num` varchar(14) NOT NULL,
  `email` varchar(254) NOT NULL,
  `city` varchar(30) NOT NULL,
  `register_date` datetime(6) NOT NULL,
  `vip_level_id` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `member_vip_level_id_44ba3146_fk_vip_level_id` (`vip_level_id`),
  CONSTRAINT `member_vip_level_id_44ba3146_fk_vip_level_id` FOREIGN KEY (`vip_level_id`) REFERENCES `vip_level` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;

1. Django ORM 中外键的使用

为了能演示 ORM 中外键的使用,我们在前面的会员 Member 的基础上新增一个关联表:会员等级表(vip_level)。这个会员等级有 VIP、VVIP 以及超级 VIP 的 VVVIP 三个等级,我们在 models.py 中添加如下模型类,并在会员表中添加对应的外键字段,连接到会员等级表中:

# hello_app/models.py
# ...

class VIPLevel(models.Model):
    name = models.CharField('会员等级名称', max_length=20)
    price = models.IntegerField('会员价格,元/月', default=10)
    remark = models.TextField('说明', default="暂无信息")

    def __str__(self):
        return "<%s>" % (self.name)

    class Meta:
        db_table = 'vip_level'
    
class Member(models.Model):
    # ...

    # 添加外键字段
    vip_level = models.ForeignKey('VIPLevel', on_delete=models.CASCADE, verbose_name='vip level')

    # ...

# ...

首先,我们需要把前面生成的 Member 表删除,同时删除迁移记录文件,操作如下:

(django-manual) [root@server first_django_app]# pwd
/root/django-manual/first_django_app
# 删除迁移记录表
(django-manual) [root@server first_django_app]# rm -f hello_app/migrations/0001_initial.py 

此外,还需要将数据库中的原 member 表、django_migrations 表删除,即还原到最初状态。接下来,我们使用数据库迁移命令:

(django-manual) [root@server first_django_app]# python manage.py makemigrations
Migrations for 'hello_app':
  hello_app/migrations/0001_initial.py
    - Create model VIPLevel
    - Create model Member
(django-manual) [root@server first_django_app]# python manage.py migrate hello_app
Operations to perform:
  Apply all migrations: hello_app
Running migrations:
  Applying hello_app.0001_initial... OK

注意: 如果 migrate 后面不带应用会生成许多 Django 内置应用的表,比如权限表、用户表、Session表等。

图片描述

生成的 member 表

上面我们可以看到,我们生成的会员表中相比之前对了一个 vip_level_id 字段,这个字段关联的是 vip_level 表的 id 字段。现在我们首先在 vip_level 中新建三条记录,分别表示 VIP、VVIP 以及 VVVIP:

(django-manual) [root@server first_django_app]# python manage.py shell
Python 3.8.1 (default, Dec 24 2019, 17:04:00) 
[GCC 4.8.5 20150623 (Red Hat 4.8.5-39)] on linux
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from hello_app.models import VIPLevel
>>> vip = VIPLevel(name='vip', remark='普通vip', price=10)
>>> vip.save()
>>> vvip = VIPLevel(name='vvip', remark='高级vip', price=20)
>>> vvip.save()
>>> vvvip = VIPLevel(name='vvvip', remark='超级vip', price=30)
>>> vvvip.save()
>>> VIPLevel.objects.all()
<QuerySet [<VIPLevel: <vip>>, <VIPLevel: <vvip>>, <VIPLevel: <vvvip>>]>

接下来,我们操作 member 表,生成几条记录并关联到 vip_level 表:

>>> from hello_app.models import Member
>>> m1 = Member(name='会员1', age=29, sex=0, occupation='python', phone_num='18054299999', city='guangzhou')
>>> m1.vip_level = vip
>>> m1.save()
>>> m2 = Member(name='会员2', age=30, sex=1, occupation='java', phone_num='18054299991', city='shanghai')
>>> m2.vip_level = vvip
>>> m2.save()
>>> m3 = Member(name='会员3', age=35, sex=0, occupation='c/c++', phone_num='18054299992', city='beijing')
>>> m3.vip_level = vvvip
>>> m3.save()

查看会员表中生成的数据如下:

图片描述

会员表

可以看到,这里我们并没有直接写 vip_level_id 值,而是将 Member 的 vip_level 属性值直接赋值,然后保存。最后 Django 的 ORM 模型在这里会自动帮我们处理这个关联字段的值,找到关联记录的 id 值,并赋值给该字段。接下来,我们看下外键关联的查询操作:

>>> Member.objects.get(age=29).vip_level
<VIPLevel: <vip>>
>>> type(Member.objects.get(age=29).vip_level)
<class 'hello_app.models.VIPLevel'>

>>> vip = VIPLevel.objects.get(name='vip')
>>> vip.member_set.all()
<QuerySet [<Member: <会员1, 18054299999>>]>
>>> type(vip.member_set)
<class 'django.db.models.fields.related_descriptors.create_reverse_many_to_one_manager.<locals>.RelatedManager'>

上面的操作示例中我们给出了关联表 vip_level (往往成为主表) 和 member (往往成为子表) 之间的正向和反向查询。在 Django 默认每个主表都有一个外键属性,这个属性值为:从表_set,通过这个属性值我们可以查到对应的从表记录,比如上面的 vip.member_set.all() 语句就是查询所有 vip 会员。当然这个外键属性是可以修改的,我们需要在 member 表中的外键字段那里加上一个属性值:

class Member(models.Model):
    ...

    vip_level = models.ForeignKey('VIPLevel', related_name="new_name", on_delete=models.CASCADE, verbose_name='vip level')

    ...

这样我们想再次通过主表查询子表时,就要变成如下方式了:

>>> from hello_app.models import VIPLevel
>>> from hello_app.models import Member
>>> vip = VIPLevel.objects.get(name='vip')
>>> vip.member_set.all()
Traceback (most recent call last):
  File "<console>", line 1, in <module>
AttributeError: 'VIPLevel' object has no attribute 'member_set'
>>> vip.new_name.all()
<QuerySet [<Member: <会员1, 18054299999>>]>
>>>

前面在定义外键时,我们添加了一个 on_delete 属性,这个属性控制着在删除子表外键连接的记录时,对应字表的记录会如何处理,它有如下属性值:

CASCADE:级联操作。如果外键对应的那条记录被删除了,那么子表中所有外键为那个记录的数据都会被删除。对于例中,就是如果我们将会员等级 vip 的记录删除,那么所有 vip 会员会被一并删除;

# 前面使用的正是CASCADE
>>> from hello_app.models import VIPLevel
>>> from hello_app.models import Member
>>> VIPLevel.objects.get(name='vip')
<VIPLevel: <vip>>
>>> VIPLevel.objects.get(name='vip').delete()
(2, {'hello_app.Member': 1, 'hello_app.VIPLevel': 1})
>>> Member.objects.all()
<QuerySet [<Member: <会员2, 18054299991>>, <Member: <会员3, 18054299992>>]>

PROTECT:受保护。即只要子表中有记录引用了外键的那条记录,那么就不能删除外键的那条记录。如果我们强行删除,Django 就会报 ProtectedError 异常;

# 修改外键连接的 on_delete 属性值为 PROTECT
>>> from hello_app.models import VIPLevel
>>> from hello_app.models import Member
>>> VIPLevel.objects.get(name='vvip').delete()
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "/root/.pyenv/versions/django-manual/lib/python3.8/site-packages/django/db/models/base.py", line 918, in delete
    collector.collect([self], keep_parents=keep_parents)
  File "/root/.pyenv/versions/django-manual/lib/python3.8/site-packages/django/db/models/deletion.py", line 224, in collect
    field.remote_field.on_delete(self, field, sub_objs, self.using)
  File "/root/.pyenv/versions/django-manual/lib/python3.8/site-packages/django/db/models/deletion.py", line 22, in PROTECT
    raise ProtectedError(
django.db.models.deletion.ProtectedError: ("Cannot delete some instances of model 'VIPLevel' because they are referenced through a protected foreign key: 'Member.vip_level'", <QuerySet [<Member: <会员2, 18054299991>>]>)

SET_NULL:设置为空。如果外键的那条数据被删除了,那么子表中所有外键为该条记录的对应字段值会被设置为 NULL,前提是要指定这个字段可以为空,否则也会报错;

# hello_app/models.py
vip_level = models.ForeignKey('VIPLevel', related_name="new_name", on_delete=models.SET_NULL, verbose_name='vip level', null=True)

>>> from hello_app.models import VIPLevel
>>> from hello_app.models import Member
>>> VIPLevel.objects.get(name='vvip').delete()
>>> Member.objects.get(name='会员2').vip_level_id is None
True

注意:注意加上null=True是不够的,因为数据库在使用迁移命令时候已经默认是不可为空,这里测试时还需要手动调整下表 vip_level 字段属性,允许为 null。

图片描述

允许 vip_level_id 为 null

SET_DEFAULT:设置默认值。和上面类似,前提是字表的这个字段有默认值;

SET():如果外键的那条数据被删除了。那么将会获取SET函数中的值来作为这个外键的值。SET函数可以接收一个可以调用的对象(比如函数或者方法),如果是可以调用的对象,那么会将这个对象调用后的结果作为值返回回去;

# hello_app/models.py

# 新增一个设置默认值函数
def default_value():
    # 删除记录时会调用,在这里可以做一些动作
    # ...
    # 返回临时指向一条记录的id,返回不存在的id时会报错;返回数字也会报错,要注意
    return '4'

# ...
class Member(models.Model):
    # ...
    vip_level = models.ForeignKey('VIPLevel', related_name="new_name", on_delete=models.SET(default_value), verbose_name='vip level', null=True)
    # ...
>>> from hello_app.models import VIPLevel
>>> from hello_app.models import Member
>>> VIPLevel.objetcs.get(name='会员3').vip_level_id
3

# 新建一个临时过渡vip记录
>>> tmp_vip=VIPLevel(name='等待升级vip', price=30, remark='临时升级过渡')
>>> tmp_vip.save()
>>> tmp_vip.id
4

# 删除vvvip记录
>>> VIPLevel.objects.all().get(name='vvvip').delete()
(1, {'hello_app.VIPLevel': 1}
 # 可以看到,会员表中曾经指向为vvvip的记录被重新指向了临时过渡vip
>>> Member.objects.get(name='会员3').vip_level_id
4

DO_NOTHING:什么也不做,你删除你的,我保留我的,一切全看数据库级别的约束。在 MySQL 中,这种情况下无法执行删除动作。

2. 小结

本小节中我们描述了外键的相关概念,然后在 Django 的 shell 模式下使用会员表和会员等级表来进行外键的操作,重点演示了关联表之间的创建、相互查询以及删除等相关的操作。