1. 关注者功能在数据库中的实现
一对多关系是最常用的关系类型,它把一个记录和一组相关的记录联系在一起。实现这种关系时,要在“多”这一侧加入一个外键,指向“一”这一侧联接的记录。比如在User中指定外键,指向Role(多个User为一个Role)
大部分的其他关系类型都可以从一对多类型中衍生。多对一关系从“多”这一侧看,就是一对多关系。一对一关系类型是简化版的一对多关系,限制“多”这一侧最多只能有一个记录。唯一不能从一对多关系中简单演化出来的类型是多对多关系。
多对多关系
下面以一个典型的多对多关系为例,即一个记录学生和他们所选课程的数据库。很显然,你不能在学生表中加入一个指向课程的外键,因为一个学生可以选择多个课程,一个外键不够用。同样,你也不能在课程表中加入一个指向学生的外键,因为一个课程有多个学生选择。两侧都需要一组外键。
这种问题的解决方法是添加第三张表,这个表称为关联表。这样,多对多关系可以分解成原表和关联表之间的两个一对多关系。
这个例子中的关联表是 registrations,表中的每一行都表示一个学生注册的一个课程。
查询多对多关系要分成两步。若想知道某位学生选择了哪些课程,你要先从学生和注册之间的一对多关系开始,获取这位学生在 registrations 表中的所有记录,然后再按照多到一的方向遍历课程和注册之间的一对多关系,找到这位学生在 registrations 表中各记录所对应的课程。
若想找到选择了某门课程的所有学生,你要先从课程表中开始,获取其在 registrations 表中的记录,再获取这些记录联接的学生。
像上面这样的简单多对多关系,SQLAlchemy 就可以完成大部分操作
registrations = db.Table(‘registrations‘, db.Column(‘student_id‘, db.Integer, db.ForeignKey(‘student.id‘)), db.Column(‘class_id‘, db.Integer, db.ForeignKey(‘classes.id‘)) ) class Student(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String) classes = db.relationship(‘Class‘, secondary=registrations, backref=db.backref(‘students‘, lazy=‘dynamic‘), lazy=‘dynamic‘) class Class(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String)
多对多关系仍使用定义一对多关系的 db.relationship() 方法进行定义,但在多对多关系中,必须把 secondary 参数设为关联表。多对多关系可以在任何一个类中定义,backref 参数会处理好关系的另一侧。关联表就是一个简单的表,不是模型,SQLAlchemy 会自动接管这个表。
假设学生是 s,课程是 c 。
则学生注册课程:
>>> s.classes.append(c) >>> db.session.add(s)
列出 s 注册的课程, 以及 c 的学生:
>>> s.classes.all() >>> c.students.all()
学生取消课程:
>>> s.classes.remove(c)
自引用关系
在学生和课程的例子中,关联表联接的是两个明确的实体。
但是,表示用户关注其他用户时,只有用户一个实体,没有第二个实体。
如果关系中的两侧都在同一个表中,这种关系称为自引用关系。在关注中,关系的左侧是用户实体,可以称为follower。关系的右侧也是用户实体,但这些是followed。
关联表为follows,其中每一行都表示一个用户关注另一个用户。
图中左边表示的一对多关系把用户和 follows 表中的一组记录联系起来,用户是follower
图中右边表示的一对多关系把用户和 follows 表中的一组记录联系起来,用户是followed
高级多对多关系
用前一节介绍的自引用多对多关系可在数据库中表示用户之间的关注,但却有个限制。
使用多对多关系时,往往需要存储所联两个实体之间的额外信息。对用户之间的关注来说,可以存储用户关注另一个用户的日期,这样就能按照时间顺序列出所有关注者。这种信息只能存储在关联表中,但是在之前实现的学生和课程之间的关系中,关联表完全是由SQLAlchemy 掌控的内部表。为了能在关系中处理自定义的数据,我们必须提升关联表的地位,使其变成程序可访问的模型。
app/models/user.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) app/models/user.py: 使用两个一对多关系实现的多对多关系 class User(UserMixin, db.Model): # ... followed = db.relationship(‘Follow‘, foreign_keys=[Follow.follower_id], backref=db.backref(‘follower‘, 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‘)
这里的逻辑关系是,如果用户A关注了100个用户,调用user.followed.all() 后会返回一个列表其中包含100个Follow实例,每个实例的follower和followed回引属性都指向相应的用户。(follower指向用户A,followed指向用户A关注的用户)
为了消除外键间的歧义,定义关系时必须使用可选参数foreign_keys指定的外键
如果把lazy 设为默认值 select,那么首次访问 follower 和 followed 属性时才会加载对应的用户,而且每个属性都需要一个单独的查询,这就意味着获取全部被关注用户时需要增加 100 次额外的数据库查询。
而设定为 lazy=‘joined‘ 模式,就可在一次数据库查询中完成这些操作。
设为 all, delete-orphan 的意思是启用所有默认cascade选项,而且还要删除孤儿记录。
cascade 配置在父对象上执行的操作对相关对象的影响。
cascade 的默认值能满足大多数情况的需求,但对这个多对多关系来说却不合用。删除对象时,默认的cascade是把对象联接的所有相关对象的外键设为空值。但在关联表中,删除记录后正确的行为应该是把指向该记录的实体也删除,因为这样能有效销毁联接。这就是cascade选项delete-orphan 的作用。
app/models.py:在模型中添加follow, unfollow 功能
class User(db.Model): # ... def follow(self, user): if not self.is_following(user): f = Follow(follower=self, followed=user) db.session.add(f) def unfollow(self, user): f = self.followed.filter_by(followed_id=user.id).first() if f: db.session.delete(f) def is_following(self, user): return self.followed.filter_by(followed_id=user.id).first() is not None def is_followed_by(self, user): return self.followers.filter_by(follower_id=user.id).first() is not None
2. 在资料页中显示关注者
app/templates/user.html: 添加关注信息的用户信息页模板
{% if current_user.can(Permission.FOLLOW) and user != current_user %} {% if not current_user.is_following(user) %} <a href="{{ url_for(‘.follow‘, username=user.username) }}" class="btn btn-primary">Follow</a> {% else %} <a href="{{ url_for(‘.unfollow‘, username=user.username) }}" class="btn btn-default">Unfollow</a> {% endif %} {% endif %} <a href="{{ url_for(‘.followers‘, username=user.username) }}"> Followers: <span class="badge">{{ user.followers.count() }}</span> </a> <a href="{{ url_for(‘.followed_by‘, username=user.username) }}"> Following: <span class="badge">{{ user.followed.count() }}</span> </a> {% if current_user.is_authenticated() and user != current_user and user.is_following(current_user) %} | <span class="label label-default">Follows you</span> {% endif %}
app/main/views.py: follow路由
@main.route(‘/follow/<username>‘) @login_required @permission_required(Permission.FOLLOW) def follow(username): user = User.query.filter_by(username=username).first() if user is None: flash(‘Invalid user‘) return redirect(url_for(‘.index‘)) if current_user.is_following(user): flash(‘You are already following this user‘) return redirect(url_for(‘.user, username=username‘)) current_user.follow(user) flash(‘You are now following %s.‘ % username) return redirect(url_for(‘.user‘, username))
app/main/views.py: followers路由
@main.route(‘/followers/<username>‘) def followers(username): user = User.query.filter_by(username=username).first() if user is None: flash(‘Invalid user‘) return redirect(url_for(‘.index‘)) page = request.args.get(‘page‘, 1, type=int) pagination = user.followers.paginate( page, per_page=current_app.config[‘FLASKY_FOLLOWERS_PER_PAGE‘], error_out=False) followss = [{‘user‘: item.follower, ‘timestamp‘: item.timestamp} for item in pagination.items] return render_template(‘followers.html‘, user=user, title=‘Followers of‘, endpoint=‘.followers‘, pagination=pagination, follows=follows)
3. 使用数据库联结查询所关注用户的文章
想显示所关注用户发布的所有文章,第一步显然先要获取这些用户,然后获取各用户的文章,再按一定顺序排列,写入单独列表。可是这种方式的伸缩性不好,随着数据库不断变大,生成这个列表的工作量也不断增长,而且分页等操作也无法高效率完成。获取博客文章的高效方式是只用一次查询。
完成这个操作的数据库操作称为联结。联结操作用到两个或更多的数据表,在其中查找满足指定条件的记录组合,再把记录组合插入一个临时表中,这个临时表就是联结查询的结果。
如下示例
若想获得 susuan 所关注用户发布的文章,就要合并 posts 表和 follows 表。首先过滤follows 表,只留下关注者为 susuan 的记录,即上面表中的最后两行。然后过滤 posts 表,留下 author_id 和过滤后的 follows 表中 followed_id 相等的记录,把两次过滤结果合并,组成临时联结表,这样就能高效查询 susuan 所关注用户的文章列表。
app/models.py: 获取所关注用户的文章
class User(db.Model): # ... @property def followed_posts(self): return Post.query.join(Follow, Follow.followed_id == Post.author_id) .filter(Follow.follower_id == self.id)
4. 在首页显示所关注用户的文章
app/main/views.py: 显示所有文章或只显示所关注用户的文章
@app.route(‘/‘, methods=[‘GET‘, ‘POST‘]) def index(): # ... show_followed = False if current_user.is_authenticated(): show_followed = bool(request.cookies.get(‘show_followed‘, ‘‘)) if show_followed: query = current_user.followed_posts else: query = Post.query pagination = query.order_by(Post.timestamp.desc()).paginate( page, per_page=current_app.config[‘FLASKY_FOLLOWERS_PER_PAGE‘], error_out=False) posts = pagination.items return render_template(‘index.html‘, form=form, posts=posts, show_followed=show_followed, pagination=pagination)
app/main/views.py: 设定 show_followed cookie
@main.route(‘/all‘) @login_required def show_all(): resp = make_response(redirect(url_for(‘.index‘))) resp.set_cookie(‘show_followed‘, ‘‘, max_age=30*24*60*60) return resp @main.route(‘/followed‘) @login_required def show_followed(): resp = make_response(redirect(url_for(‘.index‘))) resp.set_cookie(‘show_followed‘, ‘1‘, max_age=30*24*60*60) return resp
添加功能: 用户查看好友文章时能看到自己的文章。最简单的解决办法是,注册时把用户设为自己的关注者
app/models.py:构建用户时把用户设为自己的关注者
class User(UserMixin, db.Model): # ... def __init__(self, **kwargs): # ... self.follow(self)
现在的数据库中可能已经创建了一些用户,而且都没有关注自己。如果数据库还比较小,容易重新生成,那么可以删掉再重新创建。如果情况相反,那么正确的方法是添加一个函数,更新现有用户。
class User(UserMixin, db.Model): # ... @staticmethod def add_self_follows(): for user in User.query.all(): if not user.is_following(user): user.follow(user) db.session.add(user) db.session.commit()
可以在shell中运行该函数来更新数据库
(venv) $ python manage.py shell >>> User.add_self_follows()
2015-05-27