Flask web开发实战之基础篇 Flask-数据库


前言

数据库是大多数动态Web程序的基础设施,只要你想把数据存储下来,就离不开数据库。我们这里提及的数据库(Database)指的是由存储数据的单个或多个文件组成的集合,它是一种容器,可以类比为文件柜。而人们通常使用数据库来表示操作数据库的软件,这类管理数据库的软件被称为数据库管理系统(DBMS,Database ManagementSystem),常见的DBMS有MySQL、PostgreSQL、SQLite、MongoDB等。为了便于理解,我们可以把数据库看作一个大仓库,仓库里有一些负责搬运货物(数据)的机器人,而DBMS就是操控机器人搬运货物的程序。

这一章我们来学习如何给Flask程序添加数据库支持。具体来说,是
学习如何在Python中使用这些DBMS来对数据库进行管理和操作。

本章新涉及的Python库如下所示:


本章的示例程序在helloflask/demos/database目录下,确保当前目录在helloflask/demos/database下并激活了虚拟环境,然后执行flask run命令运行程序:

$ cd demos/database
$ flask run

因为所有示例程序的CSS文件名称、JavaScript文件名称以及Favicon文件名称均相同,为了避免浏览器对不同示例程序中同名的文件进行缓存,请在第一次运行新的示例程序后按下Crtl+F5或Shift+F5清除缓存。

5. 数据库

5.1 数据库的分类

数据库一般分为两种,SQL(Structured Query Language,结构化查询语言)数据库和NoSQL(Not Only SQL,泛指非关系型)数据库。

5.1.1 SQL

SQL数据库指关系型数据库,常用的SQL DBMS主要包括SQLServer、Oracle、MySQL、PostgreSQL、SQLite等。关系型数据库使用表来定义数据对象,不同的表之间使用关系连接。下表是一个身份信息表的示例。
在这里插入图片描述
在SQL数据库中,每一行代表一条记录(record),每条记录又由不同的列(column)组成。在存储数据前,需要预先定义表模式(schema),以定义表的结构并限定列的输入数据类型。

为了避免在措辞上引起误解,我们先了解几个基本概念:

  • 1)表(table):存储数据的特定结构。
  • 2)模式(schema):定义表的结构信息。
  • 3)列/字段(column/field):表中的列,存储一系列特定的数据,
    列组成表。
  • 4)行/记录(row/record):表中的行,代表一条记录。
  • 5)标量(scalar):指的是单一数据,与之相对的是集合
    (collection)。

5.1.2 NoSQL

NoSQL最初指No SQL或No Relational,现在NoSQL社区一般会解释为Not Only SQL。NoSQL数据库泛指不使用传统关系型数据库中的表格形式的数据库。近年来,NoSQL数据库越来越流行,被大量应用在实时(real-time)Web程序和大型程序中。与传统的SQL数据库相比,它在速度和可扩展性方面有很大的优势,除此之外还拥有无模式(schemafree)、分布式、水平伸缩(horizontally scalable)等特点。最常用的两种NoSQL数据库如下所示:

最常用的两种NoSQL数据库如下所示:
1.文档存储(document store)
文档存储是NoSQL数据库中最流行的种类,它可以作为主数据库使用。文档存储使用的文档类似SQL数据库中的记录,文档使用类JSON格式来表示数据。常见的文档存储DBMS有MongoDB、CouchDB等。表5-1的身份信息表中的第一条记录使用文档可以表示为:

{
	id: 1,
	name: "Nick",
	sex: "Male"
	occupation: "Journalist"
}

2.键值对存储(key-value store)
键值对存储在形态上类似Python中的字典,通过键来存取数据,在读取上非常快,通常用来存储临时内容,作为缓存使用。常见的键值对DBMS有Redis、Riak等,其中Redis不仅可以管理键值对数据库,还可以作为缓存后端(cache backend)和消息代理(message broker)。

另外,还有列存储(column store,又被称为宽列式存储)、图存储
(graph store)等类型的NoSQL数据库,这里不再展开介绍。

5.1.3 如何选择?

NoSQL数据库不需要定义表和列等结构,也不限定存储的数据格式,在存储方式上比较灵活,在特定的场景下效率更高。SQL数据库稍显复杂,但不容易出错,能够适应大部分的应用场景。这两种数据库都各有优势,也各有擅长的领域。两者并不是对立的,我们需要根据使用场景选择适合的数据库类型。大型项目通常会同时需要多种数据库,比如使用MySQL作为主数据库存储用户资料和文章,使用Redis(键值对型数据库)缓存数据,使用MongoDB(文档型数据库)存储实时消息

大多数情况下,SQL数据库都能满足你的需求。为了便于开发和测试,本文中的示例程序都使用SQLite作为DBMS。对于大型程序,在部署程序前,你需要根据程序的特点来改用更健壮的DBMS。

5.2 ORM魔法

在Web应用里使用原生SQL语句操作数据库主要存在下面两类问题:

  • ·手动编写SQL语句比较乏味,而且视图函数中加入太多SQL语句会降低代码的易读性。另外还会容易出现安全问题,比如SQL注入。
  • ·常见的开发模式是在开发时使用简单的SQLite,而在部署时切换到MySQL等更健壮的DBMS。但是对于不同的DBMS,我们需要使用不同的Python接口库,这让DBMS的切换变得不太容易。

尽管ORM非常方便,但如果你对SQL相当熟悉,那么自己编写SQL代码可以获得更大的灵活性和性能优势。就像是使用IDE一样,ORM对初学者来说非常方便,但进阶以后你也许会想要自己掌控一切。

ORM把底层的SQL数据实体转化成高层的Python对象,这样一来,你甚至不需要了解SQL,只需要通过Python代码即可完成数据库操作,ORM主要实现了三层映射关系:

  • ·表→Python类。
  • ·字段(列)→类属性。
  • ·记录(行)→类实例。

比如,我们要创建一个contacts表来存储留言,其中包含用户名称和电话号码两个字段。在SQL中,下面的代码用来创建这个表:

CREATE TABLE contacts(
   name varchar(100) NOT NULL,
   phone_number varchar(32),
);

如果使用ORM,我们可以使用类似下面的Python类来定义这个表:

from foo_orm import Model, Column, String

class Contact(Model):
   __tablename__ = 'contacts'
   name = Column(String(100), nullable=False)
   phone_number = Column(String(32))

要向表中插入一条记录,需要使用下面的SQL语句:

INSERT INTO contacts(name, phone_number)
VALUES('Grey Li', '12345678');

使用ORM则只需要创建一个Contact类的实例,传入对应的参数表示各个列的数据即可。下面的代码和使用上面的SQL语句效果相同:

contact = Contact(name='Grey Li', phone_number='12345678')

除了便于使用,ORM还有下面这些优点:

  • ·灵活性好。你既能使用高层对象来操作数据库,又支持执行原生
    SQL语句。
  • ·提升效率。从高层对象转换成原生SQL会牺牲一些性能,但这微
    不足道的性能牺牲换取的是巨大的效率提升。
  • ·可移植性好。ORM通常支持多种DBMS,包括MySQL、PostgreSQL、Oracle、SQLite等。你可以随意更换DBMS,只需要稍微改动少量配置。

使用Python实现的ORM有SQLAlchemy、Peewee、PonyORM等。其中SQLAlchemy是Python社区使用最广泛的ORM之一,我们将介绍如何在Flask程序中使用它。SQL-Alchemy,直译过来就是SQL炼金术,下一节我们会见识到SQLAlchemy的神奇力量。

5.3 使用Flask-SQLAlchemy管理数据库

扩展Flask-SQLAlchemy集成了SQLAlchemy,它简化了连接数据库服务器、管理数据库操作会话等各类工作,让Flask中的数据处理体验变得更加轻松。首先使用Pipenv安装Flask-SQLAlchemy及其依赖(主要是SQLAlchemy):

$ pipenv install flask-sqlalchemy

下面在示例程序中实例化Flask-SQLAlchemy提供的SQLAlchemy类,传入程序实例app,以完成扩展的初始化:

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
db = SQLAlchemy(app)

为了便于使用,我们把实例化扩展类的对象命名为db。这个db对象代表我们的数据库,它可以使用Flask-SQLAlchemy提供的所有功能.

5.3.1 连接数据库服务器

DBMS通常会提供数据库服务器运行在操作系统中。要连接数据库服务器,首先要为我们的程序指定数据库URI(Uniform ResourceIdentifier,统一资源标识符)。数据库URI是一串包含各种属性的字符串,其中包含了各种用于连接数据库的信息。

  • 附注:
    URI代表统一资源标识符,是用来标示资源的一组字符串。URL是它的子集。在大多数情况下,这两者可以交替使用。

表5-2是一些常用的DBMS及其数据库URI格式示例。

表5-2 常用的数据库URI格式

