数据模型定义
SQLAlchemy ORM中,数据被封装为了声明式的数据模型,框架会自动映射数据表内的字段到模型中,我们操作数据其实就是操作数据模型的实例。这篇笔记我们详细学习SQLAlchemy中数据模型的定义。
模型基础使用
下面是SQLAlchemy中数据模型定义的一个例子。
CREATE TABLE t_user (
  id BIGINT NOT NULL AUTO_INCREMENT, 
  username VARCHAR(20) NOT NULL, 
  email VARCHAR(50) NOT NULL, 
  password VARCHAR(255) NOT NULL, 
  PRIMARY KEY (id)
);
from sqlalchemy import BigInteger, String
from sqlalchemy.orm import mapped_column, DeclarativeBase, Mapped
class Base(DeclarativeBase):
    pass
class User(Base):
    __tablename__ = 't_user'
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
    username: Mapped[str] = mapped_column(String(20), nullable=False)
    email: Mapped[str] = mapped_column(String(50))
    password: Mapped[str] = mapped_column(String(255), nullable=False)
SQLAlchemy中,数据模型类都要继承DeclarativeBase类。代码中,我们首先基于DeclarativeBase创建了声明式基类Base,这样我们后续的其它数据模型都可以继承这个Base来定义。创建Base类的意义在于集中管理元数据,这样编写后所有继承Base的模型类都可以通过Base.metadata统一管理,此外它可以提供通用的元数据管理、公共配置和扩展能力。
User类是我们自定义的数据模型,__tablename__属性指定了表名,其它属性对应于具体的数据表字段,字段对象需要用mapped_column()函数生成,其第一个参数是数据表中的字段类型,BigInteger通常在MySQL中将被映射为bigint(20),String(20)将被映射为varchar(20),以此类推;此外函数还支持许多命名参数,name可以指定与Python类属性名不同的列名(常用于支持特殊的数据库字段命名规范场景),primary_key用于设置主键字段,autoincrement用于设置主键是否自增,nullable用于指定该字段是否可以为空(默认为True),此外还有index指定索引列,unique指定列是否使用唯一索引。
注:SQLAlchemy中,主键字段如果是Integer或BigInteger的单列主键,当数据库支持自增主键时,SQLAlchemy默认就会设置自增主键,因此这里的autoincrement=True其实可以省略。虽然有些人不喜欢这种隐式的默认行为,但后文出于节约篇幅考虑,我们就不明确写出了。
id、username等属性我们都使用了Mapped[]进行类型标注,它们主要用于Python的类型检查器和IDE的智能提示,不会影响ORM框架的具体行为,因此这些类型标注是可选的,即使不标注代码也可以正常运行,不过一般来说还是推荐添加标注。
关于Python类型、SQLAlchemy类型和MySQL类型之间的关系可以参考下表。
| Python类型标注 | SQLAlchemy类型 | MySQL类型 | 
|---|---|---|
| Mapped[int] | SmallInteger() | SMALLINT | 
| Mapped[int] | Integer() | INT | 
| Mapped[int] | BigInteger() | BIGINT | 
| Mapped[str] | String(length) | VARCHAR(length) | 
| Mapped[str] | Text() | TEXT | 
| Mapped[float] | Float() | FLOAT | 
| Mapped[float] | Double() | DOUBLE | 
| Mapped[bool] | Boolean() | BOOL或TINYINT(1) | 
| Mapped[datetime] | DateTime() | DATETIME | 
| Mapped[datetime] | TIMESTAMP(timezone=True) | TIMESTAMP | 
| Mapped[date] | Date() | DATE | 
| Mapped[time] | Time() | TIME | 
| Mapped[bytes] | LargeBinary() | BLOB | 
| Mapped[Decimal] | Numeric(precision, scale) | DECIMAL(precision, scale) | 
| Mapped[dict] | JSON() | JSON | 
定义关联映射
SQLAlchemy中关联映射需要使用relationship()函数定义,不过和许多其它ORM框架不同,SQLAlchemy默认并不会自动创建外键,我们需要显式的使用ForeignKey()定义外键字段。下面我们分别介绍如何在SQLAlchemy中创建一对多、一对一和多对多关系。
一对多关联
下面例子中,我们创建了User用户和Order订单模型,一个用户可以对应多个订单,它们具有一对多关系。
from typing import List
from sqlalchemy import BigInteger, String, ForeignKey
from sqlalchemy.orm import mapped_column, DeclarativeBase, Mapped, relationship
class Base(DeclarativeBase):
    pass
