codecamp

5.1 统一数据库命名与约束

统一数据库命名与约束

当文章数据还在 Python 列表里时,重复邮箱、无效作者 ID 和负数阅读时长都只能靠代码分支拦截。一旦有两个进程同时写入,应用层的“先查询再插入”就可能在检查和写入之间失效。数据库约束属于最终写入边界,应该和表结构一起设计,并且给每个约束一个能在错误日志和迁移文件中找到的名字。

本节为贯穿案例建立 usersposts 两张表。示例选择小写蛇形命名和复数表名,是本教程的团队约定;单数表名也可以成立,关键是同一项目持续使用一种规则。时间字段使用 _at 后缀,作者关系统一使用 author_id,避免在同一关系中混用 creator_idprofile_id 等含义不同的名字。

先设计最小关系和约束

案例中的约束分工如下:

对象 约束 要保证的事实
users.idposts.id 主键 每行都有稳定的唯一标识,且不能为 NULL
users.emailposts.slug 唯一约束 同一表中不能出现重复值
posts.author_id 外键 文章作者必须存在于 users
posts.title 检查约束 标题不能是空字符串
posts.reading_time_minutes 检查约束 阅读时长在 1 到 120 分钟之间
posts.author_id, posts.created_at 索引 支持作者文章列表按时间查询

SQLAlchemy 2 的声明式映射使用 DeclarativeBaseMappedmapped_columnMetaData.naming_convention 会在 Python 构造约束时生成名称,Alembic 后续生成候选迁移时也能沿用这些名称。ck 模板使用了 %(constraint_name)s,所以每个 CheckConstraint 必须显式给出名字;省略名字会在模型导入阶段报错,而不是等数据库建表时才发现。

<!-- file: ch05_schema/models.py -->

from __future__ import annotations


from datetime import datetime


from sqlalchemy import (
    CheckConstraint,
    DateTime,
    ForeignKey,
    Index,
    MetaData,
    String,
    Text,
)
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.schema import CreateIndex, CreateTable




NAMING_CONVENTION = {
    "ix": "ix_%(column_0_label)s",
    "uq": "uq_%(table_name)s_%(column_0_name)s",
    "ck": "ck_%(table_name)s_%(constraint_name)s",
    "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
    "pk": "pk_%(table_name)s",
}




class Base(DeclarativeBase):
    metadata = MetaData(naming_convention=NAMING_CONVENTION)




class User(Base):
    __tablename__ = "users"


    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(320), nullable=False, unique=True)
    display_name: Mapped[str] = mapped_column(
        String(80), nullable=False, index=True
    )
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), nullable=False
    )




class Post(Base):
    __tablename__ = "posts"
    __table_args__ = (
        CheckConstraint("length(title) > 0", name="title_not_empty"),
        CheckConstraint(
            "reading_time_minutes BETWEEN 1 AND 120",
            name="reading_time_minutes_range",
        ),
        Index("ix_posts_author_created_at", "author_id", "created_at"),
    )


    id: Mapped[int] = mapped_column(primary_key=True)
    slug: Mapped[str] = mapped_column(String(160), nullable=False, unique=True)
    title: Mapped[str] = mapped_column(String(120), nullable=False)
    content: Mapped[str] = mapped_column(Text, nullable=False)
    reading_time_minutes: Mapped[int] = mapped_column(nullable=False)
    author_id: Mapped[int] = mapped_column(
        ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
    )
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), nullable=False
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), nullable=False
    )




def self_check() -> None:
    users = Base.metadata.tables["users"]
    posts = Base.metadata.tables["posts"]
    assert set(Base.metadata.tables) == {"users", "posts"}


    user_constraints = {constraint.name for constraint in users.constraints}
    post_constraints = {constraint.name for constraint in posts.constraints}
    assert "pk_users" in user_constraints
    assert "uq_users_email" in user_constraints
    assert "pk_posts" in post_constraints
    assert "uq_posts_slug" in post_constraints
    assert "ck_posts_title_not_empty" in post_constraints
    assert "ck_posts_reading_time_minutes_range" in post_constraints
    assert "fk_posts_author_id_users" in post_constraints


    dialect = postgresql.dialect()
    ddl = "\n".join(
        str(CreateTable(table).compile(dialect=dialect))
        for table in Base.metadata.sorted_tables
    )
    indexes = "\n".join(
        str(CreateIndex(index).compile(dialect=dialect))
        for table in Base.metadata.sorted_tables
        for index in table.indexes
    )
    assert "CONSTRAINT pk_users PRIMARY KEY" in ddl
    assert "CONSTRAINT fk_posts_author_id_users" in ddl
    assert "CREATE INDEX ix_posts_author_created_at" in indexes
    assert "CREATE INDEX ix_users_display_name" in indexes
    print("models self-check passed; PostgreSQL DDL compiled without a database")




