码上问学问代码之理,学编程之道

第 6 章 · 数据库与 ORM

上一章我们把 Flask 项目搭成了「有骨架的工程」,但数据还停在 MySQL 里没接上。这一章教你把 hiwen 库的六张表翻译成 Python 类(ORM 模型),然后用对象的方式增删改查,不再手写一行 SQL。学完,你就能用 Article.query.filter_by(dynasty="唐").all() 这种写法,把「静夜思」查出来。

为什么用 ORM 而不是手写 SQL

从「拼字符串」到「操作对象」
为什么用 ORM:从「拼字符串」到「操作对象」 手写 SQL sql = "SELECT * FROM article WHERE title LIKE '%" + kw + "%'" · 引号、转义容易出错 · 拼字符串有注入风险 · 表名/列名改了要全局改 ORM 操作对象 Article.query.filter( Article.title.like(f'%{kw}%')).all() · 参数自动转义,防注入 · 改模型只改一处 · 拿到的是对象,可直接用属性 ORM 不是不要 SQL,而是让 SQL 由框架生成;遇到复杂统计,该写 SQL 就写 SQL(见本章最后一节)

手写 SQL 要自己拼字符串,容易出引号错误和注入风险;ORM 让你操作对象,SQL 由框架自动生成、参数自动转义。复杂统计场景仍可以退回原生 SQL。

先讲为什么第 1 节的第 9 章讲过 SQL 注入:只要用字符串拼 SQL,用户输入里塞点东西就能改掉你的语句。更麻烦的是,用 pymysql 取回的数据是元组——row[1] 到底是标题还是作者,你得回头数表结构,改天加了一列,全项目的下标全乱套。
ORM(Object-Relational Mapping,对象关系映射)就是把「一张表」自动映射成「一个 Python 类」,把「一行记录」映射成「一个对象」。之后你操作的是 article.title,而不是 row[1]——名字永远对得上,SQL 由框架自动生成并做参数化,注入自然也就防住了。
动手做先看「手写 SQL 版」的作品详情接口——注意它有多脆。
手写 SQL 版(反面教材)python
import pymysql

def get_article_detail(article_id):
    conn = pymysql.connect(
        host="127.0.0.1", user="root", password="123456",
        database="hiwen", charset="utf8mb4",
    )
    cursor = conn.cursor()
    # 危险①:字符串拼接,article_id 若来自 URL 未校验,可被注入
    sql = f"SELECT * FROM article WHERE id = {article_id}"
    cursor.execute(sql)
    row = cursor.fetchone()
    conn.close()

    if row is None:
        return None
    # 危险②:靠下标取字段,加一列就可能全错位
    return {"id": row[0], "title": row[1], "author": row[2], "views": row[8]}
运行输出Python 运行结果
运行结果: # 正常传入 1 GET /api/article/detail/1 {"id": 1, "title": "静夜思", "author": "李白", "views": 328} # 但若是拼接出问题的输入,SQL 结构会被改变 # 这就是第 9 章反复强调「参数化」的原因
动手做换成 ORM,同一件事变成三行,字段名直接当属性用。
ORM 版python
from app.models import Article

def get_article_detail(article_id):
    article = Article.query.get(article_id)   # 或 db.session.get(Article, article_id)
    if article is None:
        return None
    return {
        "id": article.id,
        "title": article.title,
        "author": article.author,
        "views": article.views,
    }
运行输出Python 运行结果
运行结果: GET /api/article/detail/1 {"id": 1, "title": "静夜思", "author": "李白", "views": 328} # 同样的结果,但字段靠名字取,不怕加列、不怕注入
讲透:ORM 到底替你做了什么
  • 不是「不用 SQL」,而是「不手写 SQL」:Article.query.filter_by(dynasty="唐") 最终仍会翻译成 SELECT ... WHERE dynasty = %s,只是参数化那一步由框架做了。
  • 模型 = 表的结构声明:你在 models.py 里写一遍类,ORM 就知道表名、列名、类型、约束,后面所有查询都基于它。
  • 一个对象 = 一行,一个属性 = 一列:article.title 读的是这一行的 title 列;给 article.likes = 43 赋值、再 commit,就等于 UPDATE。
  • 本课程用的 ORM 是 SQLAlchemy,Flask 的官方集成包是 Flask-SQLAlchemy——它把 SQLAlchemy 和 Flask 的配置、上下文接在了一起。
