序列化模型
本指南涵盖使用 AdonisJS transformers 序列化 Lucid 模型的特定模式。你将学会如何:
- 在生成 Lucid 模型的同时生成一个 transformer
- 在将模型传递给 transformer 之前预加载关联关系
- 对 Lucid 查询进行分页并序列化分页器结果
- 暴露由
withCount和withAggregate填充的$extras - 包含多对多关联关系上的中间表(pivot)属性
- 在 transformer 输出中包含模型访问器(accessors)和旁路加载(sideloaded)的上下文
- 使用变体(variants)塑造列表与详情响应的结构
概述
AdonisJS transformers 是将 Lucid 模型序列化为 JSON 响应的推荐方式。它们让你对输出结构拥有明确的控制权,通过同一个 serialize() 助手与 Inertia 和 JSON API 集成,并生成前端可以直接使用的 TypeScript 类型。
本指南重点关注 Lucid 特定的模式。AdonisJS transformers 指南涵盖了 transformer API 本身:创建 transformers、toObject / pick、变体、依赖注入、自定义构造函数数据、.depth(),以及生成的 TypeScript 类型。如果你是 transformers 的新手,请先阅读该指南。
在模型旁边生成 transformer
当你使用 --transformer 生成模型时,Lucid 会同时在 app/transformers 中创建一个匹配的 transformer。
node ace make:model Post --transformer
// CREATE: app/models/post.ts
// CREATE: app/transformers/post_transformer.ts
生成的 transformer 使用你模型的类名,从 #models/... 导入它,并声明带类型的资源。
import { BaseTransformer } from '@adonisjs/core/transformers'
import Post from '#models/post'
export default class PostTransformer extends BaseTransformer<Post> {
toObject() {
return this.pick(this.resource, ['id'])
}
}
从这里开始,添加你想要在输出中出现的字段,并在控制器中将其连接起来。本指南的其余部分将介绍当 transformer 需要处理真实的 Lucid 查询时你会用到的模式。
转换前先预加载
Transformers 不会发出自己的查询。它们只从你传递给 transform 的模型实例中读取数据。如果 transformer 访问了一个未被预加载的关联关系,它会在 hasOne 和 belongsTo 关联上读取到 undefined(或在 hasMany 和 manyToMany 上读取到空数组),最好的情况是静默跳过它,如果你忘记保护访问则会抛出异常。
规则是:无论 transformer 读取什么,查询都必须已经预加载了它。
import type { HttpContext } from '@adonisjs/core/http'
import Post from '#models/post'
import PostTransformer from '#transformers/post_transformer'
export default class PostsController {
async index({ serialize }: HttpContext) {
const posts = await Post.query()
.preload('author')
.preload('tags')
.orderBy('created_at', 'desc')
return serialize(PostTransformer.transform(posts))
}
}
import { BaseTransformer } from '@adonisjs/core/transformers'
import Post from '#models/post'
import UserTransformer from '#transformers/user_transformer'
import TagTransformer from '#transformers/tag_transformer'
export default class PostTransformer extends BaseTransformer<Post> {
toObject() {
return {
...this.pick(this.resource, ['id', 'title', 'body', 'createdAt']),
author: UserTransformer.transform(this.resource.author),
tags: TagTransformer.transform(this.resource.tags),
}
}
}
嵌套预加载遵循相同的结构:查询预加载像 posts.comments.author 这样的路径,每一层的 transformer 将其关联关系传递给相应的 transformer。
const users = await User
.query()
.preload('posts', (postsQuery) => {
postsQuery.preload('comments', (commentsQuery) => {
commentsQuery.preload('author')
})
})
return serialize(UserTransformer.transform(users))
当 transformer 尝试读取一个可能未加载的关联关系时,使用 this.whenLoaded(...) 优雅地跳过渲染。这是仅由某些端点预加载的关联关系的规范模式。
author: UserTransformer.transform(this.whenLoaded(this.resource.author))
日期和时间格式化
每个 @column.date 和 @column.dateTime 列都是一个 Luxon DateTime 实例。DateTime.toJSON() 默认发出 ISO 8601 字符串,因此通过 pick 包含日期列会在网络上产生一个标准的机器可读日期。
return this.pick(this.resource, ['createdAt'])
// { createdAt: "2026-03-15T10:45:00.000Z" }
当你想要特定的格式(仅日期、本地化或截断)时,在 transformer 中显式格式化日期。
return {
...this.pick(this.resource, ['id', 'title']),
publishedOn: this.resource.publishedOn?.toFormat('yyyy-MM-dd'),
publishedRelative: this.resource.publishedAt?.toRelative(),
}
?. 处理可空列。对于保证非空的列(带有日期类型的主键、保存后的 autoCreate 时间戳),可选链是不必要的。
模型分页
Post.query().paginate(page, perPage) 返回一个 ModelPaginator,它在 all() 上暴露页面的行,在 getMeta() 上暴露分页元数据。将两者传递给 transformer 的静态 paginate 方法。
import type { HttpContext } from '@adonisjs/core/http'
import Post from '#models/post'
import PostTransformer from '#transformers/post_transformer'
export default class PostsController {
async index({ request, serialize }: HttpContext) {
const page = request.input('page', 1)
const posts = await Post.query()
.preload('author')
.orderBy('created_at', 'desc')
.paginate(page, 20)
return serialize(
PostTransformer.paginate(posts.all(), posts.getMeta())
)
}
}
响应体在 data 下包含转换后的行,在 metadata 下包含分页元数据。对于 Inertia,直接将分页器结果传递给 inertia.render,而无需包装在 serialize 中(Inertia 适配器会处理解析)。
return inertia.render('posts/index', {
posts: PostTransformer.paginate(posts.all(), posts.getMeta()),
})
有关包括 URL 自定义在内的完整分页工作流,请参阅分页指南。
暴露 $extras
通过 withCount 和 withAggregate 加载的聚合不会成为模型上的列。它们落在模型实例的 $extras 对象上,因此 transformer 需要从那里读取它们。
const authors = await User.query()
.withCount('posts')
.withAggregate('posts', (query) => {
query.max('created_at').as('lastPostAt')
})
.orderBy('id', 'asc')
return serialize(UserTransformer.transform(authors))
import { BaseTransformer } from '@adonisjs/core/transformers'
import User from '#models/user'
export default class UserTransformer extends BaseTransformer<User> {
toObject() {
return {
...this.pick(this.resource, ['id', 'email', 'fullName']),
postsCount: Number(this.resource.$extras.posts_count ?? 0),
lastPostAt: this.resource.$extras.lastPostAt ?? null,
}
}
}
关于聚合和 $extras 需要了解的几件事:
withCount将结果存储在{relationName}_count(snake_case)下,因此withCount('posts')会填充$extras.posts_count。使用回调形式(.as('publishedPostsCount'))覆盖别名以获得更友好的键。- 聚合结果在某些驱动上作为字符串返回(MySQL 和 SQLite 对整数聚合返回字符串;PostgreSQL 返回数字)。如果前端期望数字类型而无论驱动如何,请使用
Number(...)进行强制转换。 $extras是无类型的。如果由于查询未请求而缺少字段,读取$extras.foo将返回undefined。使用?? 0(用于计数)或?? null(用于值)设置默认值,以便 transformer 在使用不同查询的端点之间保持稳健。
来自原始 SQL 的连接列也落在 $extras 中。
await User.query()
.select('users.*', 'subscriptions.plan as subscription_plan')
.leftJoin('subscriptions', 'subscriptions.user_id', 'users.id')
// In the transformer:
subscriptionPlan: this.resource.$extras.subscription_plan ?? null
多对多与中间表属性
当模型通过 manyToMany 关联关系返回时,其中间表列会以 pivot_ 前缀合并到 $extras 中。一个 User.belongsToMany(Skill),在带有 proficiency 和 last_used_at 列的 user_skills 表上进行关联,会在每个加载的 Skill 实例上将它们暴露为 $extras.pivot_proficiency 和 $extras.pivot_last_used_at。
在关联关系上声明中间表列,从查询端预加载它们,并在 transformer 中从 $extras 读取它们。
import { manyToMany } from '@adonisjs/lucid/orm'
import type { ManyToMany } from '@adonisjs/lucid/types/relations'
import Skill from '#models/skill'
export default class User extends UsersSchema {
@manyToMany(() => Skill, {
pivotTable: 'user_skills',
pivotColumns: ['proficiency', 'last_used_at'],
})
declare skills: ManyToMany<typeof Skill>
}
const users = await User.query().preload('skills')
return serialize(UserTransformer.transform(users))
import { BaseTransformer } from '@adonisjs/core/transformers'
import Skill from '#models/skill'
export default class SkillTransformer extends BaseTransformer<Skill> {
toObject() {
return {
...this.pick(this.resource, ['id', 'name']),
pivot: {
proficiency: this.resource.$extras.pivot_proficiency,
lastUsedAt: this.resource.$extras.pivot_last_used_at,
},
}
}
}
在输出中将中间表数据分组在 pivot 键下是一种常见的约定,使其在视觉上与关联模型自己的字段分开。有关中间表列、中间表时间戳和 pivotQuery 模式的更多信息,请参阅多对多关联关系指南。
模型访问器与派生属性
模型上的 TypeScript getters 作为常规可读属性暴露。它们在 transformer 内部通过 this.resource.<name> 可用,并与 this.pick(...) 一起工作。
export default class User extends UsersSchema {
get fullName(): string {
return `${this.firstName} ${this.lastName}`.trim()
}
get initials(): string {
return `${this.firstName?.[0] ?? ''}${this.lastName?.[0] ?? ''}`.toUpperCase()
}
}
toObject() {
return this.pick(this.resource, [
'id',
'email',
'firstName',
'lastName',
'fullName',
'initials',
])
}
需要异步工作(获取签名 URL、调用外部服务)的 Getters 不能通过 pick 使用。在异步 toObject 内部执行异步工作,并将选取的字段与计算值一起展开。
async toObject() {
return {
...this.pick(this.resource, ['id', 'avatarPath']),
avatarUrl: await this.resource.buildSignedAvatarUrl(),
}
}
旁路加载上下文
模型查询构建器的 sideload(value) 将任意键值对附加到每个返回的模型实例的 $sideloaded 下。附加的值也会传播到预加载的关联关系中,因此你可以通过查询树传递请求作用域上下文(当前用户、租户 ID、功能标志),而无需修改下游的每个控制器。
const posts = await Post
.query()
.preload('author')
.sideload({ currentUser: auth.user })
在需要它的 transformer 中从 $sideloaded 读取值。因为对象在树中每个加载的模型之间共享,所以 Post 和预加载的 author 都会看到相同的 currentUser。
toObject() {
const currentUser = this.resource.$sideloaded.currentUser as User | undefined
return {
...this.pick(this.resource, ['id', 'title', 'body']),
isOwnPost: currentUser?.id === this.resource.authorId,
}
}
$sideloaded 是无类型的,因此在 transformer 中将其转换为预期的形状。如果值可能不存在(某些端点不调用 sideload),请显式处理 undefined 的情况。
当许多嵌套 transformers 需要相同的上下文时,$sideloaded 最有用。当上下文仅与一个 transformer 相关时,在 transformer 方法中依赖注入 HttpContext 通常更干净。
使用变体区分列表与详情
将查询的预加载深度与 transformer 变体配对,为列表和详情端点生成不同的结构。列表端点仅预加载列表视图所需的内容,而详情端点预加载更多内容并使用更丰富的变体。
import { BaseTransformer } from '@adonisjs/core/transformers'
import Post from '#models/post'
import UserTransformer from '#transformers/user_transformer'
import CommentTransformer from '#transformers/comment_transformer'
export default class PostTransformer extends BaseTransformer<Post> {
toObject() {
return {
...this.pick(this.resource, ['id', 'title', 'createdAt']),
author: UserTransformer.transform(this.resource.author),
}
}
async forDetailedView() {
return {
...(await this.toObject()),
body: this.resource.body,
comments: CommentTransformer.transform(this.resource.comments),
}
}
}
async index({ request, serialize }: HttpContext) {
const page = request.input('page', 1)
const posts = await Post
.query()
.preload('author')
.orderBy('created_at', 'desc')
.paginate(page, 20)
return serialize(PostTransformer.paginate(posts.all(), posts.getMeta()))
}
async show({ params, serialize }: HttpContext) {
const post = await Post
.query()
.where('id', params.id)
.preload('author')
.preload('comments', (q) => q.preload('author'))
.firstOrFail()
return serialize(
PostTransformer.transform(post).useVariant('forDetailedView')
)
}
详情变体通过 this.toObject() 重用基础结构,并叠加特定于详情视图的字段。因为查询仅针对详情端点预加载 comments,所以评论仅出现在 forDetailedView 中。有关包括类型生成在内的完整变体 API,请参阅 transformers 变体指南。
进一步学习
- AdonisJS transformers 指南涵盖了 transformer API 基础:
toObject、pick、serialize、变体、依赖注入、自定义构造函数数据、深度控制,以及传递到前端的生成的 TypeScript 类型。 - 分页指南端到端地涵盖了分页,包括 URL 自定义和 API 与 Inertia 响应结构。
- 多对多关联关系指南深入涵盖了中间表列、中间表时间戳和查询时中间表操作。