Mypy / Pep-484 对 ORM 映射的支持

支持 PEP 484 类型注解以及 MyPy 类型检查工具,当使用 SQLAlchemy 声明式 映射直接引用 Column 对象,而不是 SQLAlchemy 2.0 中引入的 mapped_column() 结构。

自版本 2.0 起已弃用: SQLAlchemy Mypy 插件已弃用,可能会在 SQLAlchemy 2.1 版本中移除。我们强烈建议用户尽快迁移。mypy 插件仅在 mypy 版本 1.10.1 之前有效。版本 1.11.0 及更高版本可能无法正常工作。

此插件无法在不断变化的 mypy 版本中进行维护,其未来的稳定性无法保证。

现代 SQLAlchemy 现在提供了 完全符合 pep-484 的映射语法;有关迁移详细信息,请参阅链接部分。

安装

仅适用于SQLAlchemy 2.0:不应安装任何存根文件,如 sqlalchemy-stubssqlalchemy2-stubs 等软件包应该完全卸载。

Mypy 包本身是一个依赖项。

可以使用 pip 的“mypy”额外钩子安装 Mypy

pip install sqlalchemy[mypy]

插件本身的配置如 配置 mypy 使用插件 中所述,使用 sqlalchemy.ext.mypy.plugin 模块名,例如在 setup.cfg

[mypy]
plugins = sqlalchemy.ext.mypy.plugin

插件的功能

Mypy 插件的主要目的是拦截和更改 SQLAlchemy 声明式映射 的静态定义,使其与它们被 工具化 后的结构相匹配,由它们的 Mapper 对象完成。这使类结构本身以及使用该类的代码对 Mypy 工具有意义,否则基于声明式映射当前的运作方式,情况并非如此。该插件类似于类似的插件,这些插件是针对像 dataclasses 这样的库所必需的,这些库在运行时动态更改类。

为了涵盖发生这种情况的主要区域,请考虑以下 ORM 映射,使用典型的 User 类示例

from sqlalchemy import Column, Integer, String, select
from sqlalchemy.orm import declarative_base

# "Base" is a class that is created dynamically from the
# declarative_base() function
Base = declarative_base()


class User(Base):
    __tablename__ = "user"

    id = Column(Integer, primary_key=True)
    name = Column(String)


# "some_user" is an instance of the User class, which
# accepts "id" and "name" kwargs based on the mapping
some_user = User(id=5, name="user")

# it has an attribute called .name that's a string
print(f"Username: {some_user.name}")

# a select() construct makes use of SQL expressions derived from the
# User class itself
select_stmt = select(User).where(User.id.in_([3, 4, 5])).where(User.name.contains("s"))

上面,Mypy 扩展可以采取的步骤包括

  • 解释由 declarative_base() 生成的 Base 动态类,以便继承它的类被识别为已映射。它还可以适应在 使用装饰器进行声明式映射(没有声明式基类) 中描述的类装饰器方法。

  • 对在声明式“内联”样式中定义的 ORM 映射属性进行类型推断,在上面的示例中,是 User 类的 idname 属性。这包括 User 的实例将对 id 使用 int,对 name 使用 str。它还包括在访问 User.idUser.name 类级属性时,如上面的 select() 语句中,它们与 SQL 表达式行为兼容,该行为源自 InstrumentedAttribute 属性描述符类。

  • 对没有明确构造函数的映射类应用 __init__() 方法,该方法接受所有检测到的映射属性的特定类型的关键字参数。

当 Mypy 插件处理上述文件时,传递给 Mypy 工具的结果静态类定义和 Python 代码等效于以下代码

from sqlalchemy import Column, Integer, String, select
from sqlalchemy.orm import Mapped
from sqlalchemy.orm.decl_api import DeclarativeMeta


class Base(metaclass=DeclarativeMeta):
    __abstract__ = True


class User(Base):
    __tablename__ = "user"

    id: Mapped[Optional[int]] = Mapped._special_method(
        Column(Integer, primary_key=True)
    )
    name: Mapped[Optional[str]] = Mapped._special_method(Column(String))

    def __init__(self, id: Optional[int] = ..., name: Optional[str] = ...) -> None: ...