避坑别把「ORM 防注入」当成免死金牌——只要你用 text() 或 db.session.execute() 手写 SQL,就必须自己用占位符传参,写成 text("SELECT * FROM article WHERE title = :t") + {"t": kw},绝不能 f-string 拼进去。ORM 保护的是「ORM 的查询写法」,不保护你手拼的字符串。

初始化 Flask-SQLAlchemy

接上第 5 章的工厂
动手做第 5 章我们在 app/extensions.py 里已经放了 db = SQLAlchemy(),现在只要在工厂里 db.init_app(app) 把它绑上应用即可。
app/extensions.pypython
from flask_sqlalchemy import SQLAlchemy

# 全局唯一的 db 对象:所有模型都从它派生
db = SQLAlchemy()
运行输出Python 运行结果
运行结果: # 此刻 db 还没有 app,等 create_app 里 init_app 后才真正生效
app/__init__.py(节选)python
from flask import Flask
from config import config_map
from .extensions import db

def create_app(env: str = "development") -> Flask:
    app = Flask(__name__)
    app.config.from_object(config_map[env])

    # 连接串来自 config.py 的 SQLALCHEMY_DATABASE_URI
    # mysql+pymysql://用户:密码@主机:端口/库名?charset=utf8mb4
    db.init_app(app)

    return app
运行输出Python 运行结果
运行结果: $ pip install flask-sqlalchemy pymysql Successfully installed flask-sqlalchemy-3.1.1 pymysql-1.1.1 $ flask --app app shell >>> from app.extensions import db >>> db.engine.url mysql+pymysql://root:***@127.0.0.1:3306/hiwen?charset=utf8mb4 # 引擎已就绪,说明配置读对了
讲透
  • 驱动是 pymysql:连接串里的 mysql+pymysql 表示「用 MySQL 方言 + pymysql 驱动」,缺了驱动包会报 No module named 'pymysql'。
  • 必须先有 app 才有 db.engine:db 是「未绑定」的,只有 init_app 之后才知道连哪。所以在 flask shell 外裸跑 Article.query 会报 RuntimeError: Working outside of application context——这是第 4 章「应用上下文」的直接体现。
  • 本章后面所有示例,都默认你已经进到了 create_app() 造出的应用上下文里(flask shell 或视图函数中)。
避坑如果忘了设 SQLALCHEMY_TRACK_MODIFICATIONS = False,启动时会一直刷一条 LegacyAPIWarning 警告。这个选项默认开启,会为每次对象修改记事件、白白吃内存——永远把它关掉。我们在第 5 章的 config.py 里已经关了。

定义模型:把表翻译成类

对照 hiwen.sql 逐列来
ORM:一张表 = 一个类,一列 = 一个字段 SQL(建表) CREATE TABLE article ( id INT PRIMARY KEY, title VARCHAR(200), content TEXT, category_id INT ); Python(模型类) class Article(db.Model): id = Column(Integer, pk) title = Column(String(200)) content = Column(Text) category_id = Column(Integer) 列→字段 表→类 之后用 Article.query 操作对象,ORM 自动翻译成 SQL——你不再手写 SELECT

ORM(对象关系映射)把数据库表映射成 Python 类:一张表 = 一个类,一列 = 一个字段。你操作的是对象,SQL 由 ORM 自动生成。

先讲为什么ORM 的写法是「先声明、后使用」——你得先用 Python 类把「表长什么样」说清楚,框架才能帮你查。规则只有三条:一个类对应一张表,一个 db.Column 对应一列,类属性名就是列名。下面我们就照着 hiwen.sql 的建表语句,一张张翻译过去。
动手做新建 app/models.py。先写 user 表:主键、唯一约束、默认值一个都不能少。
app/models.py(User)python
from datetime import datetime
from .extensions import db

class User(db.Model):
    """用户表,对应 hiwen 库的 `user`"""
    __tablename__ = "user"

    id         = db.Column(db.Integer, primary_key=True, autoincrement=True)
    username   = db.Column(db.String(50), nullable=False, unique=True)
    password   = db.Column(db.String(255), nullable=False)   # 存哈希,绝不存明文
    nickname   = db.Column(db.String(50))
    email      = db.Column(db.String(100), unique=True)
    avatar     = db.Column(db.String(255), default="")
    intro      = db.Column(db.String(255))
    gender     = db.Column(db.SmallInteger, default=0)       # 0未知 1男 2女
    status     = db.Column(db.SmallInteger, nullable=False, default=1)
    created_at = db.Column(db.DateTime, nullable=False, default=datetime.now)
    updated_at = db.Column(db.DateTime, nullable=False,
                           default=datetime.now, onupdate=datetime.now)