if __name__ == "__main__":
    self_check()

在已经安装 SQLAlchemy 的环境中运行:

python ch05_schema/models.py

这个检查只使用 PostgreSQL 方言编译 DDL,并没有连接 PostgreSQL,也没有真正创建表。它能发现模型导入、命名约定和生成 SQL 的问题;约束是否由服务器执行,仍需在隔离的 PostgreSQL 数据库中运行迁移和失败案例。

为什么不能只在应用层预检查

应用层可以先给用户更友好的提示,但它不能代替数据库约束。例如两个并发请求同时查询“这个 slug 是否存在”,两次查询都可能得到不存在,随后同时尝试插入。唯一约束会让其中一个写入成功,另一个收到数据库冲突;服务层应该捕获这个冲突并转换成约定的 409,而不是把预检查当成保证。

外键和检查约束也有同样的边界。posts.author_id=999 在应用层看似可以先查作者,但另一个事务可能在检查后删除作者;外键仍然是最终关系完整性边界。reading_time_minutes=0 可以在 Pydantic 中提前拒绝,但数据库约束保证任何写入路径(接口、脚本、后台任务)都不能绕过这个规则。

在独立的 PostgreSQL 练习数据库中,可以用以下语句观察三类失败。先通过正常迁移创建表并插入一名作者,再逐条执行;每条语句的约束名会出现在 PostgreSQL 错误中:

<!-- file: ch05_schema/constraint_failures.sql -->

-- 重复值:SQLSTATE 23505,命中 uq_users_email 或 uq_posts_slug
INSERT INTO users (id, email, display_name, created_at)
VALUES (2, 'alice@example.com', '另一个 Alice', CURRENT_TIMESTAMP);


-- 无效引用:SQLSTATE 23503,命中 fk_posts_author_id_users
INSERT INTO posts (
    id, slug, title, content, reading_time_minutes, author_id,
    created_at, updated_at
)
VALUES (
    2, 'missing-author', '无效作者', '正文', 5, 999,
    CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
);


-- 检查失败:SQLSTATE 23514,命中 ck_posts_reading_time_minutes_range
INSERT INTO posts (
    id, slug, title, content, reading_time_minutes, author_id,
    created_at, updated_at
)
VALUES (
    3, 'bad-reading-time', '错误时长', '正文', 0, 1,
    CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
);

不要在真实业务库中直接执行这些失败语句。数据库约束名称、SQLSTATE 和错误映射应在测试中固定,正文示例只说明需要验证的事实,本地的模型自检不应被描述成已经完成了 PostgreSQL 集成验证。

约束名称要服务于迁移和排错

命名约定不是为了让每个名字都更长,而是为了让错误和迁移可定位。uq_posts_slug 一眼能说明表和字段,fk_posts_author_id_users 能说明关系方向,ck_posts_reading_time_minutes_range 能提示失败条件。PostgreSQL 对标识符长度有上限,SQLAlchemy 会对过长的约定名称做确定性截断;团队仍应避免生成难以阅读的超长组合名称。

primary_key=True 同时表达主键唯一和非空语义;nullable=False 解决字段本身不可为空,但不会替代跨行唯一性或跨表引用。需要给多列组合设唯一性时,使用明确的 UniqueConstraint,不要分别给两列加 unique=True,因为两种约束的业务含义不同。

资料来源

4.4 用一致的资源路径支持依赖复用
5.2 管理会话事务与失败回滚
温馨提示
下载编程狮App,免费阅读超1000+编程语言教程
取消
确定
目录

关闭

MIP.setData({ 'pageTheme' : getCookie('pageTheme') || {'day':true, 'night':false}, 'pageFontSize' : getCookie('pageFontSize') || 20 }); MIP.watch('pageTheme', function(newValue){ setCookie('pageTheme', JSON.stringify(newValue)) }); MIP.watch('pageFontSize', function(newValue){ setCookie('pageFontSize', newValue) }); function setCookie(name, value){ var days = 1; var exp = new Date(); exp.setTime(exp.getTime() + days*24*60*60*1000); document.cookie = name + '=' + value + ';expires=' + exp.toUTCString(); } function getCookie(name){ var reg = new RegExp('(^| )' + name + '=([^;]*)(;|$)'); return document.cookie.match(reg) ? JSON.parse(document.cookie.match(reg)[2]) : null; }