class User(Base):
    __tablename__ = 't_user'
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    username: Mapped[str] = mapped_column(String(20), nullable=False)
    email: Mapped[str] = mapped_column(String(50))
    password: Mapped[str] = mapped_column(String(255), nullable=False)
    orders: Mapped[List['Order']] = relationship(back_populates='user', cascade='save-update, merge, delete')
class Order(Base):
    __tablename__ = 't_order'
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('t_user.id'))
    user: Mapped['User'] = relationship(back_populates='orders')
我们主要关注和关联映射相关的字段。
Order中的user_id是一个外键字段,ForeignKey指定该字段是一个外键,参数t_user.id指定了关联的表和字段名(注意这里不是数据模型的属性名)。
User中的orders属性我们采用了Mapped[List['Order']]类型标注,它表示这个字段的类型是Order对象的列表,relationship()定义了模型之间的映射关联关系,back_populates参数是关联的属性名(即关联对方的属性名),cascade用于定义级联操作,save-update, merge, delete表示处理级联更新和级联删除。
如果不设置cascade,默认的级联行为是save-update, merge,表示级联更新但不级联删除,当父记录被删除时关联记录的外键会被SQLAlchemy尝试设置为NULL,不过此时注意数据库外键字段需要定义为允许NULL,否则将报错。
# 允许外键字段为空
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey('t_user.id'), nullable=True)
cascade的可选值如下:
- save-update:当父对象被保存时,自动保存或更新关联的子对象
- merge:当父对象被合并到当前会话时,自动合并关联的子对象
- expunge:当父对象从会话中移除时,自动移除关联的子对象
- delete:当父对象被删除时,自动删除关联的子对象
- delete-orphan:当子对象不再与任何父对象关联时,自动删除该子对象
- refresh-expire:当父对象被刷新或过期时,自动刷新或过期关联的子对象
- all:包含以上除delete-orphan的所有级联操作
如果你不想搞得太复杂,记住在实际开发中,我们90%的情况都是使用默认的save-update, merge和支持级联删除的save-update, merge, delete,极特殊情况需要删除孤儿数据时使用save-update, merge, delete, delete-orphan。对于其它级联选项,我们需要充分了解其含义再使用,避免错误使用。
Order中的user也使用了relationship(),这样两个模型就形成了双向的关联关系。对于双向关联,cascade应该放在拥有者对象里,即一对多中的集合端(也就是“一”的一侧),表示关联关系中父对象删除和更新时的级联行为。将cascade放在另一端或是两端都放置虽然符合代码语法,但不符合现实数据记录的操作逻辑,通常都是错误的。
关于外键,细心的同学可能会发现,我们没有为外键特别指定ON DELETE和ON UPDATE的级联行为,通过观察数据库内的表结构可以验证这一点,但实际试验你会发现save-update, merge, delete指定的级联更新和级联删除操作都会被正确执行。这是因为SQLAlchemy会在应用层面进行处理,以级联删除为例,实际执行的SQL是先查询并删除关联记录,再删除父记录。
另外relationship()还有一个可配置的lazy属性,它用于设置关联对象的懒加载行为:
- select:默认,访问关联属性时,执行一个单独的SELECT查询
- immediate:访问父对象时,立即加载关联数据
- joined:使用JOIN语句在查询父对象时一次性加载关联数据
- subquery:使用子查询在查询父对象时一次性加载关联数据
- dynamic:返回一个查询对象,可以进一步筛选数据
- noload:永远不加载该属性,即使访问也不会触发查询
- raise:访问该属性时,如果未加载,会抛出InvalidRequestError错误
实际开发中,我们主要使用默认是select和立即加载的immediate配置,其它选项较少使用。
一对一关联
一对一关联就是一种特殊的一对多关联,它的写法和一对多完全一致,我们使用relationship()定义关联关系,配合ForeignKey指定外键即可。对于cascade操作的定义也完全和一对多一致,我们还是需要将其放置在拥有者对象里,并正确设置级联操作模式。
多对多关联
多对多关联相比一对多要复杂一些,多对多涉及中间表。下面例子代码中,角色Role和权限Permission具有多对多关系,一个角色可能包含多个权限,一个权限也可能被多个角色引用。
from typing import List
from sqlalchemy import BigInteger, String, ForeignKey, Table, Column
from sqlalchemy.orm import mapped_column, DeclarativeBase, Mapped, relationship
class Base(DeclarativeBase):
    pass