运行输出Python 运行结果
运行结果: # 对照 hiwen.sql 的 user 表,逐列映射: # id → Integer, primary_key=True # username → String(50), nullable=False, unique=True # password → String(255), nullable=False # gender / status → SmallInteger(对应 MySQL 的 TINYINT) # created_at → DateTime, default=datetime.now
动手做再写 category 和 article 两张表。注意 article.category_id 是指向 category.id 的外键——这是本节的第一个重点。
app/models.py(Category / Article)python
class Category(db.Model):
    """文学分类表,对应 `category`(唐诗/宋词/元曲/散文/小说)"""
    __tablename__ = "category"

    id          = db.Column(db.Integer, primary_key=True)
    name        = db.Column(db.String(30), nullable=False, unique=True)
    description = db.Column(db.String(255))
    sort        = db.Column(db.Integer, nullable=False, default=0)
    status      = db.Column(db.SmallInteger, nullable=False, default=1)
    created_at  = db.Column(db.DateTime, nullable=False, default=datetime.now)


class Article(db.Model):
    """文学作品表,对应 `article`(静夜思 / 春望 / 念奴娇·赤壁怀古 ……)"""
    __tablename__ = "article"

    id           = db.Column(db.Integer, primary_key=True)
    title        = db.Column(db.String(150), nullable=False)
    author       = db.Column(db.String(50))
    dynasty      = db.Column(db.String(20))
    category_id  = db.Column(db.Integer, db.ForeignKey("category.id"))  # 外键
    cover        = db.Column(db.String(255), default="")
    summary      = db.Column(db.String(500))
    content      = db.Column(db.Text)
    views        = db.Column(db.Integer, nullable=False, default=0)
    likes        = db.Column(db.Integer, nullable=False, default=0)
    is_recommend = db.Column(db.SmallInteger, nullable=False, default=0)
    status       = db.Column(db.SmallInteger, nullable=False, default=1)
    created_at   = db.Column(db.DateTime, nullable=False, default=datetime.now)
    updated_at   = db.Column(db.DateTime, nullable=False,
                             default=datetime.now, onupdate=datetime.now)
运行输出Python 运行结果
运行结果: # db.ForeignKey("category.id") 告诉数据库:这一列引用 category 表的主键 # 注意参数是"表名.列名"的字符串,不是 Python 类名 # 生成 DDL 时会带上:FOREIGN KEY (category_id) REFERENCES category (id)
动手做最后写 comment 表。它有三个外键,其中一个指向自己(parent_id 实现「楼中楼」回复)。
app/models.py(Comment)python
class Comment(db.Model):
    """书评表,对应 `comment`;parent_id 自关联实现回复"""
    __tablename__ = "comment"

    id         = db.Column(db.Integer, primary_key=True)
    user_id    = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
    article_id = db.Column(db.Integer, db.ForeignKey("article.id"), nullable=False)
    parent_id  = db.Column(db.Integer, db.ForeignKey("comment.id"))
    content    = db.Column(db.String(1000), nullable=False)
    likes      = db.Column(db.Integer, nullable=False, default=0)
    status     = db.Column(db.SmallInteger, nullable=False, default=1)
    created_at = db.Column(db.DateTime, nullable=False, default=datetime.now)
运行输出Python 运行结果
运行结果: $ flask --app app shell >>> from app.models import Article >>> [c.name for c in Article.__table__.columns] ['id', 'title', 'author', 'dynasty', 'category_id', 'cover', 'summary', 'content', 'views', 'likes', 'is_recommend', 'status', 'created_at', 'updated_at'] # 14 个属性 = article 表的 14 列,与 hiwen.sql 完全一致
讲透:几个关键参数怎么用
  • primary_key=True:声明主键。SQLAlchemy 约定名为 id 的整型主键默认自增,所以不必再写 autoincrement=True。
  • nullable=False:对应 SQL 的 NOT NULL;不写则默认可空。
  • unique=True:对应 UNIQUE KEY,如 username、email、category.name。
  • default= 是「Python 侧默认值」,插入时若没给值就用它;onupdate=datetime.now 对应 SQL 的 ON UPDATE CURRENT_TIMESTAMP,每次改记录自动刷新 updated_at。
  • db.String(n) / db.Text / db.DateTime / db.SmallInteger 分别对应 VARCHAR(n) / TEXT / DATETIME / TINYINT。
