用 Litestar Repository 构建作者 CRUD,并扩展示例 Slug

原文:Litestar 官方 SQLAlchemy Repository 教程,由 Litestar-Org 维护,页面未列个人作者。本文合并翻译与整理四章:建模与功能、仓库操作、控制器、扩展功能。中文整理:未完纪;全文核查日期:2026-10-05。

Repository 把常用数据库操作集中到一个对象:路由负责 HTTP 输入输出,仓库负责查找、写入与过滤,SQLAlchemy 模型描述持久化结构。这个系列从作者和书籍的一对多关系开始,逐步建立异步仓库、分页 CRUD 控制器,再给博客记录增加可读的 URL 标识。

版本与验证边界:源文位于 Litestar /2/ 文档树,使用 SQLAlchemy 2 风格、Pydantic 2 API 和 Litestar 的 SQLAlchemy/Advanced Alchemy 集成。源文没有固定完整依赖锁文件,本文也不虚构一组已经测试通过的版本。实际使用前须固定相互兼容的 Litestar 2、Advanced Alchemy、SQLAlchemy、Pydantic 与数据库驱动版本。下文所有代码仅经静态审查,未安装依赖、启动服务器或执行数据库写入。

请求经过输入模型和控制器,经依赖注入取得Repository及数据库会话,写入Author与Book;事务成功提交、失败回滚再抛出;Slug最终依靠数据库唯一约束。
未完纪绘制:Repository 的职责、事务边界与 Slug 唯一性。不是运行截图。

一、从模型开始

base.UUIDBase 提供 UUID 主键 id,作者表再增加姓名与出生日期。书籍从 UUIDAuditBase 继承同样的主键,以及 created_at、updated_at 时间戳。书籍通过 author_id 外键关联作者,作者可以对应多本书:

class AuthorModel(base.UUIDBase):
    __tablename__ = "author"
    name: Mapped[str]
    dob: Mapped[date | None]
    books: Mapped[list[BookModel]] = relationship(
        back_populates="author", lazy="noload"
    )

class BookModel(base.UUIDAuditBase):
    __tablename__ = "book"
    title: Mapped[str]
    author_id: Mapped[UUID] = mapped_column(ForeignKey("author.id"))
    author: Mapped[AuthorModel] = relationship(
        lazy="joined", innerjoin=True, viewonly=True
    )

最早建模章节对作者使用 lazy="selectin";控制器章节改为默认 noload,在详情查询中显式用 selectinload 加载书籍。不能把不同章节的模型定义重复粘贴进同一个模块。viewonly=True 的关联用于读取,不是修改关系的通用入口;是否级联删除书籍也要显式决定,原例未给出业务删除策略。

如果数据库适合整数主键,还可以使用 BigIntBase、BigIntAuditBase。原文特别提醒 Spanner 不适合单调递增主键,且未提供与 Sequence 对应的机制,因此当时的这两类整数基类不支持 Spanner。其他辅助功能包括从模型名推导蛇形表名、跨驱动 GUID、BigIntIdentity、JsonB 以及 EncryptedString。原文列举 Postgres、SQLite、MySQL、DuckDB、Oracle、Spanner 为支持引擎;这不是本文对这些后端做过兼容或性能测试的声明。

二、建立 Repository 并理解它提供什么

class AuthorRepository(repository.SQLAlchemyAsyncRepository[AuthorModel]):
    model_type = AuthorModel

指定模型以后即可取得常用操作。同步版本是 SQLAlchemySyncRepository;同步调用不用 await,但是不能把阻塞数据库操作随意塞进事件循环。原文也给出同步控制器版本,实际项目需按 Litestar 的线程配置处理同步路由。

方法或方法族 用途
get 按主键取一条;不存在时抛异常。
get_one / get_one_or_none 按关键字条件查一条;前者缺失时报错,后者返回 None。
list / list_and_count 按条件与过滤器取列表;后者同时返回列表和总数。
get_or_create 查找或创建,还可指定匹配字段与合并更新行为;以安装版本 API 为准。
add / add_many 原文实际示例使用的单条、批量新增方法;概览表另使用 create/create_many 名称,不能据此认定所有版本都提供同名别名。
update / update_many 更新单条或多条。
upsert / upsert_many 根据对象标识与具体实现完成插入或更新。
delete / delete_many / count 删除指定标识对应的记录,或统计记录数。

原文说明批量 DML 会利用驱动支持的优化与多行 RETURNING,并按 SQL 引擎的 IN 参数限制分批。调用批量 API 不代表可以无限扩大一次事务;仍要关注数据库限制、锁、内存与应用端的请求大小。

脚本中的事务:回滚后必须让错误继续传播

第二章用 asynccontextmanager 管理会话,成功后提交,异常时回滚。但源例的 except Exception 只执行 rollback,没有重新抛出异常,可能让调用方误以为操作成功。下面是编辑修正版;提交也放在 try 中,使提交失败能进入回滚分支:

@asynccontextmanager
async def repository_factory() -> AsyncIterator[AuthorRepository]:
    async with session_factory() as db_session:
        try:
            yield AuthorRepository(session=db_session)
            await db_session.commit()
        except Exception:
            await db_session.rollback()
            raise

它假定项目已配置 session_factory,并导入 asynccontextmanager、AsyncIterator。会话生命周期仍由异步上下文管理器关闭。取消与数据库驱动的异常传播需要在实际应用测试,不能因为加了一个 raise 就认定覆盖全部失败场景。

原文脚本先新增 F. Scott Fitzgerald,更新逝世日期,再删除同一记录,最后用 get_one_or_none 查询以展示返回 None。删除是有副作用的演示,不应对已有数据库执行。本文只说明流程,没有运行它。

批量 fixture、分页与删除

第二章另用美国州与地区的名称/缩写 JSON 作为 fixture,建立 USState 和同步仓库,在内存 DuckDB 中调用 add_many 批量加入记录,再查询前十条:

# 片段:repo、db_session、fixture 已由前面的设置取得。
objects = repo.add_many([USState(**row) for row in fixture])
db_session.commit()
rows, total = repo.list_and_count(LimitOffset(limit=10, offset=0))
# 原文随后用 rows 的 id 批量删除,并 count() 查看剩余行数。

原文 fixture 读取函数把目录与 fixture_name + ".json" 拼成路径,按 UTF-8 打开并用 json.loads 解析。如果只传程序内固定 fixture 名称,这就是普通本地样例加载;若改成接受外部输入,应限制文件名和解析后路径在指定目录内,并限制文件大小与记录结构,防止目录穿越或过量加载。不要把它直接变成任意文件读取接口。

批量删除示例展示 delete_many([obj.id ...]) 后计数;事务内能看见删除不表示删除已经永久提交。是否提交或回滚需要由明确的会话边界决定,本文不提供针对真实数据的删除命令。

三、把仓库注入完整的 CRUD 控制器

SQLAlchemy 插件管理会话依赖。一个 provider 接收 db_session,返回使用该会话的作者仓库;详情版本可传入额外查询选项。分页 provider 把页码与页大小转换为 LimitOffset:offset 等于 page_size × (current_page - 1)。

下面是一份按第三章完整异步示例整理的单文件教学代码。保留作者/书籍模型、会话依赖、分页、五个 CRUD 路由与建表初始化;编辑修改为内存 SQLite,分页最多 100 条,输入拒绝额外字段,PATCH 区分未提供与显式 null,并统一使用 Pydantic 2 的 model_validate。这些修改尚未执行验证。

from __future__ import annotations

from datetime import date
from typing import Annotated
from uuid import UUID

from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, model_validator
from sqlalchemy import ForeignKey, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column, relationship, selectinload

from litestar import Litestar, get, post, patch, delete
from litestar.controller import Controller
from litestar.di import Provide
from litestar.pagination import OffsetPagination
from litestar.params import QueryParameter
from litestar.plugins.sqlalchemy import (
    AsyncSessionConfig, SQLAlchemyAsyncConfig,
    SQLAlchemyInitPlugin, base, filters, repository,
)