role_permission = Table(
    't_role_permission',
    Base.metadata,
    Column('role_id', ForeignKey('t_role.id'), primary_key=True),
    Column('permission_id', ForeignKey('t_permission.id'), primary_key=True),
)
class Role(Base):
    __tablename__ = 't_role'
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    code: Mapped[str] = mapped_column(String(20), nullable=False)
    name: Mapped[str] = mapped_column(String(20), nullable=False)
    permissions: Mapped[List['Permission']] = relationship(secondary=role_permission, back_populates='roles')
class Permission(Base):
    __tablename__ = 't_permission'
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    code: Mapped[str] = mapped_column(String(20), nullable=False)
    name: Mapped[str] = mapped_column(String(20), nullable=False)
    roles: Mapped[List['Role']] = relationship(secondary=role_permission, back_populates='permissions')
多对多中间表并不是一个“数据模型”,虽然我们确实可以很牵强的将中间表定义为数据模型类,但SQLAlchemy提供了更好的方式处理这个问题,SQLAlchemy Core中支持直接定义数据表的底层元数据信息,role_permission就是这样的一个对象,代码中我们指定了表名、元数据基类和两个外键列名。
Role和Permission中,我们分别使用relationship()定义了关联关系,但这里我们使用的是secondary指定了中间关联元数据对象,back_populates则和之前一样,指定了关联对方的关联属性。多对多中较少使用级联删除,默认配置save-update, merge下,当多对多的一方被删除时,被删除记录和中间表记录会被清理,但关联的记录不会被清理,因为多对多中关联记录还可能被其它记录关联。
单向关联和双向关联
前面我们定义的全部都是双向关联,如果只需要单向关联,那么我们只在一方使用relationship()即可。
执行DDL操作
前面我们已经定义了数据模型,SQLAlchemy支持基于数据模型自动建表或删除表。
from sqlalchemy import create_engine
from models import Base
if __name__ == '__main__':
    DATABASE_URL = 'mysql+pymysql://root:root@localhost:3306/netstore'
    engine = create_engine(DATABASE_URL, echo=True, future=True)
    Base.metadata.create_all(engine)
代码中我们用到了之前定义的基类Base。由于我们所有的数据模型都继承自这个基类,因此框架运行后它会自动通过反射找到所有的模型类的元数据并注册到Base的metadata类属性,我们可以在其上调用create_all()方法自动建表,该方法会在表不存在时自动创建,对于已存在的表则不会做额外操作。此外,也可以使用drop_all()方法删除所有表。
不过在实际开发中,我们通常不会直接这样调用create_all()建表。SQLAlchemy支持Alembic数据迁移工具,如果采用Code First开发模式,我们可以使用Alembic生成增量的数据迁移脚本,使用数据迁移工具操作表结构和数据更加可靠。