避坑hiwen 库的表名是单数(user、article、comment),但 Flask-SQLAlchemy 默认会把类名小写并加复数(User → users)。如果你不写 __tablename__ = "user",框架就会去找一张叫 users 的表,直接报 Table 'hiwen.users' doesn't exist。只要是对着已有库写模型,就老老实实写明 __tablename__。

关系定义:relationship 与 backref

一篇文章有多条书评
关系:一篇文章 → 多条书评(一对多) Article 作品 id, title, content comments = relationship('Comment') Comment 书评 id, content, article_id article = backref(...) 1 — * article_id 是外键 relationship 让你用 article.comments 直接拿到书评, 用 comment.article 反查所属作品——不用手写 JOIN

两张表之间用「外键 + relationship」表达关系。一篇文章对应多条书评(一对多):Article 里写 comments = relationship,ORM 就能让你用 article.comments 直接取到它的全部书评。

先讲为什么有了外键 comment.article_id,数据库知道「这条书评属于哪篇作品」,但你写代码时还是得手动 join。relationship 就是把这个 join 预先「接好线」,让你能直接 article.comments 拿到这篇作品的所有书评、comment.article 拿到它属于哪篇作品——像访问普通属性一样。
一句话类比:外键是「门牌号」(数据库层面的事实),relationship 是「电话直拨」(让你不用查号就能打通)」。
动手做回到 models.py,给几个类补上关系属性。backref 会自动在「对面」也生成一个反向属性,双向都能走。
app/models.py(补上关系)python
class Article(db.Model):
    __tablename__ = "article"
    # …… 上面 14 个 Column 不变 ……

    # 一对多:一篇文章 ↔ 多条书评
    # backref="article" 会自动给 Comment 生成 .article 属性
    comments = db.relationship("Comment", backref="article", lazy="dynamic")


class Comment(db.Model):
    __tablename__ = "comment"
    # …… 上面 8 个 Column 不变 ……

    # 自关联:一条书评 ↔ 它的父书评 / 子回复
    parent = db.relationship(
        "Comment",
        backref=db.backref("children", lazy="dynamic"),
        remote_side=[id],          # 指向「本表的主键」,表示这是一对多的自关联
    )


class Category(db.Model):
    __tablename__ = "category"
    # ……

    # 一个分类 ↔ 多篇作品
    articles = db.relationship("Article", backref="category", lazy="dynamic")


class User(db.Model):
    __tablename__ = "user"
    # ……

    comments = db.relationship("Comment", backref="user", lazy="dynamic")
运行输出Python 运行结果
运行结果: >>> a = Article.query.get(1) # 静夜思 >>> a.comments.count() # 这篇文章有几条书评 2 >>> [c.content for c in a.comments] ['短短二十字,写尽思乡之情。', '小时候背,只觉得朗朗上口;长大后读,才懂那份乡愁。'] >>> a.comments[0].user.nickname # 反向走:书评 → 用户 '管理员' >>> a.category.name # 反向走:作品 → 分类 '唐诗'
讲透:relationship ≠ ForeignKey
  • ForeignKey 是「数据库层」的:它在建表时生成真正的外键约束、在表里占据一列(article_id)。删掉它,表结构就变了。
  • relationship 是「Python 层」的:它不建列、不进数据库,只是在模型类上挂一个「快捷通道」,纯属写法上的便利。
  • 两者通常一起用:先有 ForeignKey 指明「靠哪一列关联」,再用 relationship 声明「怎么方便地取」。没有了外键列,relationship 也不知道该 join 谁。
  • backref / back_populates:前者一行代码在对端自动生成反向属性(comment.article);后者更显式,需要两边都写,适合关系复杂时。
  • lazy="dynamic":返回的是一个「还没执行的查询对象」,可以继续 .filter()、.count()、.paginate()。书评动辄几百条,用 dynamic 更省内存、更灵活(默认 select 会一次性全取回列表)。