some_user = User(id=5, name="user")

print(f"Username: {some_user.name}")

select_stmt = select(User).where(User.id.in_([3, 4, 5])).where(User.name.contains("s"))

上面采取的关键步骤包括

  • Base 类现在明确地以 DeclarativeMeta 类定义,而不是动态类。

  • idname 属性以 Mapped 类定义,它表示一个 Python 描述符,在类级和实例级表现出不同的行为。 Mapped 类现在是 InstrumentedAttribute 类的基类,该类用于所有 ORM 映射属性。

    Mapped 被定义为针对任意 Python 类型的泛型类,这意味着 Mapped 的特定实例与特定 Python 类型相关联,例如上面的 Mapped[Optional[int]]Mapped[Optional[str]]

  • 声明式映射属性赋值的右侧被 **移除**,因为这类似于 Mapper 类通常执行的操作,即它将用 InstrumentedAttribute 的特定实例替换这些属性。原始表达式被移入一个函数调用中,这将允许它在不与表达式左侧冲突的情况下进行类型检查。出于 Mypy 的目的,左侧的类型注释足以理解属性的行为。

  • User.__init__() 方法添加了一个类型存根,其中包含正确的关键字和数据类型。

用法

以下小节将讨论迄今为止针对 pep-484 兼容性考虑的各个使用案例。

基于 TypeEngine 的列内省

对于包含显式数据类型的映射列,当它们被映射为内联属性时,映射类型将被自动内省

class MyClass(Base):
    # ...

    id = Column(Integer, primary_key=True)
    name = Column("employee_name", String(50), nullable=False)
    other_name = Column(String(50))

上面,idnameother_name 的最终类级数据类型将被内省为 Mapped[Optional[int]]Mapped[Optional[str]]Mapped[Optional[str]]。默认情况下,类型 **始终** 被认为是 Optional,即使是主键和非空列也是如此。原因是,虽然数据库列“id”和“name”不能为 NULL,但 Python 属性 idname 在没有显式构造函数的情况下,肯定可以为 None

>>> m1 = MyClass()
>>> m1.id
None

上面列的类型可以 **明确** 说明,提供了更清晰的自文档以及能够控制哪些类型是可选的两个优点

class MyClass(Base):
    # ...

    id: int = Column(Integer, primary_key=True)
    name: str = Column("employee_name", String(50), nullable=False)
    other_name: Optional[str] = Column(String(50))

Mypy 插件将接受上面的 intstrOptional[str] 并将其转换为包含围绕它们的 Mapped[] 类型。 Mapped[] 结构也可以显式使用

from sqlalchemy.orm import Mapped


class MyClass(Base):
    # ...

    id: Mapped[int] = Column(Integer, primary_key=True)
    name: Mapped[str] = Column("employee_name", String(50), nullable=False)
    other_name: Mapped[Optional[str]] = Column(String(50))

当类型是非可选的时,它仅仅意味着从 MyClass 实例访问的属性将被认为是非 None

mc = MyClass(...)

# will pass mypy --strict
name: str = mc.name

对于可选属性,Mypy 认为该类型必须包含 None 或者已经是 Optional

mc = MyClass(...)

# will pass mypy --strict
other_name: Optional[str] = mc.name

无论映射属性是否被键入为 Optional__init__() 方法的生成 **仍然会将所有关键字视为可选**。这再次与 SQLAlchemy ORM 在创建构造函数时实际执行的操作相匹配,不应将其与 Python dataclasses 等验证系统的行为混淆,该系统将生成与注释在可选属性与必填属性方面相匹配的构造函数。

没有显式类型的列

包含 ForeignKey 修饰符的列不需要在 SQLAlchemy 声明式映射中指定数据类型。对于这种类型的属性,Mypy 插件将通知用户它需要发送显式类型

# .. other imports
from sqlalchemy.sql.schema import ForeignKey

Base = declarative_base()


class User(Base):
    __tablename__ = "user"

    id = Column(Integer, primary_key=True)
    name = Column(String)