在这里插入图片描述
在Flask-SQLAlchemy中,数据库的URI通过配置变量SQLALCHEMY_DATABASE_URI设置,默认为SQLite内存型数据库(sqlite:///:memory:)。SQLite是基于文件的DBMS,不需要设置数据库服务器,只需要指定数据库文件的绝对路径。我们使用app.root_path来定位数据库文件的路径,并将数据库文件命名为data.db,如代码清单5-1所示。

代码清单5-1 app.py:配置数据库URI
import os
...
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', prefix + os.path.join(app.root_path, 'data.db'))

在生产环境下更换到其他类型的DBMS时,数据库URL会包含敏感信息,所以这里优先从环境变量DATABASE_URL获取(注意这里为了便于理解使用了URL,而不是URI)。

  • 注意
    SQLite的数据库URI在Linux或macOS系统下的斜线数量是4个;在
    Windows系统下的URI中的斜线数量为3个。内存型数据库的斜线固定为
    3个。
  • 提示
    SQLite数据库文件名不限定后缀,常用的命名方式有foo.sqlite,foo.db,或是注明SQLite版本的foo.sqlite3。

设置好数据库URI后,在Python Shell中导入并查看db对象会获得下面的输出:

>>> from app import db
>>> db
<SQLAlchemy engine=sqlite:///Path/to/your/data.db>

安装并初始化Flask-SQLAlchemy后,启动程序时会看到命令行下有一行警告信息。这是因为Flask-SQLAlchemy建议你设置SQLALCHEMY_TRACK_MODIFICATIONS配置变量,这个配置变量决定是否追踪对象的修改,这用于Flask-SQLAlchemy的事件通知系统。这个配置键的默认值为None,如果没有特殊需要,我们可以把它设为False来关闭警告信息:

app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
  • 附注
    Flask-SQLAlchemy计划在3.0版本默认将这个配置键设为False,目
    前最新版本为2.5.1。

5.3.2 定义数据库模型

用来映射到数据库表的Python类通常被称为数据库模型(model),一个数据库模型类对应数据库中的一个表。定义模型即使用Python类定义表模式,并声明映射关系。所有的模型类都需要继承Flask-SQLAlchemy提供的db.Model基类。本章的示例程序是一个笔记程序,笔记保存到数据库中,你可以通过程序查询、添加、更新和删除笔记。在代码清单5-2中,我们定义了一个Note模型类,用来存储笔记。

代码清单5-2 app.py:定义Note模型
class Note(db.Model):
	id = db.Column(db.Integer, primary_key=True)
	body = db.Column(db.Text)

在上面的模型类中,表的字段(列)由db.Column类的实例表示,字段的类型通过Column类构造方法的第一个参数传入。在这个模型中,我们创建了一个类型为db.Integer的id字段和类型为db.Text的body列,分别存储整型和文本。常用的SQLAlchemy字段类型如表5-3所示。

表5-3 SQLAlchemy常用的字段类型

在这里插入图片描述

字段类型一般直接声明即可,如果需要传入参数,你也可以添加括号。对于类似String的字符串列,有些数据库会要求限定长度,因此最好为其指定长度。虽然使用Text类型可以存储相对灵活的变长文本,但从性能上考虑,我们仅在必须的情况下使用Text类型,比如用户发表的文章和评论等不限长度的内容。

当你在数据库模型类中限制了字段的长度后,在接收对应数据的表单类字段里,也需要使用Length验证器来验证用户的输入数据。默认情况下,Flask-SQLAlchemy会根据模型类的名称生成一个表名称,生成规则如下:

Message --> message # 单个单词转换为小写
FooBar --> foo_bar # 多个单词转换为小写并使用下划线分隔

Note类对应的表名称即note。如果你想自己指定表名称,可以通过定义__tablename__属性来实现。字段名默认为类属性名,你也可以通过字段类构造方法的第一个参数指定,或使用关键字name。根据我们定义的Note模型类,最终将生成一个note表,表中包含id和body字段。
除了name参数,实例化字段类时常用的字段参数如表5-4所示。

表5-4 常用的SQLAlchemy字段参数

在这里插入图片描述

  • 提示
    不需要在所有列都建立索引。一般来说,取值可能性多(比如姓名)的列,以及经常被用来作为排序参照的列(比如时间戳)更适合建立索引。

    在实例化字段类时,通过把参数primary_key设为True可以将其定义为主键。在我们定义的Note类中,id字段即表的主键(primary key)。主键是每一条记录(行)独一无二的标识,也是模型类中必须定义的字段,一般命名为id或pk。

5.3.3 创建数据库和表

如果把数据库(文件)看作一个仓库,为了方便取用,我们需要把货物按照类型分别放置在不同货架上,这些货架就是数据库中的表。创建模型类后,我们需要手动创建数据库和对应的表,也就是我们常说的建库和建表。这通过对我们的db对象调用create_all()方法实现:

$ flask shell
>>> from app import db
>>> db.create_all()

如果你将模型类定义在单独的模块中,那么必须在调用db.create_all()方法前导入相应模块,以便让SQLAlchemy获取模型类被创建时生成的表信息,进而正确生成数据表。
通过下面的方式可以查看模型对应的SQL模式(建表语句):

>>> from sqlalchemy.schema import CreateTable
>>>from app import Note
>>> print(CreateTable(Note.__table__))
CREATE TABLE note (
	id INTEGER NOT NULL,
	body TEXT,
	PRIMARY KEY (id)
)
  • 注意

数据库和表一旦创建后,之后对模型的改动不会自动作用到实际的表中。比如,在模型类中添加或删除字段,修改字段的名称和类型,这时再次调用create_all()也不会更新表结构。如果要使改动生效,最简单的方式是调用db.drop_all()方法删除数据库和表,然后再调用db.create_all()方法创建,后面会具体介绍。

我们也可以自己实现一个自定义flask命令完成这个工作,如代码清单5-3所示。
代码清单5-3 demos/database/app.py:用于创建数据库和表的flask
命令

import click
...
@app.cli.command()
def initdb():
  db.create_all()
  click.echo('Initialized database.')

在命令行下输入flask inintdb即可创建数据库和表:

$ flask initdb
Initialized database.

对于示例程序来说,这会在database目录下创建一个data.db文件。

  • 提示
  • 在开发程序或是部署后,我们经常需要在Python Shell中手动操作数
    据库(生产环境需注意备份),对于一次性操作,直接处理即可。对于
    需要重用的操作,我们可以编写成Flask命令、函数或是模型类的类方
    法。

5.4 数据库操作

现在我们创建了模型,也生成了数据库和表,是时候来学习常用的数据库操作了。数据库操作主要是CRUD,即Create(创建)、Read(读取/查询)、Update(更新)和Delete(删除)

SQLAlchemy使用数据库会话来管理数据库操作,这里的数据库会话也称为事务(transaction)Flask-SQLAlchemy自动帮我们创建会话,可以通过db.session属性获取。

  • 注意:
    SQLAlchemy中的数据库会话对象和我们在前面介绍的Flask中的session无关。
    数据库中的会话代表一个临时存储区,你对数据库做出的改动都会存放在这里。你可以调用add()方法将新创建的对象添加到数据库会话中,或是对会话中的对象进行更新。只有当你对数据库会话对象调用commit()方法时,改动才被提交到数据库,这确保了数据提交的一致性。另外,数据库会话也支持回滚操作。当你对会话调用rollback()方法时,添加到会话中且未提交的改动都将被撤销。

5.4.1 CRUD

这一节我们会在Python Shell中演示CRUD操作。默认情况下,Flask-SQLAlchemy(>=2.3.0版本)会自动为模型类生成一个__repr__()方法。当在Python Shell中调用模型的对象时,__repr__()方法会返回一条类似“<模型类名主键值>”的字符串,比如<Note 2>。为了便于实际操作测试,示例程序中,所有的模型类都重新定义了__repr__()方法,返回一些更有用的信息,比如:

# Models
class Note(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    body = db.Column(db.Text)

    # optional
    def __repr__(self):
        return '<Note %r>' % self.body

1.Create
添加一条新记录到数据库主要分为三步:

  • 1)创建Python对象(实例化模型类)作为一条记录。
  • 2)添加新创建的记录到数据库会话。
  • 3)提交数据库会话。

下面的示例向数据库中添加了三条留言:

>>> from app import db, Note
>>> note1 = Note(body='remember Sammy Jankis')
>>> note2 = Note(body='SHAVE')
>>> note3 = Note(body="DON'T BELIEVE HIS LIES, HE IS THE ONE, KILL HIM")
>>> db.session.add(note1)
>>> db.session.add(note2)
>>> db.session.add(note3)
>>> db.session.commit()

在这个示例中,我们首先从app模块导入db对象和Note类,然后分别创建三个Note实例表示三条记录,使用关键字参数传入字段数据。我们的Note类继承自db.Model基类,db.Model基类会为Note类提供一个构造函数,接收匹配类属性名称的参数值,并赋值给对应的类属性,所以我们不需要自己在Note类中定义构造方法。接着我们调用``add()方法把这三个Note对象添加到会话对象db.session中,最后调用commit()```方法提交会话。

  • 提示:
    除了依次调用add()方法添加多个记录,也可以使用···add_all()···一次添包含所有记录对象的列表。

    你可能注意到了,我们在创建模型类实例的时候并没有定义id字段的数据,这是因为主键由SQLAlchemy管理。模型类对象创建后作为临时对象(transient),当你提交数据库会话后,模型类对象才会转换为数据库记录写入数据库中,这时模型类对象会自动获得id值:

**>>> note1.id
1

2.Read
我们已经知道了如何向数据库里添加记录,那么如何从数据库里取回数据呢?使用模型类提供的query属性附加调用各种过滤方法及查询方法可以完成这个任务。
一般来说,一个完整的查询遵循下面的模式:

<模型类>.query.<过滤方法>.<查询方法>

从某个模型类出发,通过在query属性对应的Query对象上附加的过滤方法和查询函数对模型类对应的表中的记录进行各种筛选和调整,最终返回包含对应数据库记录数据的模型类实例,对返回的实例调用属性即可获取对应的字段数据。
如果你执行了上面小节里的操作,我们的数据库现在一共会有三条记录,如表5-5所示。

表5-5 note表示意

在这里插入图片描述
SQLAlchemy提供了许多查询方法用来获取记录,表5-6列出了常用的查询方法。

表5-6 常用的SQLAlchemy查询方法

在这里插入图片描述
在这里插入图片描述
表5-6中的first_or_404()、get_or_404()以及paginate()方法是Flask-SQLAlchemy附加的查询方法。
下面是对Note类进行查询的几个示例。all()返回所有记录:

>>> Note.query.all()
[<Note u'remember Sammy Jankis'>, <Note u'SHAVE'>, <Note u'DON'T BELIEVE HIS LIES, HE IS THE ONE, KILL HIM']

first()返回第一条记录:

>>> note1 = Note.query.first()
>>> note1
<Note u'remember Sammy Jankis'>
>>> note1.body
u'remember Sammy Jankis'

get()返回指定主键值(id字段)的记录:

>>> note2 = Note.query.get(2)
>>> note2
<Note u'SHAVE'>

count()返回记录的数量:

SQLAlchemy还提供了许多过滤方法,使用这些过滤方法可以获取更精确的查询,比如获取指定字段值的记录。对模型类的query属性存储的Query对象调用过滤方法将返回一个更精确的Query对象(后面我们简称为查询对象)。因为每个过滤方法都会返回新的查询对象,所以过滤器可以叠加使用。在查询对象上调用前面介绍的查询方法,即可获得一个包含过滤后的记录的列表。常用的查询过滤方法如表5-7所示。

表5-7 常用的SQLAlchemy过滤方法

在这里插入图片描述
filter()方法是最基础的查询方法。它使用指定的规则来过滤记录,下面的示例在数据库里找出了body字段值为“SHAVE”的记录:

>>> Note.query.filter(Note.body='SHAVE').first()
<Note u'SHAVE'>

filter()方法是最基础的查询方法。它使用指定的规则来过滤记录,下面的示例在数据库里找出了body字段值为“SHAVE”的记录:

>>> Note.query.filter(Note.body=='SHAVE').first()
<Note u'SHAVE'>

直接打印查询对象或将其转换为字符串可以查看对应的SQL语句:

>>> print(Note.query.filter_by(body='SHAVE'))
SELECT note.id AS note_id, note.body AS note_body
FROM note
WHERE note.body = ?

在``filter()方法中传入表达式时,除了“==”以及表示不等于的“!=”```,其他常用的查询操作符以及使用示例如下所示:

#LIKE
filter(Note.body.like('%foo%'))

#IN
filter(Note.body.in_(['foo', 'bar', 'baz']))

#NOT IN
filter(~Note.body.in_(['foo', 'bar', 'baz']))

#AND
# 使用and_()
from sqlalchemy import and_
filter(and_(Note.body == 'foo', Note.title == 'FooBar'))
# 或在filter()中加入多个表达式,使用逗号分隔
filter(Note.body == 'foo', Note.title == 'FooBar')
# 或叠加调用多个filter()/filter_by()方法
filter(Note.body == 'foo').filter(Note.title == 'FooBar')

#OR:
from sqlalchemy import or_
filter(or_(Note.body == 'foo', Note.body == 'bar'))

完整的可用操作符列表可以访问
http://docs.sqlalchemy.org/en/latest/core/sqlelement.html#sqlalchemy.sql.operators.ColumnOperators查看。
filter()方法相比,filter_by()方法更易于使用。在filter_by()方法中,你可以使用关键字表达式来指定过滤规则。更方便的是,你可以在这个过滤器中直接使用字段名称。下面的示例使用filter_by()过滤器完成了同样的任务:

>>> Note.query.filter_by(body='SHAVE').first()
<Note u'SHAVE'>

3.Update
更新一条记录非常简单,直接赋值给模型类的字段属性就可以改变字段值,然后调用commit()方法提交会话即可。下面的示例改变了一条记录的body字段的值:

>>> note = Note.query.get(2)
>>> note.body
u'SHAVE'
>>> note.body = 'SHAVE LEFT THIGH'
>>> db.session.commit()
>>> Note.query.all()
[<Note 'remember Sammy Jankis'>,
 <Note 'SHAVE LEFT THIGH'>,
 <Note "DON'T BELIEVE HIS LIES, HE IS THE ONE, KILL HIM">]

只有要插入新的记录或要将现有的记录添加到会话中时才需要使用add()方法,单纯要更新现有的记录时只需要直接为属性赋新值,然后提交会话。
4.Delete
删除记录和添加记录很相似,不过要把add()方法换成delete()方法,最后都需要调用commit()方法提交修改。下面的示例删除了id(主键)为2的记录:

>>> note = Note.query.get(2)
>>> db.session.delete(note)
>>> db.session.commit()
>>> Note.query.all()
[<Note 'remember Sammy Jankis'>,
 <Note "DON'T BELIEVE HIS LIES, HE IS THE ONE, KILL HIM">]

5.4.2 在视图函数里操作数据库

在视图函数里操作数据库的方式和我们在Python Shell中的练习大致相同,只不过需要一些额外的工作。比如把查询结果作为参数传入模板渲染出来,或是获取表单的字段值作为提交到数据库的数据。在这一节,我们将把上一节学习的所有数据库操作知识运用到一个简单的笔记程序中。这个程序可以让你创建、编辑和删除笔记,并在主页列出所有保存后的笔记。
1.Create
为了支持输入笔记内容,我们先创建一个用于填写新笔记的表单,如下所示:

from flask_wtf import FlaskForm
from wtforms import TextAreaField, SubmitField
from wtforms.validators import DataRequired

class NewNoteForm(FlaskForm):
	body = TextAreaField('Body', validators=[DataRequired()])
	submit = SubmitField('Save')

我们创建一个new_note视图,这个视图负责渲染创建笔记的模板,并处理表单的提交,如代码清单5-4所示。

代码清单5-4 demos/database/app.py:创建新笔记
@app.route('/new', methods=['GET', 'POST'])
def new_note():
	form = NewNoteForm()
	if form.validate_on_submit():
		body = form.body.data
		note = Note(body=body)
		db.session.add(note)
		db.session.commit()
		flash('Your note is saved.')
		return redirect(url_for('index'))
	return render_template('new_note.html', form=form)

我们先来看看form.validate_on_submit()返回True时的处理代码。当表单被提交且通过验证时,我们获取表单body字段的数据,然后创建新的Note实例,将表单中body字段的值作为body参数传入,最后添加到数据库会话中并提交会话。这个过程接收用户通过表单提交的数据并保存到数据库中,最后我们使用flash()函数发送提示消息并重定向到index视图。

表单在new_note.html模板中渲染,这里使用我们在第4章介绍的form_field宏渲染表单字段,传入rowscols参数来定制<textarea>输入框的大小:

{% block content %}
<h2>New Note</h2>

<form method="post">
    {{ form.csrf_token }}
    {{ form_field(form.body, rows=5, cols=50) }}
    {{ form.submit }}<br>
</form>
{% endblock %}

index视图用来显示主页,目前它的所有作用就是渲染主页对应的模板:

@app.route('/')
def index():
	return render_template('index.html')

在对应的index.html模板中,我们添加一个指向创建新笔记页面的链接:

<h1>Notebook</h1>
<a href="{{ url_for('new_note') }}">New Note</a>

2.Read
在上一节我们为程序实现了添加新笔记的功能,当你在创建笔记的页面单击保存后,程序会重定向到主页,提示的消息告诉你刚刚提交的笔记已经成功保存了,可是你却无法看到创建后的笔记。为了在主页列出所有保存的笔记,我们需要修改index视图,修改后的index视图如代码清单5-5所示。
代码清单5-5 demos/database/app.py:在视图函数中查询数据库记录并传入模板

class DeleteNoteForm(FlaskForm):
    submit = SubmitField('Delete')

@app.route('/')
def index():
	form = DeleteNoteForm()
	notes = Note.query.all()
	return render_template('index.html', notes=notes, form=form)

在新的index视图里,我们像在Python Shell中一样使用Note.query.all()查询所有note记录,然后把这个包含所有记录的列表作为notes变量传入模板。你已经猜到下一步了,没错,我们将在模板中将笔记们显示出来,如代码清单5-6所示。

代码清单5-6 demos/database/templates/index.html:在模板中渲染数据库记录

<h1>Notebook</h1>
<a href="{{ url_for('new_note') }}">New Note</a>
<h4>{{ notes|length }} notes:</h4>
{% for note in notes %}
	<div class="note">
		<p>{{ note.body }}</p>
	</div>
{% endfor %}

在模板中,我们迭代这个notes列表,调用Note对象的body属性(note.body)获取body字段的值。另外,我们还通过length过滤器获取笔记的数量。渲染后的示例如图5-1所示。
在这里插入图片描述

图5-1 显示笔记列表

3.Update

class EditNoteForm(FlaskForm):
	body = TextAreaField('Body', validators=[DataRequired()])
	submit = SubmitField('Update')

你会发现这和创建新笔记NewNoteForm唯一的不同就是提交字段的标签参数(作为<input>的value属性),因此这个表单的定义也可以通过继承来简化:

class EditNoteForm(NewNoteForm):
	submit = SubmitField('Update')

用来渲染更新笔记页面和处理更新表单提交的edit_note视图如代码清单5-7所示。代码清单5-7 database/app.py:更新笔记内容:

@app.route('/edit/<int:note_id>', methods=['GET', 'POST'])
def edit_note(note_id):
    form = EditNoteForm()
    note = Note.query.get(note_id)
    if form.validate_on_submit():
        note.body = form.body.data
        db.session.commit()
        flash('Your note is updated.')
        return redirect(url_for('index'))
    form.body.data = note.body  # preset form input's value
    return render_template('edit_note.html', form=form)

这个视图通过URL变量note_id获取要被修改的笔记的主键值(id字段),然后我们就可以使用get()方法获取对应的Note实例。当表单被提交且通过验证时,我们将表单中body字段的值赋给note对象的body属性,然后提交数据库会话,这样就完成了更新操作。和创建笔记相同,我们接着发送提示消息并重定向到index视图。唯一需要注意的是,在GET请求的执行流程中,我们添加了下面这行代码:

form.body.data = note.body

因为要添加修改笔记内容的功能,那么当我们打开修改某个笔记的页面时,这个页面的表单中必然要包含笔记原有的内容。

如果手动创建HTML表单,那么你可以通过将note记录传入模板,然后手动为对应字段中填入笔记的原有内容,比如:

<textarea name="body">{{ note.body }}</textarea>

其他input元素则通过value属性来设置输入框中的值,比如:

`<input name="foo" type="text" value="{{ note.title }}">

使用WTForms可以省略这些步骤,当我们渲染表单字段时,如果表单字段的data属性不为空,WTForms会自动把data属性的值添加到表单字段的value属性中,作为表单的值填充进去,我们不用手动为value属性赋值。因此,将存储笔记原有内容的note.body属性赋值给表单body字段的data属性即可在页面上的表单中填入原有的内容。模板的内容基本相同,这里不再赘述。最后的工作是在主页笔记列表中的每个笔记内容下添加一个编辑按钮,用来访问编辑页面:

{% for note in notes %}
<div class="note">
	<p>{{ note.body }}</p>
	<a class="btn" href="{{ url_for('edit_note', note_id=note.id) }}">Edit</a>
</div>
{% endfor %}

生成edit_note视图的URL时,我们传入当前note对象的id(note.id)作为URL变量note_id的值。
4.Delete
在程序中,删除的实现也非常简单,不过这里经常会有一个误区。大多数人通常会考虑在笔记内容下添加一个删除链接:

<a href="{{ url_for('delete_note', note_id=note.id) }}">Delete</a>

这个链接指向用来删除笔记的delete_note视图:

@app.route('/delete/<int:note_id>')
def delete_note(note_id):
	note = Note.query.get(note_id)
	db.session.delete(note)
	db.session.commit()
	flash('Your note is deleted.')
	return redirect(url_for('index'))

虽然这一切看起来都很合理,但这种处理方式实际上会使程序处于CSRF攻击的风险之中。我们在第2章曾强调过,防范CSRF攻击的基本原则就是正确使用GET和POST方法。像删除这类修改数据的操作绝对不能通过GET请求实现,正确的做法是为删除操作创建一个表单,如下所示:

class DeleteNoteForm(FlaskForm):
	submit = SubmitField('Delete')

这个表单类只有一个提交字段,因为我们只需要在页面上显示一个删除按钮来提交表单。删除表单的提交请求由delete_note视图处理,如代码清单5-8所示。

代码清单5-8 database/app.py:删除笔记
@app.route('/delete/<int:note_id>', methods=['POST'])
def delete_note(note_id):
    form = DeleteNoteForm()
    if form.validate_on_submit():
        note = Note.query.get(note_id)
        db.session.delete(note)
        db.session.commit()
        flash('Your note is deleted.')
    else:
        abort(400)
    return redirect(url_for('index'))
  • 注意:
    在delete_note视图的app.route()中,methods列表仅填入了POST,
    这会确保该视图仅监听POST请求。

和编辑笔记的视图类似,这个视图接收note_id(主键值)作为参数。如果提交表单且通过验证(唯一需要被验证的是CSRF令牌),就使用get()方法查询对应的记录,然后调用db.session.delete()方法删除并提交数据库会话。如果验证出错则使用abort()函数返回400错误响应。

因为删除按钮要在主页的笔记内容下添加,我们需要在index视图中实例化DeleteNote-Form类,然后传入模板。在index.html模板中,我们渲染这个表单:

{% for note in notes %}
    <div class="note">
        <p>{{ note.body }}</p>
        <a class='btn' href="{{ url_for('edit_note', note_id=note.id) }}">Edit</a>
        <form method="post" action="{{ url_for('delete_note', note_id=note.id) }}">
            {{ form.csrf_token }}
            {{ form.submit(class='btn') }}
        </form>
    </div>
{% endfor %}

我们将表单的action属性设置为删除当前笔记的URL。构建URL时,URL变量note_id的值通过note.id属性获取,当单击提交按钮时,会将请求发送到action属性中的URL。添加删除表单的主要目的就是防止CSRF攻击,所以不要忘记渲染CSRF令牌字段form.csrf_token。

  • 提示

在HTML中,<a>标签会显示为链接,而提交按钮会显示为按钮,为了让编辑和删除笔记的按钮显示相同的样式,我们为这两个元素使用了同一个CSS类“.btn”,具体可以在static/style.css文件中查看。作为替代,你可以考虑使用JavaScript创建监听函数,当删除按钮按下时,提交对应的隐藏表单。

如果你运行了示例程序,请访问http://localhost:5000打开示例程序的主页,你可以体验我们在这一节实现的所有功能。最终的程序主页如图5-2所示。

5.5 定义关系

在关系型数据库中,我们可以通过关系让不同表之间的字段建立联系。一般来说,定义关系需要两步,分别是创建外键和定义关系属性。在更复杂的多对多关系中,我们还需要定义关联表来管理关系。这一节我们会学习如何使用SQLAlchemy在模型之间建立几种基础的关系模式。
在这里插入图片描述

5.5.1 配置Python Shell上下文

在上面的许多操作中,每一次使用flask shell命令启动Python Shell后都要从app模块里导入db对象和相应的模型类。为什么不把它们自动集成到Python Shell上下文里呢?就像Flask内置的app对象一样。这当然可以实现!我们可以使用app.shell_context_processor装饰器注册一个shell上下文处理函数。它和模板上下文处理函数一样,也需要返回包含变量和变量值的字典,如代码清单5-9所示。

代码清单5-9 app.py:注册shell上下文处理函数

# ...
@app.shell_context_processor
def make_shell_context():
	return dict(db=db, Note=Note) # 等同于{'db': db, 'Note': Note}

当你使用flask shell命令启动Python Shell时,所有使用app.shell_context_processor装饰器注册的shell上下文处理函数都会被自动执行,这会将db和Note对象推送到Python Shell上下文里:

$ pipenv run flask shell
>>> db
<SQLAlchemy engine=sqlite:///Path/to/your/data.db>
>>> Note
<class 'app.Note'>

在这一节演示各种数据库关系时,我们将编写更多的模型类。在示例程序中,它们都使用shell上下文处理函数添加到shell上下文中,因此你可以直接在Python Shell使用,不用手动导入。

5.5.2 一对多

我们将以作者和文章来演示一对多关系:一个作者可以写作多篇文章。一对多关系示意图如图5-3所示。
在这里插入图片描述

图5-3 一对多示意图
在示例程序中,Author类用来表示作者,Article类用来表示文章,如代码清单5-10所示。
代码清单5-10 database/app.py:一对多关系示例
# ...
class Author(db.Model):
	id = db.Column(db.Integer, primary_key=True)
	name = db.Column(db.String(70), unique=True)
	phone = db.Column(db.String(20))
class Article(db.Model):
	id = db.Column(db.Integer, primary_key=True)
	title = db.Column(db.String(50), index=True)
	body = db.Column(db.Text)

我们将在这两个模型之间建立一个简单的一对多关系,建立这个一对多关系的目的是在表示作者的Author类中添加一个关系属性articles,作为集合(collection)属性,当我们对特定的Author对象调用articles属性会返回所有相关的Article对象。我们会在下面介绍如何一步步定义这个一对多关系。

1.定义外键
定义关系的第一步是创建外键。外键是(foreign key)用来在A表存储B表的主键值以便和B表建立联系的关系字段。因为外键只能存储单一数据(标量),所以外键总是在“多”这一侧定义,多篇文章属于同一个作者,所以我们需要为每篇文章添加外键存储作者的主键值以指向对应的作者。在Article模型中,我们定义一个author_id字段作为外键:

class Article(db.Model):
	...
	author_id = db.Column(db.Integer, db.ForeignKey('author.id'))

这个字段使用db.ForeignKey类定义为外键,传入关系另一侧的表名和主键字段名,即author.id。实际的效果是将article表的author_id的值限制为author表的id列的值。它将用来存储author表中记录的主键值,如图5-4所示。

在这里插入图片描述
提示:
外键字段的命名没有限制,因为要连接的目标字段是author表的id列,所以为了便于区分而将这个外键字段的名称命名为author_id

注意:

传入ForeignKey类的参数author.id,其中author指的是Author模型对应的表名称,而id指的是字段名,即“表名.字段名”。模型类对应的表名由Flask-SQLlchemy生成,默认为类名称的小写形式,多个单词通过下划线分隔,你也可以显式地通过__tablename__属性自己指定,后面不再提示。

2.定义关系属性
定义关系的第二步是使用关系函数定义关系属性。关系属性在关系的出发侧定义,即一对多关系的“一”这一侧。一个作者拥有多篇文章,在Author模型中,我们定义了一个articles属性来表示对应的多篇文章:

class Author(db.Model):
	...
	articles = db.relationship('Article')	

这个属性并没有使用Column类声明为列,而是使用了db.relationship()关系函数定义为关系属性,因为这个关系属性返回多个记录,我们称之为集合关系属性。relationship()函数的第一个参数为关系另一侧的模型名称,它会告诉SQLAlchemy将Author类与Article类建立关系。当这个关系属性被调用时,SQLAlchemy会找到关系另一侧(即article表)的外键字段(即author_id),然后反向查询article表中所有author_id值为当前表主键值(即author.id)的记录,返回包含这些记录的列表,也就是返回某个作者对应的多篇文章记录。

下面我们会在Python Shell中演示如何对实际的对象建立关系。我们先创建一个作者记录和两个文章记录,并添加到数据库会话中:

>>> foo = Author(name='Foo')
>>> spam = Article(title='Spam')
>>> ham = Article(title='Ham')
>>> db.session.add(foo)
>>> db.session.add(spam)
>>> db.session.add(ham)

3.建立关系
建立关系有两种方式,第一种方式是为外键字段赋值,比如:

>>> spam.author_id = 1
>>>> hanm.author_id = 1
>>> db.session.commit()

我们将spam对象的author_id字段的值设为1,这会和id值为1的Author对象建立关系。提交数据库改动后,如果我们对id为1的foo对象调用articles关系属性,会看到spam对象包括在返回的Article对象列表中:

>>> foo.articles
[<Article u'Spam'>, <Article u'Ham'>]

另一种方式是通过操作关系属性,将关系属性赋给实际的对象即可建立关系。集合关系属性可以像列表一样操作,调用append()方法来与一个Article对象建立关系:

>>> foo.articles.append(spam)
>>> foo.articles.append(ham)
>>> db.session.commit()

我们也可以直接将关系属性赋值给一个包含Article对象的列表。和前面的第一种方式类似,为了让改动生效,我们需要调用db.session.commit()方法提交数据库会话。建立关系后,存储外键的author_id字段会自动获得正确的值,而调用Author实例的关系属性articles时,会获得所有建立关系的Article对象:

>>> spam.author_id
1 
>>> foo.articles
[<Article u'Spam'>, <Article u'Ham'>]

和append()相对,对关系属性调用remove()方法可以与对应的Aritcle对象解除关系:

>>> foo.articles.remove(spam)
>>> db.session.commit()
>>> foo.articles
[<Article u'Ham'>]

你也可以使用pop()方法操作关系属性,它会与关系属性对应的列表的最后一个Aritcle对象解除关系并返回该对象。

不要忘记在操作结束后需要调用commit()方法提交数据库会话,这样才可以把改动写入数据库。

在关系函数中,有很多参数可以用来设置调用关系属性进行查询时的具体行为。常用的关系函数参数如表5-8所示。

表5-8 常用的SQLAlchemy关系函数参数
![在这里插入图片描述](https://img-blog.csdnimg.cn/263269ce23204b9eb05cd7d7438bc742.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5rKn5rW35LqM6Ziz,size_20,color_FFFFFF,t_70,g_se,x_16) 当关系属性被调用时,关系函数会加载相应的记录,表5-9列出了控制关系记录加载方式的lazy参数的常用选项。
表5-9 常用的SQLAlchemy关系记录加载方式(lazy参数可选值)

在这里插入图片描述
dynamic选项仅用于集合关系属性,不可用于多对一、一对一或是在关系函数中将uselist参数设为False的情况。

  • 注意
    许多教程和示例使用dynamic来动态加载所有集合关系属性对应的记录,这是应该避免的行为。使用dynamic加载方式意味着每次操作关系都会执行一次SQL查询,这会造成潜在的性能问题。大多数情况下我们只需要使用默认值(select),只有在调用关系属性会返回大量记录,并且总是需要对关系属性返回的结果附加额外的查询时才需要使用动态加载(lazy=‘dynamic’)。

4.建立双向关系
我们在Author类中定义了集合关系属性articles,用来获取某个作者拥有的多篇文章记录。在某些情况下,你也许希望能在Article类中定义一个类似的author关系属性,当被调用时返回对应的作者记录,这类返回单个值的关系属性被称为标量关系属性。而这种两侧都添加关系属性获取对方记录的关系我们称之为双向关系(bidirectional relationship)。

双向关系并不是必须的,但在某些情况下会非常方便。双向关系的建立很简单,通过在关系的另一侧也创建一个relationship()函数,我们就可以在两个表之间建立双向关系。我们使用作家(Writer)和书(Book)的一对多关系来进行演示,建立双向关系后的Writer和Book类如代码清单5-11所示。

代码清单5-11 database/app.py:基于一对多关系的双向关系
class Writer(db.Model):
	id = db.Column(db.Integer, primary_key=True)
	name = db.Column(db.String(70), unique=True)
	books = db.relationship('Book', back_populates='writer')
	
class Book(db.Model):
	id = db.Column(db.Integer, primary_key=True)
	title = db.Column(db.String(50), index=True)
	writer_id = db.Column(db.Integer, db.ForeignKey('writer.id'))
	writer = db.relationship('Writer', back_populates='books')

在“多”这一侧的Book(书)类中,我们新创建了一个writer关系属性,这是一个标量关系属性,调用它会获取对应的Writer(作者)记录;而在Writer(作者)类中的books属性则用来获取对应的多个Book(书)记录。在关系函数中,我们使用back_populates参数来连接对方,back_populates参数的值需要设为关系另一侧的关系属性名。为了方便演示,我们先创建1个Writer和2个Book记录,并添加到数据库中:

>>> carrie = Book(name='Carrie')
>>> it = Book(name='IT')
>>> db.session.add(king)
>>> db.session.add(carrie)
>>> db.session.add(it)
>>> db.session.commit()

设置双向关系后,除了通过集合属性books来操作关系,我们也可以使用标量属性writer来进行关系操作。比如,将一个Writer对象赋值给某个Book对象的writer属性,就会和这个Book对象建立关系:

>>> carrie.writer = king
>>> carrie.writer
<Writer u'Stephen King'>
>>> king.books
[<Book u'Carrie'>]
>>> it.writer = writer
>>> king.books
[<Book u'Carrie'>, <Book u'IT'>]

相对的,将某个Book的writer属性设为None,就会解除与对应Writer对象的关系:

>>> carrie.writer = None
>>> king.books
[<Book u'IT'>]
>>> db.session.commit()

需要注意的是,我们只需要在关系的一侧操作关系。当为Book对象的writer属性赋值后,对应Writer对象的books属性的返回值也会自动包含这个Book对象。反之,当某个Writer对象被删除时,对应的Book对象的writer属性被调用时的返回值也会被置为空(即NULL,会返回None)。

5.使用backref简化关系定义

在介绍关系函数的参数时,我们曾提到过,使用关系函数中的backref参数可以简化双向关系的定义。以一对多关系为例,backref参数用来自动为关系另一侧添加关系属性,作为反向引用(backreference),赋予的值会作为关系另一侧的关系属性名称。比如,我们在Author一侧的关系函数中将backref参数设为author,SQLAlchemy会自动为Article类添加一个author属性。为了避免和前面的示例命名冲突,我们使用歌手(Singer)和歌曲(Song)的一对多关系作为演示,分别创建Singer和Song类,如代码清单5-12所示。

代码清单5-12 database/app.py:使用backref建立双向关系
class Singer(db.Model):
	id = db.Column(db.Integer, primary_key=True)
	name = db.Column(db.String(70), unique=True)
	songs = db.relationship('Song', backref='singer')
class Song(db.Model):
	id = db.Column(db.Integer, primary_key=True)
	name = db.Column(db.String(50), index=True)
	singer_id = db.Column(db.Integer, db.ForeignKey('singer.id'))

在定义集合属性songs的关系函数中,我们将backref参数设为singer,这会同时在Song类中添加了一个singer标量属性。这时我们仅需要定义一个关系函数,虽然singer是一个“看不见的关系属性”,但在使用上和定义两个关系函数并使用back_populates参数的效果完全相同。

需要注意的是,使用backref允许我们仅在关系一侧定义另一侧的关系属性,但是在某些情况下,我们希望可以对在关系另一侧的关系属性进行设置,这时就需要使用backref()函数。backref()函数接收第一个参数作为在关系另一侧添加的关系属性名,其他关键字参数会作为关系另一侧关系函数的参数传入。比如,我们要在关系另一侧“看不见的relationship()函数”中将uselist参数设为False,可以这样实现:

class Singer(db.Model):
...
songs = relationship('Song', backref=backref('singer', uselist=False))
  • 注意
    尽管使用backref非常方便,但通常来说“显式好过隐式”,所以我们应该尽量使用back_populates定义双向关系。为了便于理解,在本书的示例程序中都将使用back_populates来建立双向关系。

5.5.3 多对一

一对多关系反过来就是多对一关系,这两种关系模式分别从不同的视角出发。一个作者拥有多篇文章,反过来就是多篇文章属于同一个作者。为了便于区分,我们使用居民和城市来演示多对一关系:多个居民居住在同一个城市。多对一关系如图5-5所示。
在这里插入图片描述

图5-5 多对一示意图

在示例程序中,Citizen类表示居民,City类表示城市。建立多对一关系后,我们将在Citizen类中创建一个标量关系属性city,调用它可以获取单个City对象。
我们在前面介绍过,关系属性在关系模式的出发侧定义。当出发点在“多”这一侧时,我们希望在Citizen类中添加一个关系属性city来获取对应的城市对象,因为这个关系属性返回单个值,我们称之为标量关系属性。在定义关系时,外键总是在“多”这一侧定义,所以在多对一关系中外键和关系属性都定义在“多”这一侧,即City类中,如代码清单5-13所示。

代码清单5-13 database/app.py:建立多对一关系
class Citizen(db.Model):
	id = db.Column(db.Integer, primary_key=True)
	name = db.Column(db.String(70), unique=True)
	city_id = db.Column(db.Integer, db.ForeignKey('city.id'))
	city = db.relationship('City')
	
class City(db.Model):
	id = db.Column(db.Integer, primary_key=True)
	name = db.Column(db.String(30), unique=True)

这时定义的city关系属性是一个标量属性(返回单一数据)。当Citizen.city被调用时,SQLAlchemy会根据外键字段city_id存储的值查找对应的City对象并返回,即居民记录对应的城市记录。

当建立双向关系时,如果不使用backref,那么一对多和多对一关系模式在定义上完全相同,这时可以将一对多和多对一视为同一种关系模式。在后面我们通常都会为一对多或多对一建立双向关系,这时将弱化这两种关系的区别,一律称为一对多关系。

5.5.4 一对一

我们将使用国家和首都来演示一对一关系:每个国家只有一个首都;反过来说,一个城市也只能作为一个国家的首都。一对一关系示意如图5-6所示。
在这里插入图片描述

图5-6 一对一关系示意图

一对一关系实际上是通过建立双向关系的一对多关系的基础上转化而来。我们要确保关系两侧的关系属性都是标量属性,都只返回单个值,所以要在定义集合属性的关系函数中将uselist参数设为False,这时一对多关系将被转换为一对一关系。代码清单5-14基于建立双向关系的一对多关系实现了一对一关系。

代码清单5-14 database/app.py:建立一对一关系

class Country(db.Model):
	id = db.Column(db.Integer, primary_key=True)
	name = db.Column(db.String(30), unique=True)
	capital = db.relationship('Capital', uselist=False)
	
class Capital(db.Model):
	id = db.Column(db.Integer, primary_key=True)
	name = db.Column(db.String(30), unique=True)
	country_id = db.Column(db.Integer, db.ForeignKey('country.id'))
	country = db.relationship('Country')

“多”这一侧本身就是标量关系属性,不用做任何改动。而“一”这一侧的集合关系属性,通过将uselist设为False后,将仅返回对应的单个记录,而且无法再使用列表语义操作:

>>> china = Country(name='China')
>>> beijing = Capital(name='Beijing')
>>> db.session.add(china)
>>> db.session.add(beijing)
>>> db.session.commit()
>>> china.capital = beijing
>>> china.capital
<Capital 1>
>>> beijing.country
u'China'
>>> tokyo = Capital(name'Tokyo')
>>> china.capital.append(tokyo)
Traceback (most recent call last):
File "<console>", line 1, in <module>
AttributeError: 'Capital' object has no attribute 'append'

5.5.5 多对多

我们将使用学生和老师来演示多对多关系:每个学生有多个老师,而每个老师有多个学生。多对多关系模式示意图如图5-7所示。

在这里插入图片描述

图5-7 多对多关系示意图

在示例程序中,Student类表示学生,Teacher类表示老师。在这两个模型之间建立多对多关系后,我们需要在Student类中添加一个集合关系属性teachers,调用它可以获取某个学生的多个老师,而不同的学生可以和同一个老师建立关系。

在一对多关系中,我们可以在“多”这一侧添加外键指向“一”这一侧,外键只能存储一个记录,但是在多对多关系中,每一个记录都可以与关系另一侧的多个记录建立关系,关系两侧的模型都需要存储一组外键。在SQLAlchemy中,要想表示多对多关系,除了关系两侧的模型外,我们还需要创建一个关联表(association table)。关联表不存储数据,只用来存储关系两侧模型的外键对应关系,如代码清单5-15所示。

代码清单5-15 database/app.py:建立多对多关系
# many to many with association table
association_table = db.Table('association',
                             db.Column('student_id', db.Integer, db.ForeignKey('student.id')),
                             db.Column('teacher_id', db.Integer, db.ForeignKey('teacher.id'))
                             )


class Student(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(70), unique=True)
    grade = db.Column(db.String(20))
    teachers = db.relationship('Teacher',
                               secondary=association_table,
                               back_populates='students')  # collection

    def __repr__(self):
        return '<Student %r>' % self.name


class Teacher(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(70), unique=True)
    office = db.Column(db.String(20))
    students = db.relationship('Student',
                               secondary=association_table,
                               back_populates='teachers')  # collection

关联表使用db.Table类定义,传入的第一个参数是关联表的名称。我们在关联表中定义了两个外键字段:teacher_id字段存储Teacher类的主键,student_id存储Student类的主键。借助关联表这个中间人存储的外键对,我们可以把多对多关系分化成两个一对多关系,如图5-8所示。

为了便于实现真正的多对多关系,我们需要建立双向关系。建立双向关系后,多对多关系会变得更加直观。在Student类上的teachers集合属性会返回所有关联的老师记录,而在Teacher类上的students集合属性会返回所有相关的学生记录:

除了在声明关系时有所不同,多对多关系模式在操作关系时和其他
关系模式基本相同。调用关系属性student.teachers时,SQLAlchemy会直
接返回关系另一侧的Teacher对象,而不是关联表记录,反之亦同。和其他关系模式中的集合关系属性一样,我们可以将关系属性teachers和students像列表一样操作。比如,当你需要为某一个学生添加老师时,对关系属性使用append()方法即可。如果你想要解除关系,那么可以使用remove()方法。

5.6 更新数据库表

模型类(表)不是一成不变的,当你添加了新的模型类,或是在模型类中添加了新的字段,甚至是修改了字段的名称或类型,都需要更新表。在前面我们把数据库表类比成盛放货物的货架,这些货架是固定生成的。当我们在操控程序(DBMS/ORM)上变更了货架的结构时,仓库的货架也要根据变化相应进行调整。而且,当货架的结构产生变动时,我们还需要考虑如何处理货架上的货物(数据)。

当你在数据库的模型中添加了一个新的字段后,比如在Note模型里添加了一个存储笔记创建时间的timestamp字段。这时你可能想要立刻启动程序看看效果,遗憾的是,你看到了下面的报错信息:

OperationalError: (sqlite3.OperationalError) no such column: note.timestamp [...]

这段错误消息指出note表中没有timestamp列,并在中括号里给出了查询所对应的SQL原语。之所以会出现这个错误,是因为数据库表并不会随着模型的修改而自动更新。想想我们之前关于仓库的比喻,仓库里来了一批新类型的货物,可我们还没为它们安排相应的货架,这当然要出错了。下面我们会学习如何更新数据库。

5.6.1 重新生成表

重新调用create_all()方法并不会起到更新表或重新创建表的用。如果你并不在意表中的数据,最简单的方法是使用drop_all()方法删除表以及其中的数据,然后再使用create_all()方法重新创建:

>>> db.drop_all()
>>> db.create_all()
  • 注意
    这会清除数据库里的原有数据,请勿在生产环境下使用。为了方便开发,我们修改initdb命令函数的内容,为其增加一个--drop选项来支持删除表和数据库后进行重建,如代码清单5-16所示。
代码清单5-16 database/app.py:支持删除表后重建
@app.cli.command()
@click.option('--drop', is_flag=True, help='Create after drop.')
def initdb(drop):
    """Initialize the database."""
    if drop:
        click.confirm('This operation will delete the database, do you want to continue?', abort=True)
        db.drop_all()
        click.echo('Drop tables.')
    db.create_all()
    click.echo('Initialized database.')

在这个命令函数前,我们使用click提供的option装饰器为命令添加了一个–drop选项,将is_flag参数设为True可以将这个选项声明为布尔值标志(boolean flag)。–drop选项的值作为drop参数传入命令函数,如果提供了这个选项,那么drop的值将是True,否则为False。因为添加–drop选项会直接清空数据库内容,如果需要,也可以通过click.confirm()函数添加一个确认提示,这样只有输入y或yes才会继续执行操作。

现在,执行下面的命令会重建数据库和表:

$ flask initdb --drop
  • 提示
    当使用SQLite时,直接删除data.db文件和调用drop_all()方法效果
    相同,而且更直接,不容易出错。

5.6.2 使用Flask-Migrate迁移数据库

在开发时,以删除表再重建的方式更新数据库简单直接,但明显的缺陷是会丢掉数据库中的所有数据。在生产环境下,你绝对不会想让数据库里的数据都被删除掉,这时你需要使用数据库迁移工具来完成这个工作。SQLAlchemy的开发者Michael Bayer写了一个数据库迁移工具——Alembic来帮助我们实现数据库的迁移,数据库迁移工具可以在不破坏数据的情况下更新数据库表的结构。蒸馏器(Alembic)是炼金术士最重要的工具,要学习SQL炼金术(SQLAlchemy),我们当然要掌握蒸馏器的使用。

扩展Flask-Migrate集成了Alembic,提供了一些flask命令来简化迁移工作,我们将使用它来迁移数据库。Flask-Migrate及其依赖(主要是Alembic)可以使用Pipenv安装:

$ pipenv install flask-migrate

在程序中,我们实例化Flask-Migrate提供的Migrate类,进行初始化操作:

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
app = Flask(__name__)
...
db = SQLAlchemy(app)
migrate = Migrate(app, db) # 在db对象创建后调用

实例化Migrate类时,除了传入程序实例app,还需要传入实例化Flask-SQLAlchemy提供的SQLAlchemy类创建的db对象作为第二个参数。

1.创建迁移环境
在开始迁移数据之前,需要先使用下面的命令创建一个迁移环境:

$ flask db init
  • 附注
    Flask-Migrate提供了一个命令集,使用db作为命名集名称,它提供的命令都以flask db开头。你可以在命令行中输入flask–help查看所有可用的命令和说明。

    迁移环境只需要创建一次。这会在你的项目根目录下创建一个migrations文件夹,其中包含了自动生成的配置文件和迁移版本文件夹。
    2.生成迁移脚本
    使用migrate子命令可以自动生成迁移脚本:

$ flask db migrate -m "add note timestamp"
...
INFO [alembic.autogenerate.compare] Detected added column 'message.timestamp
Generating /Path/to/your/database/migrations/versions/c52a02014635_add note_timestamp

这条命令可以简单理解为在flask里对数据库(db)进行迁移(migrate)。-m选项用来添加迁移备注信息。从上面的输出信息我们可以看到,Alembic检测出了模型的变化:表note新添加了一个timestamp列,并且相应生成了一个迁移脚本c52a02014635_add_note_timestamp.py,脚本的内容如代码:

"""add note timastamp
Revision ID: c52a02014635
"""
from alembic import op
import sqlalchemy as sa
# ...
def upgrade():
	# ### commands auto generated by Alembic - please adjust! ###
	op.add_column('note', sa.Column('timestamp', sa.DateTime(), nullable=True))
	# ### end Alembic commands ###
def downgrade():
	# ### commands auto generated by Alembic - please adjust! ###
	op.drop_column('note', 'timestamp')
	# ### end Alembic commands ###

从上面的代码可以看出,迁移脚本主要包含了两个函数:upgrade()函数用来将改动应用到数据库,函数中包含了向表中添加timestamp字段的命令;而downgrade()函数用来撤销改动,包含了删除timestamp字段的命令。
3.更新数据库
生成了迁移脚本后,使用upgrade子命令即可更新数据库:

>>> $ flask db upgrade
...
INFO [alembic.runtime.migration] Running upgrade -> c52a02014635, add note timestamp

如果还没有创建数据库和表,这个命令会自动创建;如果已经创建,则会在不损坏数据的前提下执行更新。

  • 提示
    如果你想回滚迁移,那么可以使用downgrade命令(降级),它会撤销最后一次迁移在数据库中的改动,这在开发时非常有用。比如,当你执行upgrade命令后发现某些地方出错了,这时就可以执行flask dbdowngrade命令进行回滚,删除对应的迁移脚本,重新生成迁移脚本后再进行更新(upgrade)

  • 注意

  • 虽然我们更新了数据库,但是之前创建的记录中并没有timestamp字段,所以这些记录的timestamp字段的值将为空。如果你需要为旧的数据添加默认的timestamp字段值,可以手动操作。本节只是对数据库迁移做一个简单的介绍,你可以阅读Alembic的文档了解更多用法和自定义选项,其中的入门教程(http://alembic.zzzcomputing.com/en/latest/tutorial.html)值得一读。

5.6.3 开发时是否需要迁移?

在生产环境下,当对数据库结构进行修改后,进行数据库迁移是必要的。因为你不想损坏任何数据,毕竟数据是无价的。在生成自动迁移脚本后,执行更新之前,对迁移脚本进行检查,甚至是使用备份的数据库进行迁移测试,都是有必要的。

而在开发环境中,你可以按需要选择是否进行数据迁移。对于大多数程序来说,我们可以在开发时使用虚拟数据生成工具来生成虚拟数据,从而避免手动创建记录进行测试。这样每次更改表结构时,可以直接清除后重新生成,然后生成测试数据,这要比执行一次迁移简单很多(在后面我们甚至会学习通过一条命令完成所有工作),除非生成虚拟
数据耗费的时间过长。

另外,在本地开发时通常使用SQLite作为数据库引擎。SQLite不支持ALTER语句,而这正是迁移工具依赖的工作机制。也就是说,当SQLite数据库表的字段删除或修改后,我们没法直接使用迁移工具进行更新,你需要手动添加迁移代码来进行迁移。在开发中,修改和删除列是很常见的行为,手动操作迁移会花费太多的时间。

  • 提示
    对于SQLite,迁移工具一般使用“move and copy”的工作流(创建新表、转移数据、删除旧表)达到类似的效果,具体可访问http://alembic.zzzcomputing.com/en/latest/batch.html了解。

    当然,这些仅仅是从方便的角度考虑,如果你希望让生产环境的部署更加高效,则应该尽可能让开发环境和生产环境保持一致。这时你应该考虑直接在本地使用MySQL或PostgreSQL等性能更高的DBMS,然后设置迁移环境。

5.7 数据库进阶实践

5.7.1 级联操作

Cascade意为“级联操作”,就是在操作一个对象的同时,对相关的对象也执行某些操作。我们通过一个Post模型和Comment模型来演示级联操作,分别表示文章(帖子)和评论,两者为一对多关系:

# cascade
class Post(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(50))
    body = db.Column(db.Text)
    comments = db.relationship('Comment', back_populates='post', cascade='all, delete-orphan')  # collection


class Comment(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    body = db.Column(db.Text)
    post_id = db.Column(db.Integer, db.ForeignKey('post.id'))
    post = db.relationship('Post', back_populates='comments')  # scalar

级联行为通过关系函数relationship()的cascade参数设置。我们希望在操作Post对象时,处于附属地位的Comment对象也被相应执行某些操作,这时应该在Post类的关系函数中定义级联参数。设置了cascade参数的一侧将被视为父对象,相关的对象则被视为子对象。

常用的配置组合如下所示:

  • ·save-update、merge(默认值)
  • ·save-update、merge、delete
  • ·all
  • ·all、delete-orphan

当没有设置cascade参数时,会使用默认值save-update、merge。上面的all等同于除了delete-orphan以外所有可用值的组合,即saveupdate、merge、refresh-expire、expunge、delete。下面我们会介绍常用的几个级联值:

1.save-update
save-update是默认的级联行为,当cascade参数设为save-update时,如果使用db.session.add()方法将Post对象添加到数据库会话时,那么与Post相关联的Comment对象也将被添加到数据库会话。我们首先创建一个Post对象和两个Comment对象:

>>> post1 = Post()
>>> comment1 =Comment()
>>> comment2 =Comment()

将post1添加到数据库会话后,只有post1在数据库会话中:

>>> db.session.add(post1)
>>> post1 in db.session
True
>>> comment1 in db.session
False
>>> comment2 in db.session
False

如果我们让post1与这两个Comment对象建立关系,那么这两个Comment对象也会自动被添加到数据库会话中:

>>> post1.comments.append(comment1)
>>> post1.comments.append(comment2)
>>> comment1 in db.session
True
>>> comment2 in db.session
True

2.delete
如果某个Post对象被删除,那么按照默认的行为,该Post对象相关联的所有Comment对象都将与这个Post对象取消关联,外键字段的值会被清空。如果Post类的关系函数中cascade参数设为delete时,这些相关的Comment会在关联的Post对象删除时被一并删除。当需要设置delete级联时,我们会将级联值设为all或save-update、merge、delete,比如:

class Post(db.Model):
	...
	comments = relationship('Comment', cascade='all')

我们先创建一个文章对象post2和两个评论对象comment3和comment4,并将这两个评论对象与文章对象建立关系,将它们添加到数据库会话并提交:

>>> post2 = Post()
>>> comment3 = Comment()
>>> comment4 = Comment()
>>> post2.comments.append(comment3)
>>> post2.comments.append(comment4)
>>> db.session.add(post2)
>>> db.session.commit()

现在共有两条Post记录和四条Comment记录:

>>> Post.query.all()
[<Post 1>, <Post 2>]
>>> Comment.query.all()
[<Comment 1>, <Comment 2>, <Comment 3>, <Comment 4>]

如果删除文章对象post2,那么对应的两个评论对象也会一并被删除:

>>> post2 = Post.quer2y.get(2)
>>> db.session.delete(post2)
>>> db.session.commit()
>>> Post.query.all()
[<Post 1>]
>>> Comment.query.all()
[<Comment 1>, <Comment 2>]

3.delete-orphan

这个模式是基于delete级联的,必须和delete级联一起使用,通常会设为all、delete-orphan,因为all包含delete。因此当cascade参数设为delete-orphan时,它首先包含delete级联的行为:当某个Post对象被除
时,所有相关的Comment对象都将被删除(delete级联)。除此之外,当某个Post对象(父对象)与某个Comment对象(子对象)解除关系时,也会删除该Comment对象,这个解除关系的对象被称为孤立对象(orphan object)。现在comments属性中的级联值为all、deleteorphan,如下所示:

class Post(db.Model):
	...
	comments = relationship('Comment', cascade='all, delete-orphan')

我们先创建一个文章对象post3和两个评论对象comment5和comment6,并将这两个评论对象与文章对象建立关系,将它们添加到数据库会话并提交:

>>> post3 = Post()
>>> comment5 = Comment()
>>> comment6 = Comment()
>>> post3.comments.append(comment5)
>>> post3.comments.append(comment6)
>>> db.session.add(post3)
>>> db.session.commit()

现在数据库中共有两条文章记录和四条评论记录:

>>> Post.query.all()
[<Post 1>, <Post 3>]
>>> Comment.query.all()
[<Comment 1>, <Comment 2>, <Comment 5>, <Comment 6>]

下面我们将comment5和comment6与post3解除关系并提交数据库会话:

>>> post3.comments.remove(comment5)
>>> post3.comments.remove(comment6)
>>> db.session.commit()

默认情况下,相关评论对象的外键会被设为空值。因为我们设置了delete-orphan级联,所以现在你会发现解除关系的两条评论记录都被删除了:

>>> Comment.query.all()
[<Comment 1>, <Comment 2>]

delete和delete-orphan通常会在一对多关系模式中,而且“多”这一侧的对象附属于“一”这一侧的对象时使用。尤其是如果“一”这一侧的“父”对象不存在了,那么“多”这一侧的“子”对象不再有意义的情况。比如,文章和评论的关系就是一个典型的示例。当文章被删除了,那么评论也就没必要再留存。在这种情况下,如果不使用级联操作,那么我们就需要手动迭代关系另一侧的所有评论对象,然后一一进行删除操作。

  • 提示
    对于这两个级联选项,如果你不会通过列表语义对集合关系属性调用remove()方法等方式来操作关系,那么使用delete级联即可。虽然级联操作方便,但是容易带来安全隐患,因此要谨慎使用。默认值能够满足大部分情况,所以最好仅在需要的时候才修改它。在SQLAlchemy中,级联的行为和配置选项等最初衍生自另一个ORM—HibernateORM。如果你对这部分内容感到困惑,那么我将在这里引用SQLAlchemy文档中关于Hibernate文档的结论:“The sectionswe havejust covered can be a bit confusing.However,in practice,it allworks out nicely.(我们刚刚介绍的这部分内容可能会有一些让人惑,不过,在实际使用中,它们都会工作得很顺利。)”
  • 附注
    你可以访问SQLAlchemy文档相关部分(http://docs.sqlalchemy.org/en/latest/orm/cascades.html)查看所有可的级联值及具体细节

5.7.2 事件监听

在Flask中,我们可以使用Flask提供的多个装饰器注册请求回调函数,它们会在特定的请求处理环节被执行。类似的,SQLAlchemy也提供了一个listen_for()装饰器,它可以用来注册事件回调函数。

listen_for()装饰器主要接收两个参数,target表示监听的对象,这个对象可以是模型类、类实例或类属性等。identifier参数表示被监听事件的标识符,比如,用于监听属性的事件标识符有set、append、remove、init_scalar、init_collection等。

为了演示事件监听,我们创建了一个Draft模型类表示草稿,其中包含body字段和edit_time字段,分别存储草稿正文和被修改的次数,其中edit_time字段的默认值为0,如下所示:

class Draft(db.Model):
	id = db.Column(db.Integer, primary_key=True)
	body = db.Column(db.Text)
	edit_time = db.Column(db.Integer, default=0)

通过注册事件监听函数,我们可以实现在body列修改时,自动叠加表示被修改次数的edit_time字段。在SQLAlchemy中,每个事件都会有一个对应的事件方法,不同的事件方法支持不同的参数。被注册的监听函数需要接收对应事件方法的所有参数,所以具体的监听函数用法因使用事件而异。设置某个字段值将触发set事件,代码清单5-18是我们为set事件编写的事件监听函数。

代码清单5-18 database/app.py:set事件监听函数
@db.event.listens_for(Draft.body, 'set')
def increment_edit_time(target, value, oldvalue, initiator):
	if target.edit_time is not None:
	target.edit_time += 1

我们在listen_for()装饰器中分别传入Draft.body和set作为targe和identifier参数的值。监听函数接收所有set()事件方法接收的参数,其中的target参数表示触发事件的模型类实例,使用target.edit_time即获取我们需要叠加的字段。其他的参数也需要照常写出,虽然这里没有用到。value表示被设置的值,oldvalue表示被取代的旧值。

当set事件发生在目标对象Draft.body上时,这个监听函数就会被执行,从而自动叠加Draft.edit_time列的值,如下所示:

>>> draft = Draft(body='init')
>>> db.session.add(draft)
>>> db.session.commit()
>>> draft.edit_time
0 >>> draft.body =
'
edited'
>>> draft.edit_time
1 >>> draft.body =
'
edited again'
>>> draft.edit_time
2 >>> draft.body =
'
edited again again'
>>> draft.edit_time
3 >>> db.session.commit()

除了这种传统的参数接收方式,即接收所有事件方法接收的参数,还有一种更简单的方法。通过在listen_for()装饰器中将关键字参数nam设为True,可以在监听函数中接收kwargs作为参数(可变长关键字参数),即“named argument”。然后在函数中可以使用参数名作为键来从kwargs字典获取对应的参数值:

@db.event.listens_for(Draft.body, 'set', named=True)
def increment_edit_time(**kwargs):
	if kwargs['target'].edit_time is not None:
		kwargs['target'].edit_time += 1

SQLAlchemy作为SQL工具集本身包含两大主要组件:SQLAlchemyORM和SQLAlchemy Core。前者实现了我们前面介绍ORM功能,后者实现了数据库接口等核心功能,这两类组件都提供了大量的监听事件,几乎覆盖整个SQLAlchemy使用的生命周期。请访下面的链接查看可用的事件列表以及具体的事件方法使用介绍:

5.8 本章代码

# -*- coding: utf-8 -*-
"""
    :author: Grey Li (李辉)
    :url: http://greyli.com
    :copyright: © 2018 Grey Li
    :license: MIT, see LICENSE for more details.
"""
import os
import sys

import click
from flask import Flask
from flask import redirect, url_for, abort, render_template, flash
from flask_sqlalchemy import SQLAlchemy
from flask_wtf import FlaskForm
from wtforms import SubmitField, TextAreaField
from wtforms.validators import DataRequired

# sqlite URI compatible
WIN = sys.platform.startswith('win')
if WIN:
    prefix = 'sqlite:///'
else:
    prefix = 'sqlite:'

app = Flask(__name__)
app.jinja_env.trim_blocks = True
app.jinja_env.lstrip_blocks = True

app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'secret string')

app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', prefix + os.path.join(app.root_path, 'data.db'))
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

db = SQLAlchemy(app)


# handlers
@app.shell_context_processor
def make_shell_context():
    return dict(db=db, Note=Note, Author=Author, Article=Article, Writer=Writer, Book=Book,
                Singer=Singer, Song=Song, Citizen=Citizen, City=City, Capital=Capital,
                Country=Country, Teacher=Teacher, Student=Student, Post=Post, Comment=Comment, Draft=Draft)


@app.cli.command()
@click.option('--drop', is_flag=True, help='Create after drop.')
def initdb(drop):
    """Initialize the database."""
    if drop:
        click.confirm('This operation will delete the database, do you want to continue?', abort=True)
        db.drop_all()
        click.echo('Drop tables.')
    db.create_all()
    click.echo('Initialized database.')


# Forms
class NewNoteForm(FlaskForm):
    body = TextAreaField('Body', validators=[DataRequired()])
    submit = SubmitField('Save')


class EditNoteForm(FlaskForm):
    body = TextAreaField('Body', validators=[DataRequired()])
    submit = SubmitField('Update')


class DeleteNoteForm(FlaskForm):
    submit = SubmitField('Delete')


# Models
class Note(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    body = db.Column(db.Text)

    # optional
    def __repr__(self):
        return '<Note %r>' % self.body


@app.route('/')
def index():
    form = DeleteNoteForm()
    notes = Note.query.all()
    return render_template('index.html', notes=notes, form=form)


@app.route('/new', methods=['GET', 'POST'])
def new_note():
    form = NewNoteForm()
    if form.validate_on_submit():
        body = form.body.data
        note = Note(body=body)
        db.session.add(note)
        db.session.commit()
        flash('Your note is saved.')
        return redirect(url_for('index'))
    return render_template('new_note.html', form=form)


@app.route('/edit/<int:note_id>', methods=['GET', 'POST'])
def edit_note(note_id):
    form = EditNoteForm()
    note = Note.query.get(note_id)
    if form.validate_on_submit():
        note.body = form.body.data
        db.session.commit()
        flash('Your note is updated.')
        return redirect(url_for('index'))
    form.body.data = note.body  # preset form input's value
    return render_template('edit_note.html', form=form)


@app.route('/delete/<int:note_id>', methods=['POST'])
def delete_note(note_id):
    form = DeleteNoteForm()
    if form.validate_on_submit():
        note = Note.query.get(note_id)
        db.session.delete(note)
        db.session.commit()
        flash('Your note is deleted.')
    else:
        abort(400)
    return redirect(url_for('index'))


# one to many
class Author(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(20), unique=True)
    phone = db.Column(db.String(20))
    articles = db.relationship('Article')  # collection

    def __repr__(self):
        return '<Author %r>' % self.name


class Article(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(50), index=True)
    body = db.Column(db.Text)
    author_id = db.Column(db.Integer, db.ForeignKey('author.id'))

    def __repr__(self):
        return '<Article %r>' % self.title


# many to one
class Citizen(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(70), unique=True)
    city_id = db.Column(db.Integer, db.ForeignKey('city.id'))
    city = db.relationship('City')  # scalar

    def __repr__(self):
        return '<Citizen %r>' % self.name


class City(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(30), unique=True)

    def __repr__(self):
        return '<City %r>' % self.name


# one to one
class Country(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(30), unique=True)
    capital = db.relationship('Capital', uselist=False)  # collection -> scalar

    def __repr__(self):
        return '<Country %r>' % self.name


class Capital(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(30), unique=True)
    country_id = db.Column(db.Integer, db.ForeignKey('country.id'))
    country = db.relationship('Country')  # scalar

    def __repr__(self):
        return '<Capital %r>' % self.name


# many to many with association table
association_table = db.Table('association',
                             db.Column('student_id', db.Integer, db.ForeignKey('student.id')),
                             db.Column('teacher_id', db.Integer, db.ForeignKey('teacher.id'))
                             )


class Student(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(70), unique=True)
    grade = db.Column(db.String(20))
    teachers = db.relationship('Teacher',
                               secondary=association_table,
                               back_populates='students')  # collection

    def __repr__(self):
        return '<Student %r>' % self.name


class Teacher(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(70), unique=True)
    office = db.Column(db.String(20))
    students = db.relationship('Student',
                               secondary=association_table,
                               back_populates='teachers')  # collection

    def __repr__(self):
        return '<Teacher %r>' % self.name


# one to many + bidirectional relationship
class Writer(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(64), unique=True)
    books = db.relationship('Book', back_populates='writer')

    def __repr__(self):
        return '<Writer %r>' % self.name


class Book(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(50), index=True)
    writer_id = db.Column(db.Integer, db.ForeignKey('writer.id'))
    writer = db.relationship('Writer', back_populates='books')

    def __repr__(self):
        return '<Book %r>' % self.name


# one to many + bidirectional relationship + use backref to declare bidirectional relationship
class Singer(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(70), unique=True)
    songs = db.relationship('Song', backref='singer')

    def __repr__(self):
        return '<Singer %r>' % self.name


class Song(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(50), index=True)
    singer_id = db.Column(db.Integer, db.ForeignKey('singer.id'))

    def __repr__(self):
        return '<Song %r>' % self.name


# cascade
class Post(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(50))
    body = db.Column(db.Text)
    comments = db.relationship('Comment', back_populates='post', cascade='all, delete-orphan')  # collection


class Comment(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    body = db.Column(db.Text)
    post_id = db.Column(db.Integer, db.ForeignKey('post.id'))
    post = db.relationship('Post', back_populates='comments')  # scalar


# event listening
class Draft(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    body = db.Column(db.Text)
    edit_time = db.Column(db.Integer, default=0)


@db.event.listens_for(Draft.body, 'set')
def increment_edit_time(target, value, oldvalue, initiator):
    if target.edit_time is not None:
        target.edit_time += 1

# same with:
# @db.event.listens_for(Draft.body, 'set', named=True)
# def increment_edit_time(**kwargs):
#     if kwargs['target'].edit_time is not None:
#         kwargs['target'].edit_time += 1

  • 10
    点赞
  • 122
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值