避坑最常见的误解,是以为写了 relationship 就自动有了外键,于是只写关系、不写 db.ForeignKey。结果:数据库里没有约束(脏数据能随便进),relationship 也可能因为找不到关联列而报 Could not determine join condition。顺序永远是先 ForeignKey,后 relationship。另外 backref 里的字符串参数,写的是「对端的类名」,别写成表名。

增删改查(CRUD)

add / commit / filter / delete
CRUD:四个动作与它们生成的 SQL 增 Create db.session.add(obj) + commit() → INSERT 查 Read Model.query.filter_by(...) → SELECT 改 Update obj.title = '新' + commit() → UPDATE 删 Delete db.session.delete(obj) + commit() → DELETE 关键:增/改/删都必须 commit() 才真正写库;只 add 不提交,数据不会落盘 查询不用 commit:query 只是读;出错用 db.session.rollback() 回滚

ORM 的四个动作:add+commit 对应 INSERT,query.filter 对应 SELECT,改属性+commit 对应 UPDATE,delete+commit 对应 DELETE。增/改/删必须 commit 才真正写库。

先讲为什么ORM 的增删改都走同一个流程:改内存里的对象 → db.session.commit() 提交落库。session 可以理解成「一个待提交的购物车」:你先把要新增/修改/删除的对象放进去,只有 commit() 那一刻才会真正写进数据库。这也带来一个巨大的好处——出错可以 rollback() 整批撤销。
动手做先看「查」:filter_by(等值简写)、filter(完整表达式)、get(按主键取一条)。
查询示例.pypython
from app.models import Article, Comment

# ① 按主键取一条:查 id=1 的作品(推荐用 db.session.get,见下方讲透)
a = db.session.get(Article, 1)
# 老写法 Article.query.get(1) 也能用,但新代码不推荐

# ② filter_by:只能写等值,写法最简洁
tang = Article.query.filter_by(dynasty="唐", status=1).all()

# ③ filter:能写 > < >=、like、in_ 等完整条件
hot = Article.query.filter(Article.views > 300).order_by(Article.views.desc()).all()

# ④ 模糊搜索:标题或摘要里含「月」
kw = Article.query.filter(Article.title.like("%月%")).first()

# ⑤ 多条件组合:唐朝 + 已发布 + 浏览量降序
rows = (Article.query
        .filter_by(dynasty="唐", status=1)
        .order_by(Article.views.desc())
        .all())

# ⑥ 关联查询:某篇作品的所有书评(用上节的 relationship)
comments = Article.query.get(3).comments.filter_by(status=1).all()
运行输出Python 运行结果
运行结果: # ① 静夜思 # ② 3 篇(静夜思 / 春望 / 陋室铭) # ③ 2 篇(念奴娇·赤壁怀古 412 / 静夜思 328) # ④ 静夜思(标题含「月」) # ⑤ 静夜思 328 / 春望 256 / 陋室铭 203 # ⑥ 1 条(大江东去,三个字就起势了,果然是豪放派。)
动手做再看「增、改、删」——三者都靠 db.session 收尾。
增删改.pypython
from app.models import Comment, Article

# 增:造一个对象,add 后 commit
c = Comment(user_id=2, article_id=1, content="同感!", likes=0)
db.session.add(c)
db.session.commit()
print("新书评 id =", c.id)          # commit 后主键才会被回填

# 改:取出对象,改属性,再 commit(ORM 会自动生成 UPDATE)
a = db.session.get(Article, 1)
a.views = a.views + 1               # 浏览量 +1
db.session.commit()

# 批量增:add_all
db.session.add_all([
    Comment(user_id=1, article_id=3, content="豪放派代表!"),
    Comment(user_id=2, article_id=3, content="周郎赤壁,千古风流。"),
])
db.session.commit()

# 删:先取出对象,再 delete
bad = Comment.query.filter_by(content="垃圾广告").first()
if bad:
    db.session.delete(bad)
    db.session.commit()
运行输出Python 运行结果
运行结果: # 增 新书评 id = 4 # 改(静夜思 views 从 328 变 329) >>> db.session.get(Article, 1).views 329 # 批量增 >>> Comment.query.filter_by(article_id=3).count() 3 # 删 >>> Comment.query.filter_by(content="垃圾广告").count() 0
讲透
  • filter_by vs filter:filter_by(dynasty="唐") 只能写等值、不带类名前缀;filter(Article.views > 300) 能写任意表达式、必须写 类名.字段。简单等值用前者,复杂条件用后者。
  • 取一条用 .first() / .get(),取多条用 .all():.get() 只认主键、找不到返回 None;.first() 可按任意条件取第一条。
  • 改数据有两副面孔:① 先 get 出对象再改属性(会自动 UPDATE);② 用 query.update({...}) 批量更新(更快,但不触发事件)。日常用第一种。
  • commit() 之后对象才有主键:所以 c.id 一定要在 commit 之后读,插入前它还是 None。
  • 异常一定要 rollback():批量操作中途失败,session 会进入「异常态」,不回滚后续操作全会报错。