class Address(Base):
    __tablename__ = "address"

    id = Column(Integer, primary_key=True)
    user_id = Column(ForeignKey("user.id"))

插件将以如下方式传递消息

$ mypy test3.py --strict
test3.py:20: error: [SQLAlchemy Mypy plugin] Can't infer type from
ORM mapped expression assigned to attribute 'user_id'; please specify a
Python type or Mapped[<python type>] on the left hand side.
Found 1 error in 1 file (checked 1 source file)

要解决此问题,请对 Address.user_id 列应用显式类型注释

class Address(Base):
    __tablename__ = "address"

    id = Column(Integer, primary_key=True)
    user_id: int = Column(ForeignKey("user.id"))

使用命令式表映射列

命令式表样式 中,Column 定义在与映射属性本身分离的 Table 结构中。Mypy 插件不会考虑这个 Table,而是支持属性可以通过显式声明来指定完整的注释,该注释 **必须** 使用 Mapped 类来标识它们为映射属性

class MyClass(Base):
    __table__ = Table(
        "mytable",
        Base.metadata,
        Column(Integer, primary_key=True),
        Column("employee_name", String(50), nullable=False),
        Column(String(50)),
    )

    id: Mapped[int]
    name: Mapped[str]
    other_name: Mapped[Optional[str]]

上面的 Mapped 注释被视为映射列,并将包含在默认构造函数中,并为 MyClass 提供正确的类型配置文件,无论是在类级别还是实例级别。

映射关系

插件对使用类型推断来检测关系的类型支持有限。对于所有无法检测类型的案例,它将发出信息性错误消息,并且在所有情况下,都可以显式提供适当的类型,可以使用 Mapped 类或选择性地省略它以进行内联声明。插件还需要确定关系是引用集合还是标量,为此它依赖于 relationship.uselist 和/或 relationship.collection_class 参数的显式值。如果这两个参数都不存在,则需要显式类型,并且如果 relationship() 的目标类型是字符串或可调用对象,而不是类,则也需要显式类型

class User(Base):
    __tablename__ = "user"

    id = Column(Integer, primary_key=True)
    name = Column(String)


class Address(Base):
    __tablename__ = "address"

    id = Column(Integer, primary_key=True)
    user_id: int = Column(ForeignKey("user.id"))

    user = relationship(User)

上面的映射将产生以下错误

test3.py:22: error: [SQLAlchemy Mypy plugin] Can't infer scalar or
collection for ORM mapped expression assigned to attribute 'user'
if both 'uselist' and 'collection_class' arguments are absent from the
relationship(); please specify a type annotation on the left hand side.
Found 1 error in 1 file (checked 1 source file)

可以通过使用 relationship(User, uselist=False) 或者提供类型来解决错误,在本例中是标量 User 对象

class Address(Base):
    __tablename__ = "address"

    id = Column(Integer, primary_key=True)
    user_id: int = Column(ForeignKey("user.id"))

    user: User = relationship(User)

对于集合,类似的模式适用,在没有 uselist=Truerelationship.collection_class 的情况下,可以使用集合注释,例如 List。在注释中使用类的字符串名称也是完全合适的,如 pep-484 支持的那样,确保类在 TYPE_CHECKING 块 中根据需要导入

from typing import TYPE_CHECKING, List

from .mymodel import Base

if TYPE_CHECKING:
    # if the target of the relationship is in another module
    # that cannot normally be imported at runtime
    from .myaddressmodel import Address


class User(Base):
    __tablename__ = "user"

    id = Column(Integer, primary_key=True)
    name = Column(String)
    addresses: List["Address"] = relationship("Address")

与列一样,Mapped 类也可以显式应用

class User(Base):
    __tablename__ = "user"

    id = Column(Integer, primary_key=True)
    name = Column(String)

    addresses: Mapped[List["Address"]] = relationship("Address", back_populates="user")


class Address(Base):
    __tablename__ = "address"

    id = Column(Integer, primary_key=True)
    user_id: int = Column(ForeignKey("user.id"))

    user: Mapped[User] = relationship(User, back_populates="addresses")

使用 @declared_attr 和声明式 Mixin

