表关系
关系型数据库的一个强大的功能,就是多张表之间可以建立关系。如文章表中,通常需要保存作者数据,但是我们不需要直接把作者数据放到文章表中,而是通过外键引用用户表。这种强大的表关系,可以存储非常复杂的数据,并且可以使查询非常迅速。在 Flask-SQLAlchemy 中,同样也支持表关系的建立,表关系建立的前提,是通过数据库的外键实现的。表关系总体来讲可以分为 3 种:一对多(多对一)、一对一、多对多。下面分别进行讲解。
外键
外键是数据库的技术,Flask-SQLAlchemy 中支持在创建 ORM 模型时就指定外键,创建外键是通过 db.ForeignKey 实现的。如创建 Article 表,这张表有一个 author_id 字段,通过外键引用 user 表的 id 字段,用来保存文章的作者,那么 Article 的模型代码如下。
class Article(db.Model):
__tablename__ = "article"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text, nullable=False)
author_id = db.Column(db.Integer, db.ForeignKey("user.id"))
以上代码,除了添加常规的 title、content 属性外,还增加了一个 author_id,author_id 通过 db.ForeignKey("user.id") 引用了之前创建的 user 表的 id 字段。这里有个细节需要注意,author_id 因为引用 user 表的 id 字段,所以它的类型必须跟 user 表的 id 字段一致,否则会报错。
一对多关系
我们生活中有很多一对多的例子,如 CSDN 博客中的一篇文章只能属于一个作者,一个作者能发布多篇文章,作者和文章之间是一对多的关系,反过来文章和作者之间是多对一的关系。
建立关系
5.3.1 节中通过外键,实际上已经建立起一对多的关系,即一篇文章只能引用一个作者,而一个作者可以被多篇文章引用。但是以上只是建立了一个外键,通过 Article 的对象还是无法直接获取到 author_id 引用的那个 User 对象。为了使操作 ORM 对象与操作普通 Python 对象一样,Flask-SQLAlchemy 提供了 db.relationship 来引用外键所指向的那个 ORM 模型。在以上的 Article 模型中添加 db.relationship,示例代码如下。
class Article(db.Model):
__tablename__ = "article"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text, nullable=False)
author_id = db.Column(db.Integer, db.ForeignKey("user.id"))
author = db.relationship("User")
我们添加了一个 author 属性,这个属性通过 db.relationship 与 User 模型建立了联系,以后通过 Article 的实例对象访问 author 时,如 article.author,那么 Flask-SQLAlchemy 会自动根据外键 author_id 从 user 表中寻找数据,并形成 User 模型实例对象。下面通过创建 Article 对象,并通过访问 Article 实例对象的 author 属性来关联 User 对象,示例代码如下。
@app.route('/article/add')
def article_add():
user = User.query.first()
article = Article(title="aa", content="bb", author=user)
db.session.add(article)
db.session.commit()
article = Article.query.filter_by(title="aa").first()
print(article.author.username)
以上代码中,首先创建了一个 article 对象,并添加到数据库中,接下来再从数据库中提取,然后通过 article.author.username 访问到 article 对象的用户名。
建立双向关系
现在的 Article 模型可以通过 author 属性访问到对应的 User 实例对象,但是 User 实例对象无法访问到和其关联的所有 Article 实例对象。因此为了实现双向关系绑定,还需要在 User 模型上添加一个 db.relationship 类型的 articles 属性,并且在 User 模型和 Article 模型双方的 db.relationship 上都需要添加一个 back_populates 参数,用于绑定对方访问自己的属性,示例代码如下。
class User(db.Model):
__tablename__ = "user"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
username = db.Column(db.String(100))
password = db.Column(db.String(100))
articles = db.relationship("Article", back_populates="author")
class Article(db.Model):
__tablename__ = "article"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text, nullable=False)
author_id = db.Column(db.Integer, db.ForeignKey("user.id"))
author = db.relationship("User", back_populates="articles")
在 User 端绑定了 articles 属性后,现在双方都能通过属性直接访问到对方了,示例代码如下。
user = User.query.first()
for article in user.articles:
print(article.title)
简化关系定义
以上 User 和 Article 模型中,通过在两边的 db.relationship 上传递 back_populates 参数来实现双向绑定,这种方式有点烦琐,我们还可以通过只在一个模型上定义 db.relationship 类型属性,并且传递 backref 参数实现双向绑定,示例代码如下。
class User(db.Model):
__tablename__ = "user"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
username = db.Column(db.String(100))
password = db.Column(db.String(100))
class Article(db.Model):
__tablename__ = "article"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text, nullable=False)
author_id = db.Column(db.Integer, db.ForeignKey("user.id"))
author = db.relationship("User", backref="articles")
以上代码中,我们删除了 User 模型上的 articles 属性,并且在 Article 模型上将 author 属性的 db.relationship 中的 back_populates 修改为 backref。backref 参数的功能更加强大,其可以自动给对方添加 db.relationship 的属性。
这种方式虽然方便,但是在模型比较多、项目团队人数较多的情况下,也容易造成困扰。如 User 模型上根本没有看到定义的 articles 属性,但是却在 Article 模型上创建了,这着实会让人摸不着头脑。因此为了更加直观和方便团队协作,建议使用 back_populates 来实现双向绑定。
一对一关系
要实现一对一关系,只需要在一对多的基础之上,将 “多” 的那一端设置为 “一” 即可,在 Flask-SQLAlchemy 中,通过给 db.relationship 传递 uselist=False,即可将 “多” 设置为 “一”,为了在数据库层面也实现一对一,还需要在外键上设置 unique=True。这里以用户拓展表为例。在公司业务增长的情况下,需要存储用户的许多属性,但是有些属性是不常用的,为了提高网站的响应速度,我们会把那些不常用的属性放到拓展表中,只在需要的时候才访问。用户表和用户拓展表就是典型的一对一关系,一个用户只能有一条拓展数据,一条拓展数据只能属于一个用户。下面新增一个 UserExtension 模型,与原来的 User 模型建立一对一的关系,示例代码如下。
class User(db.Model):
__tablename__ = "user"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
username = db.Column(db.String(100))
password = db.Column(db.String(100))
extension = db.relationship("UserExtension", back_populates="user", uselist=False)
class UserExtension(db.Model):
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
school = db.Column(db.String(100))
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), unique=True)
user = db.relationship("User", back_populates="extension")
User 和 UserExtension 的关系中,因为外键是添加到 UserExtension 模型上,因此 User 模型属于 “多” 的那一端,这时就设置 uselist=False,即可将 “多” 转化为 “一”。为了在数据库层面也实现一对一,将 UserExtension 模型上的 user_id 属性设置为 unique=True。此时如果要在一个 User 对象上添加多个 UserExtension 对象,那么就会抛出异常,示例代码如下。
@app.route("/one2one")
def one2one():
user = User.query.first()
extension1 = UserExtension(school="清华大学", user=user)
extension2 = UserExtension(school="北京大学", user=user)
db.session.add(extension1)
db.session.add(extension2)
db.session.commit()
return "一对一成功!"
上述代码中,我们试图在两个 UserExtension 实例对象上绑定同一个 User 对象,但是因为设置了一对一的关系,因此以上代码将会抛出类似以下的异常。
sqlalchemy.exc.IntegrityError: (pymysql.err.IntegrityError) (1062,"Duplicate entry '1' for key 'user_extension.user_id'") [SQL: INSERT INTO user_extension (school, user_id) VALUES (%(schoo
l)s, %(user_id)s)]
[parameters: {'school': '北京大学', 'user_id': 1}]
多对多关系
多对多关系在数据库层面是需要通过一张中间表来实现的,在 Flask-SQLAlchemy 中也是一样。这里以文章和标签为例,一篇文章可以添加多个标签,一个标签可以被多篇文章添加。我们创建标签 Tag 类,示例代码如下。
article_tag_table = db.Table(
"article_tag_table",
db.Column("article_id", db.Integer, db.ForeignKey("article.id"), primary_key=True),
db.Column("tag_id", db.Integer, db.ForeignKey("tag.id"), primary_key=True)
)
class Article(db.Model):
__tablename__ = "article"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text, nullable=False)
author_id = db.Column(db.Integer, db.ForeignKey("user.id"))
author = db.relationship("User", backref="articles")
tags = db.relationship("Tag", secondary=article_tag_table, back_populates="articles")
class Tag(db.Model):
__tablename__ = "tag"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
name = db.Column(db.String(100))
articles = db.relationship("Article", secondary=article_tag_table, back_populates="tags")
为了实现 Article 和 Tag 之间的多对多关系,我们使用 db.Table 创建了一张中间表 article_tag_table,并且添加了 article_id 和 tag_id 两个外键来分别与 article 和 tag 表进行关联。然后在 Article 和 Tag 类中分别添加了 tags 和 articles 属性,用来建立双向关系,并且在 db.relationship 中传递 secondary=article_tag_table 参数来绑定中间表。Article 和 Tag 的多对多关系建立后,可以通过以下代码来添加数据。
@app.route('/many2many')
def many2many():
article1 = Article(title="11", content="aa")
article2 = Article(title="22", content="bb")
tag1 = Tag(name="python")
tag2 = Tag(name="flask")
article1.tags.append(tag1)
article1.tags.append(tag2)
article2.tags.append(tag1)
article2.tags.append(tag2)
db.session.add_all([article1, article2])
db.session.commit()
return "多对多数据添加成功!"
上述代码中首先分别创建了两个 Article 对象和两个 Tag 对象,然后把两个 Tag 对象分别添加到 article1 和 article2 中,最后通过 db.session.add_all 方法把 article1 和 article2 添加到会话中,然后执行 commit 操作。因为两个 Tag 对象都已经与 article1 和 article2 进行关联了,在 article1 和 article2 被添加到会话中后,两个 Tag 对象也会被添加到会话中。在多对多关系中,添加对象使用的是 append 方法,移除对象使用的是 remove 方法。
级联操作
级联操作(cascade)是在操作某个对象时,相关联的对象也会进行对应的操作。在数据库层面的级联操作包括级联删除、级联更新等。Flask-SQLAlchemy 提供了比数据库更强大的级联操作,定义级联操作是通过对 db.relationship 传递 cascade 参数实现的,这个参数的值可以为 all、save-update、merge、refresh-expire、expunge、delete 中的一个或者多个,如果是多个,则通过英文逗号隔开,如 “save-update, delete”。谁设置了 cascade 参数,谁就是父表,父表数据发生变化,相关联的从表也会执行相应操作。这里为了不影响之前的模型,我们创建两个新的 ORM 模型来讲解级联操作,示例代码如下。
class Category(db.Model):
__tablename__ = "category"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
name = db.Column(db.String(100))
newses = db.relationship("News", back_populates="category")
class News(db.Model):
__tablename__ = "news"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
title = db.Column(db.String(100))
content = db.Column(db.Text)
category_id = db.Column(db.Integer, db.ForeignKey("category.id"))
category = db.relationship("Category", back_populates="newses")
以上代码中创建了两个模型,分别是新闻分类模型 Category 和新闻模型 News,并且在双方都建立了关系。下面分别来讲解级联操作常用的值。
save-update
save-update 是默认选项,它的作用是当某个对象被添加到会话中时,与此对象相关的对象也会被添加进去,示例代码如下。
category = Category(name="军事")
news = News(title="新闻1", content="新闻内容1")
news.category = category
db.session.add(news)
db.session.commit()
以上代码中创建了 category 和 news 对象,因为让 news 和 category 进行了关联,因此只要添加 news,那么 category 就会被自动添加进去。在 News 模型中创建 category 时设置 cascade="",示例代码如下。
class News(db.Model):
...
# 其他字段定义
category = db.relationship("Category", back_populates="newses", cascade="")
重新执行上述添加 news 和 category 的代码,会在 PyCharm 控制台出现以下错误。
SAWarning: Object of type <Category> not in session, add operation along
'News.category' won't proceed db.session.commit()
查看数据库后发现只有 news 数据被添加了,category 并没有被添加进去。这说明一旦 cascade 没有设置 save-update,那么被关联的对象就不会被添加到会话中。
delete
delete 表示当删除某个对象时,被关联的所有对象都会被删除。这个值默认在 cascade 中是没有的。
news = News.query.first()
db.session.delete(news)
db.session.commit()
return "success"
delete-orphan
delete-orphan 表示某个对象被父表解除关联时,此对象也会自动被删除。当然,如果父表中的数据被删除,此对象也会被删除。如某个 News 对象被从 Category.newses 上删除,则这个 News 对象也会被删除。将 Category 的 newses 属性修改为如下所示的代码。
class Category(db.Model):
...
newses = db.relationship("News", back_populates="category", cascade="delete,delete-orphan")
然后再执行删除操作,示例代码如下。
category = Category.query.first()
news = News(title="新闻2", content="新闻内容2")
# 将news添加到category的newses关系中
category.newses.append(news)
db.session.commit()
# 将news从category中解除关联
category.newses.remove(news)
db.session.commit()
以上代码中,首先将 news 添加到 category.newses 中,然后通过 commit 操作提交到数据库中。接着从 category.newses 中移除,这样就把 news 从 category 上解除了关联,因为在 Category 中定义 newses 属性时,设置了 cascade 为 delete-orphan,那么一旦解除关联,news 对象就成为孤儿(orphan)对象,即会自动从数据库中被删除。这个选项一般用在一对多关系上,不能用在多对多以及多对一关系上。如例子中的 Category 和 News, Category 属于 “一”,News 属于 “多”。删除分类,该分类下的新闻也被删除了,这符合常理,但是如果新闻被删除了,分类也跟着删除,这就会造成数据混乱。
merge
merge 是默认选项。在使用 session.merge 合并对象时,会将使用了 db.relationship 相关联的对象也进行 merge 操作。
这个参数几乎很少用到,读者作为了解即可。
expunge
进行移除操作时,会将相关联的对象也进行移除。这个操作只是将对象从 session 中移除,并不会真正地从数据库中删除。
我们首先将 News 模型中 category 属性的 cascade 参数修改为如下。
class News(db.Model):
...
category = db.relationship("Category", back_populates="newses", cascade="expunge")
然后执行以下操作。
news = News.query.first()
category = news.category
db.session.expunge(news)
category.name = '测试分类'
db.session.commit()
上述代码中,使用 db.session.expunge 方法将 news 对象从 session 中移除,因为 news 和 category 级联关系中设置了 expunge 选项,所以 category 对象也会跟着从 session 中移除,此时再去修改 category.name 的值,就不会同步到数据库中了。读者可以在执行上述代码前观察第一条新闻分类的名称,执行完上述代码后再观察,会发现分类名称没有发生变化。