避坑两个必踩的坑:① 忘了 commit()——数据只改了内存,刷新页面啥也没变,新手常在视图里漏掉最后那句;② add 之后就 commit,中间不写 try/except——一旦触发唯一键冲突(比如注册重复用户名),会抛 IntegrityError,必须 db.session.rollback() 才能继续用这个 session。第 7 章的全局异常处理会统一兜住这类错误。

分页查询 paginate

作品列表页的标配
先讲为什么第 2 章我们手写过 LIMIT s OFFSET (p-1)*s,还强调「必须配 ORDER BY」。ORM 把这些细节封装成了一个 paginate() 方法,一次调用就同时给你「本页数据 + 总条数 + 总页数 + 有没有下一页」——前端要的 list / total / page / page_size 正好全在里面。
动手做给「作品列表」写一段分页查询:按浏览量降序,每页 3 条,取第 1 页。
分页.pypython
from app.models import Article

# 方法一:Model.query.paginate(...)
pager = (Article.query
         .filter_by(status=1)
         .order_by(Article.views.desc())
         .paginate(page=1, per_page=3, error_out=False))

print("本页条数:", len(pager.items))
print("总条数:  ", pager.total)
print("总页数:  ", pager.pages)
print("有下一页:", pager.has_next)

# 方法二(Flask-SQLAlchemy 3.x 新的推荐写法):
# from sqlalchemy import select
# pager = db.paginate(
#     select(Article).where(Article.status == 1).order_by(Article.views.desc()),
#     page=1, per_page=3, error_out=False)

for a in pager.items:
    print(a.id, a.title, a.views)
运行输出Python 运行结果
运行结果: 本页条数: 3 总条数: 6 总页数: 2 有下一页: True 3 念奴娇·赤壁怀古 412 1 静夜思 328 4 声声慢 298
讲透:Pagination 对象的常用属性
  • .items:本页的对象列表(就是 list 的来源)。
  • .total / .pages:总记录数 / 总页数。
  • .page / .per_page:当前页 / 每页条数——直接回给前端。
  • .has_next / .has_prev:有无下一页 / 上一页,控制「加载更多」按钮。
  • error_out=False:越界时返回空列表而不是直接抛 404。前端翻到不存在的一页时,空列表比报错友好得多——建议永远带上这个参数。
  • 分页底层仍是 LIMIT / OFFSET,所以排序不能省,否则翻页会出现重复/漏项(第 2 章讲的坑)。

用 Flask-Migrate 管理表结构

