社交WEB应用允许用户之间相互联系,在本章我们将学到如何在Flasky中实现关注功能,让用户“关注”其它用户,并在首页只显示所关注用户发布的博客文章列表。本节将再论数据库关系,讲解多对多关系以及自引用关系在ORM模型中表示。
关系型数据库使用关系建立起记录之间的联系,其中“一对多”关系是最常用的关系模型,它把一个记录和一组相关的记录联系在一起。实现这种关系时,要在多这一侧加入一个外键,指向“一”这一侧连接的记录。多数其它关系类型都可以从一对多类型中衍生:
- 多对一:从多这一侧看就是“一对多关系”;
- 一对一:简化版的“一对多“关系,限制多这一侧最多只能有一个记录即可;
唯一不能从“一对多”衍生出来的关系是“多对多”关系,这种关系的两侧都有多个记录,接下来将详细讨论“多对多”关系。
一. 多对多关系:学生和课程
一个典型的多对多关系:学生表和课程表,一个学生可以选多门课程,一门课程也会被多个学生选,因此两侧都需要一组外键。解决办法是添加第三张表,这个表是关联表,将多对多关系分解成原表和关联表之间的两个一对多关系。
这个例子中的关联表是registrations,表中的每一行表示一个学生注册的一门课程。上图的多对多关系可使用如下代码表示:
from sqlalchemy import Integer, String, create_engine, \
Column, Table, ForeignKey
from sqlalchemy.orm import sessionmaker, relationship, backref
from sqlalchemy.ext.declarative import declarative_base
engine = create_engine('sqlite:///demo.sqlite', echo=True)
Base = declarative_base(engine)
session = sessionmaker(bind=engine)()
registrations = Table(
'registrations',
Base.metadata,
Column('student_id', Integer, ForeignKey('students.id')),
Column('classes_id', Integer, ForeignKey('classes.id'))
)
class Class(Base):
__tablename__ = "classes"
id = Column(Integer, primary_key=True)
name = Column(String)
class Student(Base):
__tablename__ = "students"
id = Column(Integer, primary_key=True)
name = Column(String)
classes = relationship(
Class, secondary=registrations,
backref=backref('students', lazy='dynamic'),
lazy='dynamic')
Base.metadata.drop_all()
Base.metadata.create_all()
运行结果(运行上述代码,表格将自动创建):
多对多关系仍使用定义一对多关系的relationship()方法定义,但在多对多关系中,必须把secondary参数设为关联表。多对多关系可以在任何一个类中定义,backref参数会处理好关系的另一侧。关联表就是一个简单的表,不是模型,SQLAlchemy会自动接管这个表。
classes关系使用列表语义定义,处理对对多关系时将非常简单:
# 分别实例化课程实例和学生实体
class1 = Class(name='English')
class2 = Class(name='Chinese')
stu1 = Student(name='Alex')
stu2 = Student(name='Bob')
# 将实体关联起来
stu1.classes.append(class1)
stu1.classes.append(class2)
stu2.classes.append(class2)
# 插入表
session.add(stu1)
session.add(stu2)
session.add(class1)
session.add(class2)
# 提交数据库
session.commit()
运行结果:
表展示(依次为课程表、学生表、关系表记录):
列出学生stu1注册的课程:
stu1.classes.all()
SQLAlchemy查询过程:
查询结果:
Class模型中的students关系由函数backref()定义。注意此处还指定了lazy="dynamic"参数,所以关系两侧返回的查询都可接收额外的过滤器。
如果后来学生stu1决定不选课程c了,可以使用下面的代码跟新数据库:
将删除提交数据库:
再次查询,确认结果:
二. 多对多关系:帖子和类别标签
from sqlalchemy import DateTime, Table, Column, String, create_engine, Integer, ForeignKey
from sqlalchemy.orm import sessionmaker, relationship
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime
Base = declarative_base(engine)
engine = create_engine('sqlite:///demo.sqlite')
DataBase = sessionmaker(bind=engine)
post_tag = \
Table('post_tag', Base.metadata,
Column('id', Integer, primary_key=True, autoincrement=True),
Column('post_id', Integer, ForeignKey('posts.id')),
Column('tag_id', Integer, ForeignKey('tags.id')))
class Post(Base):
__tablename__ = 'posts'
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String, nullable=False)
content = Column(String, nullable=False)
created_on = Column(DateTime, default=datetime.now)
updated_on = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# 注意此处关系的2侧我们都没有定义lazy="dynamic",下面查询的时候注意和上例比较结果
tags = relationship('Tag', backref='posts', secondary=post_tag)
def __repr__(self):
return '<Post: {} {} letters>'.format(self.title, len(self.content))
class Tag(Base):
__tablename__ = 'tags'
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, nullable=False)
def __repr__(self):
return '<Tag: {}>'.format(self.name)
在数据库中创建上述定义的实体类对应的表:
Base.metadata.create_all(bind=engine)
插入数据:
session = DataBase()
p1 = Post(title='hello', content='world')
p2 = Post(title='football', content='world cup')
t1 = Tag(name='Tech')
t2 = Tag(name='Funny')
t3 = Tag(name='News')
# 关联实体对象
p1.tags.append(t1)
p1.tags.append(t2)
p1.tags.append(t3)
p2.tags.append(t2)
p2.tags.append(t3)
# 提交数据库
session.add(p1)
session.add(p2)
session.commit()
查询所有博客文章并获取其对应的标签:
查询所有标签分类,并获取每个标签下的所有博客文章:
三. 自引用关系
多对多关系可用于实现用户之间的关注,但存在一个问题。在上述例子中关联表连接的都是2个不同的实体,表示用户关注其它用户时,只有一个用户实体。
如果关系中的2侧都在同一个表中,这种关系称为自引用关系:
本例的关系表是follows,其中的每一行表示一个用户关注了另一个用户。当我们需要存储所连接两个实体之间的额外信息时,只能存储在关联表中,如存储用户关注另一个用户的日期。但像之前那样,关系表完全是由SQLAlchemy管控的内部表,我们无法插手。为了能在关系中处理自定义的数据,我们必须提升关联表的地位,使其变为应用可访问的模型。
app/models.py:关注关系中关联表的实现
class Follow(db.Model):
__tablename__ = 'follows'
follower_id = db.Column(db.Integer, db.ForeignKey('users.id'), primary_key=True)
followed_id = db.Column(db.Integer, db.ForeignKey('users.id'), primary_key=True)
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
SQLAlchemy不能直接使用这个关联表,因为这么做应用就无法访问其中的自定义字段。相反地,要把这个多对多关系的左右2侧拆分成2个基本的一对多关系,而且要定义成标准的关系。
app/models.py:使用2个1对多关系实现的多对多关系
class User(UserMixin, db.Model):
__tablename__ = "users"
...
followed = db.relationship('Follow',
foreign_keys=[Follow.follower_id],
backref=db.backref('followers', lazy='joined'),
lazy='dynamic',
cascade='all, delete-orphan')
followers = db.relationship('Follow',
foreign_keys=[Follow.followed_id],
backref=db.backref('followed', lazy='joined'),
lazy='dynamic',
cascade='all, delete-orphan')
解读:
- followed和followers关系都定义为单独的一对多关系。为了消除外键的歧义,定义关系时必须指定可选参数 foreign_keys 外键。此外backref并不是指定两个关系之间的引用关系,而是回引Follow模型。
- 回引中的lazy参数为'joined',此lazy模式可以实现立即从联结表中加载相关对象。如某个用户关注了100个用户,调用user.followed.all()后返回一个列表,其中包含100个Follow实例,每一个实例的follower和followed回引属性都指向相应的用户。lazy='joined'模式时,就可以在一次数据库查询中完成这些操作。如果设为'select',只有当首次访问follower和followered属性时才会加载相应的用户,而且每个属性都需要一个单独的查询,这就意味着获取全部被关注用户时需要额外增加100次数据库查询。
- lazy参数都在“一”这一侧设定,返回的结果是“多”这一侧的记录。User一侧设定的lazy参数为'dynamic',因此关系属性不会直接返回记录,而是返回查询对象,所以在执行查询之前还可以添加额外的过滤器。
- cascade参数的值是一组由逗号分隔的层叠选项,其中all表示除了delete-orpha之外的所有层叠选项,即启用所有默认层叠选项,而且还要删除孤儿记录。这是因为删除对象时,默认的层叠行为是把对象连接的所有相关对象的外键设为空值。但在关联表中,删除记录后正确的行为是把指向该记录的实体也删除,这样才能有效销毁连接。
应用现在需要处理两个一对多关系,以便实现多对多关系。接下来,在User模型中添加关注、取关、是否是xxx的关注者、是否被xxx关注 的辅助方法:
class User(UserMixin, db.Model):
__tablename__ = "users"
...
def is_following(self, user):
if user.id is None:
return False
return self.followed.filter_by(followed_id=user.id).first() is not None
def is_followed_by(self, user):
if user.id is None:
return False
return self.followers.filter_by(follower_id=user.id).first() is not None
def follow(self, user):
if not self.is_following(user):
f = Follow(followed=user, follower=self)
db.session.add(f)
def un_follow(self, user):
f = self.followed.filter_by(followed_id=user.id).first()
if f:
db.session.delete(f)
解读:
- is_followed_by和is_following()方法分别在左右2边的一对多关系中搜索指定用户,如果找到了就返回true。注意发起查询前,这2个方法都确认了指定的用户有没有id,以防创建了用户,但是尚未提交到数据库。
- follow()方法手动把Follow实例插入关联表,从而把关注者和被关注者联系起来,并让应用有机会设定自定义字段,最后把这个实例对象添加到数据库会话中。
- 注意,timestamp字段无需手动设定,因为定义字段时指定了默认值,即当前日期和时间。
- un_follow()方法使用followed关系找到连接用户和被关注用户的Follow实例。若想销毁这两个用户之间的连接,只需删除这个Follow对象即可。
四. 编写新增数据库关系的单元测试
class UserModelTestCase(unittest.TestCase):
...
def test_follows(self):
u1 = User(email='[email protected]', password='cat')
u2 = User(email='[email protected]', password='dog')
db.session.add(u1)
db.session.add(u2)
db.session.commit()
self.assertFalse(u1.is_following(u2))
self.assertFalse(u1.is_followed_by(u2))
timestamp_before = datetime.datetime.utcnow()
u1.follow(u2)
db.session.add(u1)
db.session.commit()
timestamp_after = datetime.datetime.utcnow()
self.assertTrue(u1.is_following(u2))
self.assertFalse(u1.is_followed_by(u2))
self.assertTrue(u2.is_followed_by(u1))
self.assertTrue(u1.followed.count() == 2)
self.assertTrue(u2.followers.count() == 2)
new_followed = u1.followed.all()[-1]
self.assertTrue(new_followed.followed == u2)
self.assertTrue(timestamp_before <= new_followed.timestamp <= timestamp_after)
new_follower = u2.followers.all()[-1]
self.assertTrue(new_follower.follower == u1)
u1.un_follow(u2)
db.session.add(u1)
db.session.commit()
self.assertTrue(u1.followed.count() == 1)
self.assertTrue(u2.followers.count() == 1)
self.assertTrue(Follow.query.count() == 2)
u2.follow(u1)
db.session.add(u1)
db.session.add(u2)
db.session.commit()
db.session.delete(u2)
db.session.commit()
self.assertTrue(Follow.query.count() == 1)