declared_attr 类允许在类级别函数中声明声明式映射属性,在使用 声明式 Mixin 时特别有用。对于这些函数,函数的返回类型应使用 Mapped[] 结构进行注释,或者通过指示函数返回的确切对象类型进行注释。此外,那些没有被映射的其他“Mixin”类(即没有扩展自 declarative_base() 类或没有使用 registry.mapped() 等方法映射的类)应该使用 declarative_mixin() 装饰器进行装饰,该装饰器为 Mypy 插件提供提示,表明特定类打算用作声明式 Mixin

from sqlalchemy.orm import declarative_mixin, declared_attr


@declarative_mixin
class HasUpdatedAt:
    @declared_attr
    def updated_at(cls) -> Column[DateTime]:  # uses Column
        return Column(DateTime)


@declarative_mixin
class HasCompany:
    @declared_attr
    def company_id(cls) -> Mapped[int]:  # uses Mapped
        return mapped_column(ForeignKey("company.id"))

    @declared_attr
    def company(cls) -> Mapped["Company"]:
        return relationship("Company")


class Employee(HasUpdatedAt, HasCompany, Base):
    __tablename__ = "employee"

    id = Column(Integer, primary_key=True)
    name = Column(String)

请注意,例如 HasCompany.company 这样的方法的实际返回类型与注释的返回类型之间的不匹配。Mypy 插件将所有 @declared_attr 函数转换为简单的注释属性,以避免这种复杂性

# what Mypy sees
class HasCompany:
    company_id: Mapped[int]
    company: Mapped["Company"]

与 Dataclasses 或其他类型敏感属性系统结合

将 ORM 映射应用于现有 dataclass(旧版 dataclass 使用) 中的 Python dataclasses 集成示例提出一个问题;Python dataclasses 期望一个显式类型,它将使用该类型来构建类,并且每个赋值语句中给出的值都意义重大。也就是说,以下类必须完全按照它所写的方式声明,才能被 dataclasses 接受

mapper_registry: registry = registry()


@mapper_registry.mapped
@dataclass
class User:
    __table__ = Table(
        "user",
        mapper_registry.metadata,
        Column("id", Integer, primary_key=True),
        Column("name", String(50)),
        Column("fullname", String(50)),
        Column("nickname", String(12)),
    )
    id: int = field(init=False)
    name: Optional[str] = None
    fullname: Optional[str] = None
    nickname: Optional[str] = None
    addresses: List[Address] = field(default_factory=list)

    __mapper_args__ = {  # type: ignore
        "properties": {"addresses": relationship("Address")}
    }

我们不能将 Mapped[] 类型应用于属性 idname 等,因为它们将被 @dataclass 装饰器拒绝。此外,Mypy 为 dataclasses 拥有另一个显式插件,它也会阻碍我们正在做的事情。

上面的类实际上将通过 Mypy 的类型检查而不会出现问题;我们唯一缺少的是能够在 SQL 表达式中使用 User 上的属性,例如

stmt = select(User.name).where(User.id.in_([1, 2, 3]))

为了提供解决方法,Mypy 插件提供了一个额外的功能,我们可以指定一个额外的属性 _mypy_mapped_attrs,它是一个包含类级对象或其字符串名称的列表。这个属性可以在 TYPE_CHECKING 变量中进行条件设置。

@mapper_registry.mapped
@dataclass
class User:
    __table__ = Table(
        "user",
        mapper_registry.metadata,
        Column("id", Integer, primary_key=True),
        Column("name", String(50)),
        Column("fullname", String(50)),
        Column("nickname", String(12)),
    )
    id: int = field(init=False)
    name: Optional[str] = None
    fullname: Optional[str]
    nickname: Optional[str]
    addresses: List[Address] = field(default_factory=list)

    if TYPE_CHECKING:
        _mypy_mapped_attrs = [id, name, "fullname", "nickname", addresses]

    __mapper_args__ = {  # type: ignore
        "properties": {"addresses": relationship("Address")}
    }

使用上述方法,在 _mypy_mapped_attrs 中列出的属性将应用 Mapped 类型信息,使得 User 类在类绑定环境中使用时,表现为一个 SQLAlchemy 映射类。