flask db init / migrate / upgrade
先讲为什么你把 Article 里加了一列 tags,可数据库那张表还是老样子——两边对不上了。用 db.create_all() 只能「建没建过的表」,它不会帮你改已经存在的表,直接上手 ALTER TABLE 又怕线上改错。
Flask-Migrate 就是解决这件事的:你只管改 Python 模型,它自动对比模型和数据库的差异,生成一段可回滚的「迁移脚本」,执行即可同步。这是团队协作、多环境部署时管理表结构的标准做法。
动手做第一步:初始化迁移仓库(一个项目只做一次)。它会在项目根目录生成一个 migrations/ 文件夹。
终端bash
pip install flask-migrate
flask db init
终端输出命令行执行结果
运行结果: Creating directory /Users/liuqingjie/hiflask/migrations ... done Creating directory /Users/liuqingjie/hiflask/migrations/versions ... done Generating /Users/liuqingjie/hiflask/migrations/alembic.ini ... done Generating /Users/liuqingjie/hiflask/migrations/README ... done Generating /Users/liuqingjie/hiflask/migrations/env.py ... done Generating /Users/liuqingjie/hiflask/migrations/script.py.mako ... done Please edit configuration/connection/logging settings in '/Users/liuqingjie/hiflask/migrations/alembic.ini' before proceeding.
动手做第二步:改完模型后生成迁移脚本。Flask-Migrate 会对比「模型应该长什么样」和「数据库现在什么样」,把差异写成一段 Python 脚本。
终端bash
flask db migrate -m "init user/category/article/comment"
终端输出命令行执行结果
运行结果: INFO [alembic.runtime.migration] Context impl MySQLImpl. INFO [alembic.runtime.migration] Will assume non-transactional DDL. INFO [alembic.autogenerate.compare] Detected added table 'category' INFO [alembic.autogenerate.compare] Detected added table 'user' INFO [alembic.autogenerate.compare] Detected added table 'article' INFO [alembic.autogenerate.compare] Detected added table 'comment' INFO [alembic.autogenerate.compare] Detected added index 'ix_user_username' on '['username']' INFO [alembic.autogenerate.compare] Detected added index 'ix_category_name' on '['name']' Generating /Users/liuqingjie/hiflask/migrations/versions/8f2a1c9d3e4b_init.py ... done
动手做第三步:把脚本真正执行到数据库上。这就是「模型变了 → 数据库跟着变」的落地动作。
终端bash
flask db upgrade
终端输出命令行执行结果
运行结果: INFO [alembic.runtime.migration] Context impl MySQLImpl. INFO [alembic.runtime.migration] Will assume non-transactional DDL. INFO [alembic.runtime.migration] Running upgrade -> 8f2a1c9d3e4b, init user/category/article/comment # 反悔了?回退一步: $ flask db downgrade INFO [alembic.runtime.migration] Running downgrade 8f2a1c9d3e4b -> , init user/category/article/comment
讲透:三个命令何时用
  • flask db init:项目里跑一次,生成 migrations/ 目录。这个目录要提交进 Git,队友才能共享迁移历史。
  • flask db migrate -m "说明":每次改了模型之后跑,生成一段新的版本脚本。-m 后面写清楚这次改了什么,方便回溯。
  • flask db upgrade:把还没执行的迁移脚本跑一遍,让数据库与模型同步。部署到新环境时,别人拉下代码后只需要 upgrade 就能得到一样的表结构。
  • flask db downgrade:回退一个版本(生产环境慎用,可能丢数据)。
  • 它凭什么知道模型长什么样:靠 create_app() 里导入模型、让 db 记住全部表定义。所以模型必须在应用被创建时被 import 到,否则 migrate 会认为「一张表都没有」,什么都不生成。
避坑记住这个顺序,颠倒了必出问题:先改模型 → 再 migrate 生成脚本 → 最后 upgrade 执行。很多人改完模型直接 upgrade,结果 versions 里根本没有新脚本,跑了也白跑。另外:migrate 生成的脚本要打开看一眼——自动生成的 ALTER 有时会漏掉默认值或误删数据,改列类型这种危险操作尤其要人工确认。

ORM 与原生 SQL 的取舍

复杂统计该写 SQL 就写 SQL
先讲为什么ORM 很好用,但它不是万能的。越复杂的统计查询,用 ORM 写出来反而越长越绕。比如「按朝代分组统计篇数和平均浏览量」——第 4 章你一行 SQL 就写完了,硬要用 ORM 的 func.count + group_by 拼,可读性反而下降。所以别走极端:日常增删改查用 ORM,遇到复杂统计就退回原生 SQL,两者混用完全正常。
动手做用 text() 写一段带参数的统计 SQL。再次提醒:手写 SQL 必须用占位符传参,绝不能 f-string 拼接。
原生 SQL.pypython
from sqlalchemy import text

# ① 简单统计:ORM 用 func + group_by
from sqlalchemy import func
rows = (db.session.query(Article.dynasty,
                         func.count(Article.id),
                         func.avg(Article.views))
        .group_by(Article.dynasty)
        .all())

# ② 同样的结果,原生 SQL 更直观
sql = text("""
    SELECT dynasty, COUNT(*) AS cnt, ROUND(AVG(views), 1) AS avg_views
    FROM article
    WHERE status = :status
    GROUP BY dynasty
    HAVING cnt > 0
    ORDER BY cnt DESC
""")
rows = db.session.execute(sql, {"status": 1}).all()

for dynasty, cnt, avg_views in rows:
    print(dynasty, cnt, avg_views)