class AuthorModel(base.UUIDBase):
    __tablename__ = "author"
    name: Mapped[str]
    dob: Mapped[date | None]
    books: Mapped[list[BookModel]] = relationship(
        back_populates="author", lazy="noload"
    )


class BookModel(base.UUIDAuditBase):
    __tablename__ = "book"
    title: Mapped[str]
    author_id: Mapped[UUID] = mapped_column(ForeignKey("author.id"))
    author: Mapped[AuthorModel] = relationship(
        lazy="joined", innerjoin=True, viewonly=True
    )


class Author(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: UUID
    name: str
    dob: date | None = None


class AuthorCreate(BaseModel):
    model_config = ConfigDict(extra="forbid")
    name: str = Field(min_length=1, max_length=200)
    dob: date | None = None


class AuthorUpdate(BaseModel):
    model_config = ConfigDict(extra="forbid")
    name: str | None = Field(default=None, min_length=1, max_length=200)
    dob: date | None = None

    @model_validator(mode="after")
    def validate_explicit_name(self):
        if "name" in self.model_fields_set and self.name is None:
            raise ValueError("name cannot be null")
        return self


class AuthorRepository(repository.SQLAlchemyAsyncRepository[AuthorModel]):
    model_type = AuthorModel


async def provide_authors_repo(db_session: AsyncSession) -> AuthorRepository:
    return AuthorRepository(session=db_session)


async def provide_author_details_repo(
    db_session: AsyncSession,
) -> AuthorRepository:
    return AuthorRepository(
        statement=select(AuthorModel).options(selectinload(AuthorModel.books)),
        session=db_session,
    )


def provide_limit_offset_pagination(
    current_page: Annotated[
        int, QueryParameter(name="currentPage", ge=1, required=False)
    ] = 1,
    page_size: Annotated[
        int, QueryParameter(name="pageSize", ge=1, le=100, required=False)
    ] = 10,
) -> filters.LimitOffset:
    return filters.LimitOffset(page_size, page_size * (current_page - 1))


class AuthorController(Controller):
    dependencies = {"authors_repo": Provide(provide_authors_repo)}

    @get(path="/authors")
    async def list_authors(
        self, authors_repo: AuthorRepository,
        limit_offset: filters.LimitOffset,
    ) -> OffsetPagination[Author]:
        rows, total = await authors_repo.list_and_count(limit_offset)
        return OffsetPagination(
            items=TypeAdapter(list[Author]).validate_python(rows),
            total=total, limit=limit_offset.limit, offset=limit_offset.offset,
        )

    @post(path="/authors")
    async def create_author(
        self, authors_repo: AuthorRepository, data: AuthorCreate,
    ) -> Author:
        try:
            obj = await authors_repo.add(AuthorModel(**data.model_dump()))
            await authors_repo.session.commit()
        except Exception:
            await authors_repo.session.rollback()
            raise
        return Author.model_validate(obj)

    @get(
        path="/authors/{author_id:uuid}",
        dependencies={"authors_repo": Provide(provide_author_details_repo)},
    )
    async def get_author(
        self, authors_repo: AuthorRepository, author_id: UUID,
    ) -> Author:
        return Author.model_validate(await authors_repo.get(author_id))

    @patch(path="/authors/{author_id:uuid}")
    async def update_author(
        self, authors_repo: AuthorRepository,
        author_id: UUID, data: AuthorUpdate,
    ) -> Author:
        try:
            obj = await authors_repo.get(author_id)
            for field, value in data.model_dump(exclude_unset=True).items():
                setattr(obj, field, value)
            obj = await authors_repo.update(obj)
            await authors_repo.session.commit()
        except Exception:
            await authors_repo.session.rollback()
            raise
        return Author.model_validate(obj)

    @delete(path="/authors/{author_id:uuid}")
    async def delete_author(
        self, authors_repo: AuthorRepository, author_id: UUID,
    ) -> None:
        try:
            await authors_repo.delete(author_id)
            await authors_repo.session.commit()
        except Exception:
            await authors_repo.session.rollback()
            raise


sqlalchemy_config = SQLAlchemyAsyncConfig(
    connection_string="sqlite+aiosqlite:///:memory:",
    session_config=AsyncSessionConfig(expire_on_commit=False),
)


async def on_startup() -> None:
    async with sqlalchemy_config.get_engine().begin() as conn:
        await conn.run_sync(base.UUIDBase.metadata.create_all)


app = Litestar(
    route_handlers=[AuthorController],
    on_startup=[on_startup],
    plugins=[SQLAlchemyInitPlugin(config=sqlalchemy_config)],
    dependencies={
        "limit_offset": Provide(provide_limit_offset_pagination, sync_to_thread=False)
    },
)

这份代码需要相互兼容的 Litestar SQLAlchemy 集成、SQLAlchemy 异步支持、aiosqlite 与 Pydantic;不是带依赖锁文件的已验收工程。内存数据库会随进程结束而消失,便于理解隔离教学环境的边界。源文使用 test.sqlite 文件;不要把本例改成真实数据库连接后直接运行自动建表。create_all 用于示例初始化,不代替正式数据库迁移。

五个端点如何配合

端点 行为
GET /authors 返回 items、total、limit、offset;currentPage 默认1,pageSize默认10。本修正版上限100。
POST /authors 验证 AuthorCreate,新增对象,提交后转成响应模型。
GET /authors/{author_id} 按 UUID 读取。详情仓库可加载书籍,但响应 Author 当前没有 books 字段,因此不会输出书籍数组。
PATCH /authors/{author_id} 只更新请求实际提供的字段;可以通过 dob:null 清空日期;显式 name:null 被输入模型拒绝。
DELETE /authors/{author_id} 删除指定作者并提交。存在关联书籍时的外键及删除策略必须由业务决定。

原文 PATCH 使用 exclude_unset=True, exclude_none=True,会同时排除未提供字段和显式 null,因而无法把可空 dob 清空。本修正版只排除未提供字段,先加载现存对象再更新;并明确阻止把非空的 name 改成 null。源文异步 PATCH 还使用 from_orm,本稿统一为其余路由已经使用的 model_validate。

分页大小上限可以限制一次结果规模,但不能消除巨大 offset 的查询成本,也不能自动保证并发变化中的稳定分页顺序。真实服务需结合排序键、数据规模与查询计划选择分页策略。认证、对象级授权、租户隔离、限流和完整错误响应并不在原例中;UUID 只是标识,不是访问权限。这个教学应用不应作为无保护的公网 CRUD 服务。

四、把 Repository 扩展为可读的 Slug

第四章改用博客文章。UUID 适合作为主键,但 URL 较长;Slug 给文章一个可读的标识,例如把 Follow the Yellow Brick Road! 转为 follow-the-yellow-brick-road。原文通过 mixin 在数据库建立非空、唯一、最长100字符的 slug 列:

@declarative_mixin
class SlugKey:
    __abstract__ = True
    slug: Mapped[str] = mapped_column(
        String(length=100), nullable=False, unique=True, sort_order=-9
    )

class BlogPost(base.UUIDAuditBase, SlugKey):
    title: Mapped[str]
    content: Mapped[str]

这个片段另需导入 declarative_mixin、String 等,表示第四章的独立扩展,不应直接在前面的模型模块重复定义已有类。原文的自定义仓库首先 NFKD 归一化,把字符转 ASCII 并忽略无法表示的字符,再去标点、转小写、把重复空白或连字符压成一个连字符,最后去掉首尾连字符与下划线:

value = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
value = re.sub(r"[^\w\s-]", "", value.lower())
slug = re.sub(r"[-\s]+", "-", value).strip("-_")

仓库用 get_one_or_none(slug=slug) 检查当前是否存在;若不存在就返回它,否则附上从小写字母和数字中选出的四字符随机后缀。路由把输入 Pydantic 模型转字典,生成 slug,加到 BlogPost,add 后 commit,并用 BlogPostDTO 返回 id、slug、title、content。原文完整示例还给出列表 GET / 和按 /{post_slug:str} 调用 get_one(slug=post_slug) 的详情路由。生成逻辑额外引入一次查库,应只对需要可读标识的字段使用。

为什么“先查不存在”不是唯一性保证

两个并发请求都可能在检查时看到不存在,随后尝试写入相同 slug;四字符随机后缀也可能重复。真正的仲裁必须是数据库唯一约束。源例已有 unique=True,但没有处理并发冲突后的失败恢复。不能因为函数名叫 get_available_slug,就把返回值当作已预留的标识。

静态审查还发现:纯中文等非 ASCII 标题会被清成空串,超长标题可能超出长度限制;加后缀以后也必须满足长度限制。原文提到整数 ID 暴露可能有安全问题,但换成 UUID 或 Slug 仍不能替代逐对象授权。原文用的 random.choices 不是秘密令牌生成器,Slug 也不应该被当作访问秘密。

下面给出编辑新增的候选值生成函数,处理空串与长度并降低碰撞概率;它仍然不能独自保证唯一。与原文不同,它不提前查库,固定加 UUID 后缀:

import re
import unicodedata
from uuid import uuid4

def make_slug_candidate(title: str) -> str:
    value = unicodedata.normalize("NFKD", title).encode(
        "ascii", "ignore"
    ).decode("ascii").lower()
    value = re.sub(r"[^\w\s-]", "", value)
    stem = re.sub(r"[-\s]+", "-", value).strip("-_") or "post"
    stem = stem[:67].rstrip("-_") or "post"
    return f"{stem}-{uuid4().hex}"  # 最长67 + 1 + 32 = 100

实际写入必须保留唯一约束,并在事务边界处理冲突:仅对确认来自 slug 唯一约束的错误,在回滚或保存点恢复后生成新候选值,进行有上限的重试;其他完整性错误应继续抛出。数据库驱动与 Advanced Alchemy 对异常的包装随组合变化,本文没有凭空给出“通吃所有数据库”的异常类型判断器,也未测试并发重试。若请求还有其他写入,不能无条件回滚整个会话后只重放文章插入,否则会改变原事务语义。

使用这些示例前,还要验证哪些实际行为

应在隔离、无真实凭据与生产数据的环境核查:新增/列表/缺失对象的响应、分页边界、PATCH 未提供字段与显式 null、提交失败后的错误传播、取消时会话清理、关联书籍存在时的删除行为,以及同时创建同名 Slug 的唯一约束冲突。本次没有运行这些测试,列出它们是验证计划,不是通过报告。

静态审查没有发现所展示 SQLAlchemy 条件操作拼接未可信 SQL 文本或硬编码密码;但依赖版本、认证授权、事务错误处理和未展示代码仍可能有问题。首章完整示例中的 debug=True 不应沿用到公开服务,本稿整理后的应用不启用它。对读出的博客 content,网页显示端仍应按文本转义或经过可信 HTML 清理,不能把数据库内容默认当作安全 HTML。

本稿保留四章技术脉络,并在附录完整保留原示例代码。所有编辑修正、原例缺陷和未验证项均已明确标注。

来源与许可证

来源为本页开头链接的 Litestar 官方四章教程,页面页脚 © Copyright 2025, Litestar-Org。当前官方仓库 MIT LICENSE 的版权行为 Copyright (c) 2021, 2022, 2023, 2024, 2025, 2026 Litestar Org。本稿是中文翻译与修改版本,不表示原作者为编辑新增代码提供支持。许可证完整文本如下:

The MIT License (MIT)

Copyright (c) 2021, 2022, 2023, 2024, 2025, 2026 Litestar Org.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

四章原始示例的完整代码对照

以下按各章顺序保留官方示例,包括片段与展开后的完整程序,供对照中文解释和编辑修订。不同章节、同步/异步和Python版本变体不能合并运行;原始例中的吞异常、PATCH空值、Slug并发与长度问题仍须按正文处理。源文第一章在当前版本使用NamedDependency与create_all配置,必须按相同版本选用完整示例。原例debug=True、文件SQLite和删除操作仅限隔离教学环境。未执行任何示例。

基类的其他细节:GUID优先使用数据库原生UUID,否则回退Binary(16);BigIntIdentity在不支持的后端回退Integer;JsonB优先原生JSONB,否则使用Binary或Blob。EncryptedString支持多个加密后端。这些是源文接口说明,不是兼容性实测。

第1章原代码

来源:Litestar官方第1章,©2025 Litestar-Org,MIT;代码未修改。

示例 1

# The `UUIDBase` class includes a `UUID` based primary key (`id`)
class Author(base.UUIDBase):
    __tablename__ = "author"
    name: Mapped[str]

示例 2

# The `UUIDBase` class includes a `UUID` based primary key (`id`)
class Author(base.UUIDBase):
    __tablename__ = "author"
    name: Mapped[str]
    dob: Mapped[date]
    books: Mapped[List[Book]] = relationship(back_populates="author", lazy="selectin")
# record created, and `updated_at` is the last time the record was modified.
class Book(base.UUIDAuditBase):
    __tablename__ = "book"
    title: Mapped[str]

示例 3

from __future__ import annotations

import uuid
from datetime import date
from uuid import UUID

from sqlalchemy import ForeignKey, func, select
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import Mapped, mapped_column, relationship

from litestar import Litestar, get
from litestar.di import NamedDependency
from litestar.plugins.sqlalchemy import AsyncSessionConfig, SQLAlchemyAsyncConfig, SQLAlchemyPlugin, base


# The SQLAlchemy base includes a declarative model for you to use in your models.
# The `UUIDBase` class includes a `UUID` based primary key (`id`)
class Author(base.UUIDBase):
    __tablename__ = "author"
    name: Mapped[str]
    dob: Mapped[date]
    books: Mapped[list[Book]] = relationship(back_populates="author", lazy="selectin")


# The `UUIDAuditBase` class includes the same UUID` based primary key (`id`) and 2
# additional columns: `created_at` and `updated_at`. `created_at` is a timestamp of when the
# record created, and `updated_at` is the last time the record was modified.
class Book(base.UUIDAuditBase):
    __tablename__ = "book"
    title: Mapped[str]
    author_id: Mapped[UUID] = mapped_column(ForeignKey("author.id"))
    author: Mapped[Author] = relationship(lazy="joined", innerjoin=True, viewonly=True)


session_config = AsyncSessionConfig(expire_on_commit=False)
sqlalchemy_config = SQLAlchemyAsyncConfig(
    connection_string="sqlite+aiosqlite:///test.sqlite", session_config=session_config, create_all=True
)  # Create 'async_session' dependency.


async def on_startup(app: Litestar) -> None:
    """Adds some dummy data if no data is present."""
    async with sqlalchemy_config.get_session() as session:
        statement = select(func.count()).select_from(Author)
        count = await session.execute(statement)
        if not count.scalar():
            author_id = uuid.uuid4()
            session.add(Author(name="Stephen King", dob=date(1954, 9, 21), id=author_id))
            session.add(Book(title="It", author_id=author_id))
            await session.commit()


@get(path="/authors")
async def get_authors(
    db_session: NamedDependency[AsyncSession], db_engine: NamedDependency[AsyncEngine]
) -> list[Author]:
    """Interact with SQLAlchemy engine and session."""
    return list(await db_session.scalars(select(Author)))


app = Litestar(
    route_handlers=[get_authors],
    on_startup=[on_startup],
    debug=True,
    plugins=[SQLAlchemyPlugin(config=sqlalchemy_config)],
)

第2章原代码

来源:Litestar官方第2章,©2025 Litestar-Org,MIT;代码未修改。

示例 1

from rich import get_console
from sqlalchemy.orm import Mapped

from litestar.plugins.sqlalchemy import base, repository

console = get_console()


# the SQLAlchemy base includes a declarative model for you to use in your models.
# The `Base` class includes a `UUID` based primary key (`id`)
class Author(base.UUIDBase):
    name: Mapped[str]
    dob: Mapped[date]
    dod: Mapped[date | None]


class AuthorRepository(repository.SQLAlchemyAsyncRepository[Author]):
    """Author repository."""

    model_type = Author

示例 2

# let's make a simple context manager as an example here.
@asynccontextmanager
async def repository_factory() -> AsyncIterator[AuthorRepository]:
    async with session_factory() as db_session:
        try:
            yield AuthorRepository(session=db_session)
        except Exception:  # noqa: BLE001
            await db_session.rollback()
        else:

示例 3

    async with repository_factory() as repo:
        obj = await repo.add(
            Author(
                name="F. Scott Fitzgerald",
                dob=datetime.strptime("1896-09-24", "%Y-%m-%d").date(),
            )
        )
        console.print(f"Created Author record for {obj.name} with primary key {obj.id}.")
        return obj

示例 4

    async with repository_factory() as repo:
        obj = await repo.update(obj)
        console.print(f"Updated Author record for {obj.name} with primary key {obj.id}.")
        return obj

示例 5

    async with repository_factory() as repo:
        obj = await repo.delete(id)
        console.print(f"Deleted Author record for {obj.name} with primary key {obj.id}.")
        return obj

示例 6

import json
from pathlib import Path
from typing import Any


def open_fixture(fixtures_path: Path, fixture_name: str) -> Any:
    """Loads JSON file with the specified fixture name

    Args:
        fixtures_path (Path): The path to look for fixtures
        fixture_name (str): The fixture name to load.

    Raises:
        FileNotFoundError: Fixtures not found.

    Returns:
        Any: The parsed JSON data
    """
    fixture = Path(fixtures_path / f"{fixture_name}.json")
    if fixture.exists():
        with fixture.open(mode="r", encoding="utf-8") as f:
            f_data = f.read()
        return json.loads(f_data)
    raise FileNotFoundError(f"Could not find the {fixture_name} fixture")

示例 7

[
  {
    "name": "Alabama",
    "abbreviation": "AL"
  },
  {
    "name": "Alaska",
    "abbreviation": "AK"
  },
  {
    "name": "Arizona",
    "abbreviation": "AZ"
  },
  {
    "name": "Arkansas",
    "abbreviation": "AR"
  },
  {
    "name": "California",
    "abbreviation": "CA"
  },
  {
    "name": "Colorado",
    "abbreviation": "CO"
  },
  {
    "name": "Connecticut",
    "abbreviation": "CT"
  },
  {
    "name": "Delaware",
    "abbreviation": "DE"
  },
  {
    "name": "District Of Columbia",
    "abbreviation": "DC"
  },
  {
    "name": "Florida",
    "abbreviation": "FL"
  },
  {
    "name": "Georgia",
    "abbreviation": "GA"
  },
  {
    "name": "Guam",
    "abbreviation": "GU"
  },
  {
    "name": "Hawaii",
    "abbreviation": "HI"
  },
  {
    "name": "Idaho",
    "abbreviation": "ID"
  },
  {
    "name": "Illinois",
    "abbreviation": "IL"
  },
  {
    "name": "Indiana",
    "abbreviation": "IN"
  },
  {
    "name": "Iowa",
    "abbreviation": "IA"
  },
  {
    "name": "Kansas",
    "abbreviation": "KS"
  },
  {
    "name": "Kentucky",
    "abbreviation": "KY"
  },
  {
    "name": "Louisiana",
    "abbreviation": "LA"
  },
  {
    "name": "Maine",
    "abbreviation": "ME"
  },
  {
    "name": "Maryland",
    "abbreviation": "MD"
  },
  {
    "name": "Massachusetts",
    "abbreviation": "MA"
  },
  {
    "name": "Michigan",
    "abbreviation": "MI"
  },
  {
    "name": "Minnesota",
    "abbreviation": "MN"
  },
  {
    "name": "Mississippi",
    "abbreviation": "MS"
  },
  {
    "name": "Missouri",
    "abbreviation": "MO"
  },
  {
    "name": "Montana",
    "abbreviation": "MT"
  },
  {
    "name": "Nebraska",
    "abbreviation": "NE"
  },
  {
    "name": "Nevada",
    "abbreviation": "NV"
  },
  {
    "name": "New Hampshire",
    "abbreviation": "NH"
  },
  {
    "name": "New Jersey",
    "abbreviation": "NJ"
  },
  {
    "name": "New Mexico",
    "abbreviation": "NM"
  },
  {
    "name": "New York",
    "abbreviation": "NY"
  },
  {
    "name": "North Carolina",
    "abbreviation": "NC"
  },
  {
    "name": "North Dakota",
    "abbreviation": "ND"
  },
  {
    "name": "Ohio",
    "abbreviation": "OH"
  },
  {
    "name": "Oklahoma",
    "abbreviation": "OK"
  },
  {
    "name": "Oregon",
    "abbreviation": "OR"
  },
  {
    "name": "Palau",
    "abbreviation": "PW"
  },
  {
    "name": "Pennsylvania",
    "abbreviation": "PA"
  },
  {
    "name": "Puerto Rico",
    "abbreviation": "PR"
  },
  {
    "name": "Rhode Island",
    "abbreviation": "RI"
  },
  {
    "name": "South Carolina",
    "abbreviation": "SC"
  },
  {
    "name": "South Dakota",
    "abbreviation": "SD"
  },
  {
    "name": "Tennessee",
    "abbreviation": "TN"
  },
  {
    "name": "Texas",
    "abbreviation": "TX"
  },
  {
    "name": "Utah",
    "abbreviation": "UT"
  },
  {
    "name": "Vermont",
    "abbreviation": "VT"
  },
  {
    "name": "Virginia",
    "abbreviation": "VA"
  },
  {
    "name": "Washington",
    "abbreviation": "WA"
  },
  {
    "name": "West Virginia",
    "abbreviation": "WV"
  },
  {
    "name": "Wisconsin",
    "abbreviation": "WI"
  },
  {
    "name": "Wyoming",
    "abbreviation": "WY"
  }
]

示例 8

from rich import get_console
from sqlalchemy import create_engine
from sqlalchemy.orm import Mapped, Session, sessionmaker

from litestar.plugins.sqlalchemy import base, repository

console = get_console()


class USState(base.UUIDBase):
    __tablename__ = "us_state_lookup"  # type: ignore[assignment]
    abbreviation: Mapped[str]
    name: Mapped[str]


class USStateRepository(repository.SQLAlchemySyncRepository[USState]):
    """US State repository."""

    model_type = USState


engine = create_engine(
    "duckdb:///:memory:",
    future=True,
)
session_factory: sessionmaker[Session] = sessionmaker(engine, expire_on_commit=False)


def run_script() -> None:
    """Load data from a fixture."""

    # Initializes the database.
    with engine.begin() as conn:
        USState.metadata.create_all(conn)

    with session_factory() as db_session:
        # 1) Load the JSON data into the US States table.
        repo = USStateRepository(session=db_session)
        fixture = open_fixture(here, USStateRepository.model_type.__tablename__)  # type: ignore
        objs = repo.add_many([USStateRepository.model_type(**raw_obj) for raw_obj in fixture])
        db_session.commit()
        console.print(f"Created {len(objs)} new objects.")

示例 9

from litestar.repository.filters import LimitOffset


        # 2) Select paginated data and total row count.
        created_objs, total_objs = repo.list_and_count(LimitOffset(limit=10, offset=0))
        console.print(f"Selected {len(created_objs)} records out of a total of {total_objs}.")

示例 10

        # 3) Let's remove the batch of records selected.
        deleted_objs = repo.delete_many([new_obj.id for new_obj in created_objs])
        console.print(f"Removed {len(deleted_objs)} records out of a total of {total_objs}.")

示例 11

        # 4) Let's count the remaining rows
        remaining_count = repo.count()
        console.print(f"Found {remaining_count} remaining records after delete.")

示例 12

from __future__ import annotations

from contextlib import asynccontextmanager
from datetime import date, datetime
from typing import AsyncIterator
from uuid import UUID

import anyio
from rich import get_console
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import Mapped

from litestar.plugins.sqlalchemy import base, repository

console = get_console()


# the SQLAlchemy base includes a declarative model for you to use in your models.
# The `Base` class includes a `UUID` based primary key (`id`)
class Author(base.UUIDBase):
    name: Mapped[str]
    dob: Mapped[date]
    dod: Mapped[date | None]


class AuthorRepository(repository.SQLAlchemyAsyncRepository[Author]):
    """Author repository."""

    model_type = Author


engine = create_async_engine(
    "sqlite+aiosqlite:///test.sqlite",
    future=True,
)
session_factory = async_sessionmaker(engine, expire_on_commit=False)


# let's make a simple context manager as an example here.
@asynccontextmanager
async def repository_factory() -> AsyncIterator[AuthorRepository]:
    async with session_factory() as db_session:
        try:
            yield AuthorRepository(session=db_session)
        except Exception:  # noqa: BLE001
            await db_session.rollback()
        else:
            await db_session.commit()


async def create_author() -> Author:
    async with repository_factory() as repo:
        obj = await repo.add(
            Author(
                name="F. Scott Fitzgerald",
                dob=datetime.strptime("1896-09-24", "%Y-%m-%d").date(),
            )
        )
        console.print(f"Created Author record for {obj.name} with primary key {obj.id}.")
        return obj


async def update_author(obj: Author) -> Author:
    async with repository_factory() as repo:
        obj = await repo.update(obj)
        console.print(f"Updated Author record for {obj.name} with primary key {obj.id}.")
        return obj


async def remove_author(id: UUID) -> Author:
    async with repository_factory() as repo:
        obj = await repo.delete(id)
        console.print(f"Deleted Author record for {obj.name} with primary key {obj.id}.")
        return obj


async def get_author_if_exists(id: UUID) -> Author | None:
    async with repository_factory() as repo:
        obj = await repo.get_one_or_none(id=id)
        if obj is not None:
            console.print(f"Found Author record for {obj.name} with primary key {obj.id}.")
        else:
            console.print(f"Could not find Author with primary key {id}.")
        return obj


async def run_script() -> None:
    """Load data from a fixture."""
    async with engine.begin() as conn:
        await conn.run_sync(base.UUIDBase.metadata.create_all)

    # 1) create a new Author record.
    console.print("1) Adding a new record")
    author = await create_author()
    author_id = author.id

    # 2) Let's update the Author record.
    console.print("2) Updating a record.")
    author.dod = datetime.strptime("1940-12-21", "%Y-%m-%d").date()
    await update_author(author)

    # 3) Let's delete the record we just created.
    console.print("3) Removing a record.")
    await remove_author(author_id)

    # 4) Let's verify the record no longer exists.
    console.print("4) Select one or none.")
    _should_be_none = await get_author_if_exists(author_id)


if __name__ == "__main__":
    anyio.run(run_script)

示例 13

from __future__ import annotations

from contextlib import asynccontextmanager
from datetime import date, datetime
from collections.abc import AsyncIterator
from uuid import UUID

import anyio
from rich import get_console
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import Mapped

from litestar.plugins.sqlalchemy import base, repository

console = get_console()


# the SQLAlchemy base includes a declarative model for you to use in your models.
# The `Base` class includes a `UUID` based primary key (`id`)
class Author(base.UUIDBase):
    name: Mapped[str]
    dob: Mapped[date]
    dod: Mapped[date | None]


class AuthorRepository(repository.SQLAlchemyAsyncRepository[Author]):
    """Author repository."""

    model_type = Author


engine = create_async_engine(
    "sqlite+aiosqlite:///test.sqlite",
    future=True,
)
session_factory = async_sessionmaker(engine, expire_on_commit=False)


# let's make a simple context manager as an example here.
@asynccontextmanager
async def repository_factory() -> AsyncIterator[AuthorRepository]:
    async with session_factory() as db_session:
        try:
            yield AuthorRepository(session=db_session)
        except Exception:  # noqa: BLE001
            await db_session.rollback()
        else:
            await db_session.commit()


async def create_author() -> Author:
    async with repository_factory() as repo:
        obj = await repo.add(
            Author(
                name="F. Scott Fitzgerald",
                dob=datetime.strptime("1896-09-24", "%Y-%m-%d").date(),
            )
        )
        console.print(f"Created Author record for {obj.name} with primary key {obj.id}.")
        return obj


async def update_author(obj: Author) -> Author:
    async with repository_factory() as repo:
        obj = await repo.update(obj)
        console.print(f"Updated Author record for {obj.name} with primary key {obj.id}.")
        return obj


async def remove_author(id: UUID) -> Author:
    async with repository_factory() as repo:
        obj = await repo.delete(id)
        console.print(f"Deleted Author record for {obj.name} with primary key {obj.id}.")
        return obj


async def get_author_if_exists(id: UUID) -> Author | None:
    async with repository_factory() as repo:
        obj = await repo.get_one_or_none(id=id)
        if obj is not None:
            console.print(f"Found Author record for {obj.name} with primary key {obj.id}.")
        else:
            console.print(f"Could not find Author with primary key {id}.")
        return obj


async def run_script() -> None:
    """Load data from a fixture."""
    async with engine.begin() as conn:
        await conn.run_sync(base.UUIDBase.metadata.create_all)

    # 1) create a new Author record.
    console.print("1) Adding a new record")
    author = await create_author()
    author_id = author.id

    # 2) Let's update the Author record.
    console.print("2) Updating a record.")
    author.dod = datetime.strptime("1940-12-21", "%Y-%m-%d").date()
    await update_author(author)

    # 3) Let's delete the record we just created.
    console.print("3) Removing a record.")
    await remove_author(author_id)

    # 4) Let's verify the record no longer exists.
    console.print("4) Select one or none.")
    _should_be_none = await get_author_if_exists(author_id)


if __name__ == "__main__":
    anyio.run(run_script)

第3章原代码

来源:Litestar官方第3章,©2025 Litestar-Org,MIT;代码未修改。

示例 1

async def provide_authors_repo(db_session: AsyncSession) -> AuthorRepository:
    """This provides the default Authors repository."""
    return AuthorRepository(session=db_session)

示例 2

# we can optionally override the default `select` used for the repository to pass in
# specific SQL options such as join details
async def provide_author_details_repo(db_session: AsyncSession) -> AuthorRepository:
    """This provides a simple example demonstrating how to override the join options for the repository."""
    return AuthorRepository(
        statement=select(AuthorModel).options(selectinload(AuthorModel.books)),
        session=db_session,
    )

示例 3

    @get(path="/authors")
    async def list_authors(
        self,
        authors_repo: AuthorRepository,
        limit_offset: filters.LimitOffset,
    ) -> OffsetPagination[Author]:
        """List authors."""
        results, total = await authors_repo.list_and_count(limit_offset)
        type_adapter = TypeAdapter(list[Author])
        return OffsetPagination[Author](
            items=type_adapter.validate_python(results),
            total=total,
            limit=limit_offset.limit,
            offset=limit_offset.offset,
        )

    @post(path="/authors")
    async def create_author(
        self,
        authors_repo: AuthorRepository,
        data: AuthorCreate,
    ) -> Author:
        """Create a new author."""
        obj = await authors_repo.add(
            AuthorModel(**data.model_dump(exclude_unset=True, exclude_none=True)),
        )
        await authors_repo.session.commit()
        return Author.model_validate(obj)

    # we override the authors_repo to use the version that joins the Books in
    @get(path="/authors/{author_id:uuid}", dependencies={"authors_repo": Provide(provide_author_details_repo)})
    async def get_author(
        self,
        authors_repo: AuthorRepository,
        author_id: Annotated[
            UUID,
            PathParameter(
                title="Author ID",
                description="The author to retrieve.",
            ),
        ],
    ) -> Author:
        """Get an existing author."""
        obj = await authors_repo.get(author_id)
        return Author.model_validate(obj)

    @patch(
        path="/authors/{author_id:uuid}",
        dependencies={"authors_repo": Provide(provide_author_details_repo)},
    )
    async def update_author(
        self,
        authors_repo: AuthorRepository,
        data: AuthorUpdate,
        author_id: Annotated[
            UUID,
            PathParameter(
                title="Author ID",
                description="The author to update.",
            ),
        ],
    ) -> Author:
        """Update an author."""
        raw_obj = data.model_dump(exclude_unset=True, exclude_none=True)
        raw_obj.update({"id": author_id})
        obj = await authors_repo.update(AuthorModel(**raw_obj))
        await authors_repo.session.commit()
        return Author.from_orm(obj)

    @delete(path="/authors/{author_id:uuid}")
    async def delete_author(
        self,
        authors_repo: AuthorRepository,
        author_id: Annotated[
            UUID,
            PathParameter(
                title="Author ID",
                description="The author to delete.",
            ),
        ],

示例 4

from __future__ import annotations

from datetime import date
from typing import TYPE_CHECKING, Annotated
from uuid import UUID

from pydantic import BaseModel as _BaseModel
from pydantic import TypeAdapter
from sqlalchemy import ForeignKey, select
from sqlalchemy.orm import Mapped, mapped_column, relationship, selectinload

from litestar import Litestar, get
from litestar.controller import Controller
from litestar.di import Provide
from litestar.handlers.http_handlers.decorators import delete, patch, post
from litestar.pagination import OffsetPagination
from litestar.params import PathParameter, QueryParameter
from litestar.plugins.sqlalchemy import (
    SQLAlchemyInitPlugin,
    SQLAlchemySyncConfig,
    base,
    repository,
)
from litestar.repository.filters import LimitOffset

if TYPE_CHECKING:
    from sqlalchemy.orm import Session


class BaseModel(_BaseModel):
    """Extend Pydantic's BaseModel to enable ORM mode"""

    model_config = {"from_attributes": True}


# The SQLAlchemy base includes a declarative model for you to use in your models.
# The `UUIDBase` class includes a `UUID` based primary key (`id`)
class AuthorModel(base.UUIDBase):
    # we can optionally provide the table name instead of auto-generating it
    __tablename__ = "author"  #  type: ignore[assignment]
    name: Mapped[str]
    dob: Mapped[date | None]
    books: Mapped[list[BookModel]] = relationship(back_populates="author", lazy="noload")


# The `UUIDAuditBase` class includes the same UUID` based primary key (`id`) and 2
# additional columns: `created_at` and `updated_at`. `created_at` is a timestamp of when the
# record created, and `updated_at` is the last time the record was modified.
class BookModel(base.UUIDAuditBase):
    __tablename__ = "book"  #  type: ignore[assignment]
    title: Mapped[str]
    author_id: Mapped[UUID] = mapped_column(ForeignKey("author.id"))
    author: Mapped[AuthorModel] = relationship(lazy="joined", innerjoin=True, viewonly=True)


# we will explicitly define the schema instead of using DTO objects for clarity.


class Author(BaseModel):
    id: UUID | None
    name: str
    dob: date | None = None


class AuthorCreate(BaseModel):
    name: str
    dob: date | None = None


class AuthorUpdate(BaseModel):
    name: str | None = None
    dob: date | None = None


class AuthorRepository(repository.SQLAlchemySyncRepository[AuthorModel]):
    """Author repository."""

    model_type = AuthorModel


async def provide_authors_repo(db_session: Session) -> AuthorRepository:
    """This provides the default Authors repository."""
    return AuthorRepository(session=db_session)


# we can optionally override the default `select` used for the repository to pass in
# specific SQL options such as join details
async def provide_author_details_repo(db_session: Session) -> AuthorRepository:
    """This provides a simple example demonstrating how to override the join options
    for the repository."""
    return AuthorRepository(
        statement=select(AuthorModel).options(selectinload(AuthorModel.books)),
        session=db_session,
    )


def provide_limit_offset_pagination(
    current_page: Annotated[int, QueryParameter(name="currentPage", ge=1, required=False)] = 1,
    page_size: Annotated[int, QueryParameter(name="pageSize", ge=1, required=False)] = 10,
) -> LimitOffset:
    """Add offset/limit pagination.

    Return type consumed by `Repository.apply_limit_offset_pagination()`.

    Parameters
    ----------
    current_page : int
        LIMIT to apply to select.
    page_size : int
        OFFSET to apply to select.
    """
    return LimitOffset(page_size, page_size * (current_page - 1))


class AuthorController(Controller):
    """Author CRUD"""

    dependencies = {"authors_repo": Provide(provide_authors_repo, sync_to_thread=False)}

    @get(path="/authors")
    def list_authors(
        self,
        authors_repo: AuthorRepository,
        limit_offset: LimitOffset,
    ) -> OffsetPagination[Author]:
        """List authors."""
        results, total = authors_repo.list_and_count(limit_offset)
        type_adapter = TypeAdapter(list[Author])
        return OffsetPagination[Author](
            items=type_adapter.validate_python(results),
            total=total,
            limit=limit_offset.limit,
            offset=limit_offset.offset,
        )

    @post(path="/authors")
    def create_author(
        self,
        authors_repo: AuthorRepository,
        data: AuthorCreate,
    ) -> Author:
        """Create a new author."""
        obj = authors_repo.add(
            AuthorModel(**data.model_dump(exclude_unset=True, exclude_none=True)),
        )
        authors_repo.session.commit()
        return Author.model_validate(obj)

    # we override the authors_repo to use the version that joins the Books in
    @get(
        path="/authors/{author_id:uuid}",
        dependencies={"authors_repo": Provide(provide_author_details_repo, sync_to_thread=False)},
    )
    def get_author(
        self,
        authors_repo: AuthorRepository,
        author_id: Annotated[
            UUID,
            PathParameter(
                title="Author ID",
                description="The author to retrieve.",
            ),
        ],
    ) -> Author:
        """Get an existing author."""
        obj = authors_repo.get(author_id)
        return Author.model_validate(obj)

    @patch(
        path="/authors/{author_id:uuid}",
        dependencies={"authors_repo": Provide(provide_author_details_repo, sync_to_thread=False)},
    )
    def update_author(
        self,
        authors_repo: AuthorRepository,
        data: AuthorUpdate,
        author_id: Annotated[
            UUID,
            PathParameter(
                title="Author ID",
                description="The author to update.",
            ),
        ],
    ) -> Author:
        """Update an author."""
        raw_obj = data.model_dump(exclude_unset=True, exclude_none=True)
        raw_obj.update({"id": author_id})
        obj = authors_repo.update(AuthorModel(**raw_obj))
        authors_repo.session.commit()
        return Author.model_validate(obj)

    @delete(path="/authors/{author_id:uuid}")
    def delete_author(
        self,
        authors_repo: AuthorRepository,
        author_id: Annotated[
            UUID,
            PathParameter(
                title="Author ID",
                description="The author to delete.",
            ),
        ],
    ) -> None:
        """Delete a author from the system."""
        _ = authors_repo.delete(author_id)
        authors_repo.session.commit()


sqlalchemy_config = SQLAlchemySyncConfig(connection_string="sqlite:///test.sqlite")  # Create 'db_session' dependency.
sqlalchemy_plugin = SQLAlchemyInitPlugin(config=sqlalchemy_config)


def on_startup() -> None:
    """Initializes the database."""
    with sqlalchemy_config.get_engine().begin() as conn:
        base.UUIDBase.metadata.create_all(conn)


app = Litestar(
    route_handlers=[AuthorController],
    on_startup=[on_startup],
    plugins=[SQLAlchemyInitPlugin(config=sqlalchemy_config)],
    dependencies={"limit_offset": Provide(provide_limit_offset_pagination)},
)

示例 5

from __future__ import annotations

from datetime import date
from typing import TYPE_CHECKING, Annotated
from uuid import UUID

from pydantic import BaseModel as _BaseModel
from pydantic import TypeAdapter
from sqlalchemy import ForeignKey, select
from sqlalchemy.orm import Mapped, mapped_column, relationship, selectinload

from litestar import Litestar, get
from litestar.controller import Controller
from litestar.di import Provide
from litestar.handlers.http_handlers.decorators import delete, patch, post
from litestar.pagination import OffsetPagination
from litestar.params import PathParameter, QueryParameter
from litestar.plugins.sqlalchemy import (
    AsyncSessionConfig,
    SQLAlchemyAsyncConfig,
    SQLAlchemyInitPlugin,
    base,
    filters,
    repository,
)

if TYPE_CHECKING:
    from sqlalchemy.ext.asyncio import AsyncSession


class BaseModel(_BaseModel):
    """Extend Pydantic's BaseModel to enable ORM mode"""

    model_config = {"from_attributes": True}


# The SQLAlchemy base includes a declarative model for you to use in your models.
# The `UUIDBase` class includes a `UUID` based primary key (`id`)
class AuthorModel(base.UUIDBase):
    # we can optionally provide the table name instead of auto-generating it
    __tablename__ = "author"  #  type: ignore[assignment]
    name: Mapped[str]
    dob: Mapped[date | None]
    books: Mapped[list[BookModel]] = relationship(back_populates="author", lazy="noload")


# The `UUIDAuditBase` class includes the same UUID` based primary key (`id`) and 2
# additional columns: `created_at` and `updated_at`. `created_at` is a timestamp of when the
# record created, and `updated_at` is the last time the record was modified.
class BookModel(base.UUIDAuditBase):
    __tablename__ = "book"  #  type: ignore[assignment]
    title: Mapped[str]
    author_id: Mapped[UUID] = mapped_column(ForeignKey("author.id"))
    author: Mapped[AuthorModel] = relationship(lazy="joined", innerjoin=True, viewonly=True)


# we will explicitly define the schema instead of using DTO objects for clarity.


class Author(BaseModel):
    id: UUID | None
    name: str
    dob: date | None = None


class AuthorCreate(BaseModel):
    name: str
    dob: date | None = None


class AuthorUpdate(BaseModel):
    name: str | None = None
    dob: date | None = None


class AuthorRepository(repository.SQLAlchemyAsyncRepository[AuthorModel]):
    """Author repository."""

    model_type = AuthorModel


async def provide_authors_repo(db_session: AsyncSession) -> AuthorRepository:
    """This provides the default Authors repository."""
    return AuthorRepository(session=db_session)


# we can optionally override the default `select` used for the repository to pass in
# specific SQL options such as join details
async def provide_author_details_repo(db_session: AsyncSession) -> AuthorRepository:
    """This provides a simple example demonstrating how to override the join options for the repository."""
    return AuthorRepository(
        statement=select(AuthorModel).options(selectinload(AuthorModel.books)),
        session=db_session,
    )


def provide_limit_offset_pagination(
    current_page: Annotated[int, QueryParameter(name="currentPage", ge=1, required=False)] = 1,
    page_size: Annotated[int, QueryParameter(name="pageSize", ge=1, required=False)] = 10,
) -> filters.LimitOffset:
    """Add offset/limit pagination.

    Return type consumed by `Repository.apply_limit_offset_pagination()`.

    Parameters
    ----------
    current_page : int
        LIMIT to apply to select.
    page_size : int
        OFFSET to apply to select.
    """
    return filters.LimitOffset(page_size, page_size * (current_page - 1))


class AuthorController(Controller):
    """Author CRUD"""

    dependencies = {"authors_repo": Provide(provide_authors_repo)}

    @get(path="/authors")
    async def list_authors(
        self,
        authors_repo: AuthorRepository,
        limit_offset: filters.LimitOffset,
    ) -> OffsetPagination[Author]:
        """List authors."""
        results, total = await authors_repo.list_and_count(limit_offset)
        type_adapter = TypeAdapter(list[Author])
        return OffsetPagination[Author](
            items=type_adapter.validate_python(results),
            total=total,
            limit=limit_offset.limit,
            offset=limit_offset.offset,
        )

    @post(path="/authors")
    async def create_author(
        self,
        authors_repo: AuthorRepository,
        data: AuthorCreate,
    ) -> Author:
        """Create a new author."""
        obj = await authors_repo.add(
            AuthorModel(**data.model_dump(exclude_unset=True, exclude_none=True)),
        )
        await authors_repo.session.commit()
        return Author.model_validate(obj)

    # we override the authors_repo to use the version that joins the Books in
    @get(path="/authors/{author_id:uuid}", dependencies={"authors_repo": Provide(provide_author_details_repo)})
    async def get_author(
        self,
        authors_repo: AuthorRepository,
        author_id: Annotated[
            UUID,
            PathParameter(
                title="Author ID",
                description="The author to retrieve.",
            ),
        ],
    ) -> Author:
        """Get an existing author."""
        obj = await authors_repo.get(author_id)
        return Author.model_validate(obj)

    @patch(
        path="/authors/{author_id:uuid}",
        dependencies={"authors_repo": Provide(provide_author_details_repo)},
    )
    async def update_author(
        self,
        authors_repo: AuthorRepository,
        data: AuthorUpdate,
        author_id: Annotated[
            UUID,
            PathParameter(
                title="Author ID",
                description="The author to update.",
            ),
        ],
    ) -> Author:
        """Update an author."""
        raw_obj = data.model_dump(exclude_unset=True, exclude_none=True)
        raw_obj.update({"id": author_id})
        obj = await authors_repo.update(AuthorModel(**raw_obj))
        await authors_repo.session.commit()
        return Author.from_orm(obj)

    @delete(path="/authors/{author_id:uuid}")
    async def delete_author(
        self,
        authors_repo: AuthorRepository,
        author_id: Annotated[
            UUID,
            PathParameter(
                title="Author ID",
                description="The author to delete.",
            ),
        ],
    ) -> None:
        """Delete a author from the system."""
        _ = await authors_repo.delete(author_id)
        await authors_repo.session.commit()


session_config = AsyncSessionConfig(expire_on_commit=False)
sqlalchemy_config = SQLAlchemyAsyncConfig(
    connection_string="sqlite+aiosqlite:///test.sqlite", session_config=session_config
)  # Create 'db_session' dependency.
sqlalchemy_plugin = SQLAlchemyInitPlugin(config=sqlalchemy_config)


async def on_startup() -> None:
    """Initializes the database."""
    async with sqlalchemy_config.get_engine().begin() as conn:
        await conn.run_sync(base.UUIDBase.metadata.create_all)


app = Litestar(
    route_handlers=[AuthorController],
    on_startup=[on_startup],
    plugins=[SQLAlchemyInitPlugin(config=sqlalchemy_config)],
    dependencies={"limit_offset": Provide(provide_limit_offset_pagination)},
)

第4章原代码

来源:Litestar官方第4章,©2025 Litestar-Org,MIT;代码未修改。

示例 1

from sqlalchemy.orm import Mapped, declarative_mixin, mapped_column
from sqlalchemy.types import String

from litestar.plugins.sqlalchemy import (
    base,
)


@declarative_mixin
class SlugKey:
    """Slug unique Field Model Mixin."""

    __abstract__ = True
    slug: Mapped[str] = mapped_column(String(length=100), nullable=False, unique=True, sort_order=-9)


class BlogPost(base.UUIDAuditBase, SlugKey):
    title: Mapped[str]
    content: Mapped[str]

示例 2

from __future__ import annotations

import random
import re
import string
import unicodedata
from typing import Any

from litestar.plugins.sqlalchemy import (
    repository,
)


class SQLAlchemyAsyncSlugRepository(repository.SQLAlchemyAsyncRepository[repository.ModelT]):
    """Extends the repository to include slug model features.."""

    async def get_available_slug(
        self,
        value_to_slugify: str,
        **kwargs: Any,
    ) -> str:
        """Get a unique slug for the supplied value.

        If the value is found to exist, a random 4 digit character is appended to the end.
        There may be a better way to do this, but I wanted to limit the number of
        additional database calls.

        Args:
            value_to_slugify (str): A string that should be converted to a unique slug.
            **kwargs: stuff

        Returns:
            str: a unique slug for the supplied value. This is safe for URLs and other
            unique identifiers.
        """
        slug = self._slugify(value_to_slugify)
        if await self._is_slug_unique(slug):
            return slug
        # generate a random 4 digit alphanumeric string to make the slug unique and
        # avoid another DB lookup.
        random_string = "".join(random.choices(string.ascii_lowercase + string.digits, k=4))
        return f"{slug}-{random_string}"

    @staticmethod
    def _slugify(value: str) -> str:
        """slugify.

        Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated
        dashes to single dashes. Remove characters that aren't alphanumerics,
        underscores, or hyphens. Convert to lowercase. Also strip leading and
        trailing whitespace, dashes, and underscores.

        Args:
            value (str): the string to slugify

        Returns:
            str: a slugified string of the value parameter
        """
        value = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
        value = re.sub(r"[^\w\s-]", "", value.lower())
        return re.sub(r"[-\s]+", "-", value).strip("-_")

    async def _is_slug_unique(
        self,
        slug: str,
        **kwargs: Any,
    ) -> bool:
        return await self.get_one_or_none(slug=slug) is None

示例 3

from __future__ import annotations

from uuid import UUID

from pydantic import BaseModel as _BaseModel
from sqlalchemy.orm import Mapped

from litestar import post
from litestar.plugins.sqlalchemy import (
    base,
)


class BaseModel(_BaseModel):
    """Extend Pydantic's BaseModel to enable ORM mode"""

    model_config = {"from_attributes": True}


class BlogPost(base.UUIDAuditBase, SlugKey):
    title: Mapped[str]
    content: Mapped[str]


class BlogPostRepository(SQLAlchemyAsyncSlugRepository[BlogPost]):
    """Blog Post repository."""

    model_type = BlogPost


class BlogPostDTO(BaseModel):
    id: UUID | None
    slug: str
    title: str
    content: str


class BlogPostCreate(BaseModel):
    title: str
    content: str
@post(path="/")
async def create_blog(
    blog_post_repo: BlogPostRepository,
    data: BlogPostCreate,
) -> BlogPostDTO:
    """Create a new blog post."""
    _data = data.model_dump(exclude_unset=True, by_alias=False, exclude_none=True)
    _data["slug"] = await blog_post_repo.get_available_slug(_data["title"])
    obj = await blog_post_repo.add(BlogPost(**_data))
    await blog_post_repo.session.commit()
    return BlogPostDTO.model_validate(obj)

示例 4

from __future__ import annotations

import random
import re
import string
import unicodedata
from typing import TYPE_CHECKING, Any
from uuid import UUID

from pydantic import BaseModel as _BaseModel
from pydantic import TypeAdapter
from sqlalchemy.orm import Mapped, declarative_mixin, mapped_column
from sqlalchemy.types import String

from litestar import Litestar, get, post
from litestar.di import Provide
from litestar.plugins.sqlalchemy import (
    AsyncSessionConfig,
    SQLAlchemyAsyncConfig,
    SQLAlchemyInitPlugin,
    base,
    repository,
)

if TYPE_CHECKING:
    from sqlalchemy.ext.asyncio import AsyncSession


class BaseModel(_BaseModel):
    """Extend Pydantic's BaseModel to enable ORM mode"""

    model_config = {"from_attributes": True}


# we are going to add a simple "slug" to our model that is a URL safe surrogate key to
# our database record.
@declarative_mixin
class SlugKey:
    """Slug unique Field Model Mixin."""

    __abstract__ = True
    slug: Mapped[str] = mapped_column(String(length=100), nullable=False, unique=True, sort_order=-9)


# this class can be re-used with any model that has the `SlugKey` Mixin
class SQLAlchemyAsyncSlugRepository(repository.SQLAlchemyAsyncRepository[repository.ModelT]):
    """Extends the repository to include slug model features.."""

    async def get_available_slug(
        self,
        value_to_slugify: str,
        **kwargs: Any,
    ) -> str:
        """Get a unique slug for the supplied value.

        If the value is found to exist, a random 4 digit character is appended to the end.
        There may be a better way to do this, but I wanted to limit the number of
        additional database calls.

        Args:
            value_to_slugify (str): A string that should be converted to a unique slug.
            **kwargs: stuff

        Returns:
            str: a unique slug for the supplied value. This is safe for URLs and other
            unique identifiers.
        """
        slug = self._slugify(value_to_slugify)
        if await self._is_slug_unique(slug):
            return slug
        # generate a random 4 digit alphanumeric string to make the slug unique and
        # avoid another DB lookup.
        random_string = "".join(random.choices(string.ascii_lowercase + string.digits, k=4))
        return f"{slug}-{random_string}"

    @staticmethod
    def _slugify(value: str) -> str:
        """slugify.

        Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated
        dashes to single dashes. Remove characters that aren't alphanumerics,
        underscores, or hyphens. Convert to lowercase. Also strip leading and
        trailing whitespace, dashes, and underscores.

        Args:
            value (str): the string to slugify

        Returns:
            str: a slugified string of the value parameter
        """
        value = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
        value = re.sub(r"[^\w\s-]", "", value.lower())
        return re.sub(r"[-\s]+", "-", value).strip("-_")

    async def _is_slug_unique(
        self,
        slug: str,
        **kwargs: Any,
    ) -> bool:
        return await self.get_one_or_none(slug=slug) is None


# The `UUIDAuditBase` class includes the same UUID` based primary key (`id`) and 2
# additional columns: `created_at` and `updated_at`. `created_at` is a timestamp of when the
# record created, and `updated_at` is the last time the record was modified.
class BlogPost(base.UUIDAuditBase, SlugKey):
    title: Mapped[str]
    content: Mapped[str]


class BlogPostRepository(SQLAlchemyAsyncSlugRepository[BlogPost]):
    """Blog Post repository."""

    model_type = BlogPost


class BlogPostDTO(BaseModel):
    id: UUID | None
    slug: str
    title: str
    content: str


class BlogPostCreate(BaseModel):
    title: str
    content: str


# we can optionally override the default `select` used for the repository to pass in
# specific SQL options such as join details
async def provide_blog_post_repo(db_session: AsyncSession) -> BlogPostRepository:
    """This provides a simple example demonstrating how to override the join options
    for the repository."""
    return BlogPostRepository(session=db_session)


session_config = AsyncSessionConfig(expire_on_commit=False)
sqlalchemy_config = SQLAlchemyAsyncConfig(
    connection_string="sqlite+aiosqlite:///test.sqlite", session_config=session_config
)  # Create 'async_session' dependency.
sqlalchemy_plugin = SQLAlchemyInitPlugin(config=sqlalchemy_config)


async def on_startup() -> None:
    """Initializes the database."""
    async with sqlalchemy_config.get_engine().begin() as conn:
        await conn.run_sync(base.UUIDAuditBase.metadata.create_all)


@get(path="/")
async def get_blogs(
    blog_post_repo: BlogPostRepository,
) -> list[BlogPostDTO]:
    """Interact with SQLAlchemy engine and session."""
    objs = await blog_post_repo.list()
    type_adapter = TypeAdapter(list[BlogPostDTO])
    return type_adapter.validate_python(objs)


@get(path="/{post_slug:str}")
async def get_blog_details(
    post_slug: str,
    blog_post_repo: BlogPostRepository,
) -> BlogPostDTO:
    """Interact with SQLAlchemy engine and session."""
    obj = await blog_post_repo.get_one(slug=post_slug)
    return BlogPostDTO.model_validate(obj)


@post(path="/")
async def create_blog(
    blog_post_repo: BlogPostRepository,
    data: BlogPostCreate,
) -> BlogPostDTO:
    """Create a new blog post."""
    _data = data.model_dump(exclude_unset=True, by_alias=False, exclude_none=True)
    _data["slug"] = await blog_post_repo.get_available_slug(_data["title"])
    obj = await blog_post_repo.add(BlogPost(**_data))
    await blog_post_repo.session.commit()
    return BlogPostDTO.model_validate(obj)


app = Litestar(
    route_handlers=[create_blog, get_blogs, get_blog_details],
    dependencies={"blog_post_repo": Provide(provide_blog_post_repo, sync_to_thread=False)},
    on_startup=[on_startup],
    plugins=[SQLAlchemyInitPlugin(config=sqlalchemy_config)],
)
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容