运行输出Python 运行结果
运行结果: # 两种写法输出一致 唐 3 262.3 宋 2 355.0 元 1 187.0 # 唐:静夜思 328 / 春望 256 / 陋室铭 203 → 平均 262.3 # 宋:念奴娇·赤壁怀古 412 / 声声慢 298 → 平均 355.0 # 元:天净沙·秋思 187 → 平均 187.0
讲透:什么时候用哪个
  • 用 ORM:常规的增删改查、按条件筛选、关联取数、分页——占日常工作的 90%。
  • 用原生 SQL:多表复杂统计、窗口函数、GROUP BY + HAVING 聚合、性能要求极高需要手调的执行计划。这时 SQL 反而更短更清楚。
  • db.session.execute(text(...), {...}):第二个参数是参数字典,内部走参数化,安全;不传第二个参数、直接 f-string 拼值,就等于放弃防护。
  • 两种写法可以互相配合:先用 ORM 查出一批对象,再用原生 SQL 做一次聚合报表;或者反过来。别强迫自己「全 ORM」或「全 SQL」。
  • 视图 v_article_list 也能映射成模型:把 __tablename__ = "v_article_list" 写成视图名即可只读查询,省掉每次手写 join category 的麻烦。
动手练习
1补全 Favorite(收藏)模型:对照 hiwen.sql 的 favorite 表(字段 id、user_id、article_id、created_at,联合唯一键 uk_fav_user_article),写出 db.Model 定义,并用 UniqueConstraint 表达联合唯一。
2用 ORM 完成两件事:① 查出「宋」代作品并按点赞数从高到低排列;② 把 id=1 的作品浏览量 views 加 1 并存库。
3迁移练习:把本章做法搬到「红影赏析」(电影主题)。为 movie 表(字段 id、title、director、genre、rating)写一个 ORM 模型,再加一个 review(影评)模型与它建立一对多关系,最后写出「查某部电影的全部影评」的代码。
参考答案
练习答案.pypython
from datetime import datetime
from .extensions import db
from sqlalchemy import UniqueConstraint

# 第 1 题:收藏模型
class Favorite(db.Model):
    __tablename__ = "favorite"
    __table_args__ = (
        UniqueConstraint("user_id", "article_id", name="uk_fav_user_article"),
    )

    id         = db.Column(db.Integer, primary_key=True)
    user_id    = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
    article_id = db.Column(db.Integer, db.ForeignKey("article.id"), nullable=False)
    created_at = db.Column(db.DateTime, nullable=False, default=datetime.now)

# 第 2 题:查询与更新
song = (Article.query
        .filter_by(dynasty="宋")
        .order_by(Article.likes.desc())
        .all())
# → [念奴娇·赤壁怀古(58), 声声慢(47)]

a = db.session.get(Article, 1)
a.views = a.views + 1
db.session.commit()
# → 静夜思 views 328 → 329

# 第 3 题:红影赏析(电影主题)
class Movie(db.Model):
    __tablename__ = "movie"
    id       = db.Column(db.Integer, primary_key=True)
    title    = db.Column(db.String(150), nullable=False)
    director = db.Column(db.String(50))
    genre    = db.Column(db.String(30))
    rating   = db.Column(db.Float, default=0)

    reviews  = db.relationship("Review", backref="movie", lazy="dynamic")

class Review(db.Model):
    __tablename__ = "review"
    id       = db.Column(db.Integer, primary_key=True)
    movie_id = db.Column(db.Integer, db.ForeignKey("movie.id"), nullable=False)
    content  = db.Column(db.String(1000), nullable=False)

# 查某部电影的全部影评
reviews = db.session.get(Movie, 1).reviews.all()
运行输出Python 运行结果
运行结果: # 第 1 题 >>> Favorite.__table__.constraints [PrimaryKeyConstraint(...), UniqueConstraint(columns=['user_id', 'article_id'], name='uk_fav_user_article')] # 第 2 题 ① 念奴娇·赤壁怀古 58 声声慢 47 # 第 2 题 ② >>> db.session.get(Article, 1).views 329 # 第 3 题 >>> db.session.get(Movie, 1).reviews.all() [<Review 1>, <Review 2>] # 该电影的全部影评

第 3 题的关键:换的是「表名和字段」,不变的是「ForeignKey 定关联列 + relationship 定取数通道」这套骨架。注意 movie.rating 用 db.Float(电影评分是小数),而古典文学网的 views 是整数——字段类型要跟真实业务对上。

本章小结

📝 本节自测

做完点「检查本节」看解析与得分;做错可重做。