简介
Lucid 是 AdonisJS 的 SQL 库。它将基于 Knex 的查询构建器与基于类的 Active Record 模型、手写迁移以及 Lucid 直接从你的实时数据库生成的 schema 类结合在一起。
你编写迁移来演进数据库结构,Lucid 读取数据库为每个表重新生成带类型的 schema 类。你的模型继承这些生成的类,并专注于真正重要的内容:关联关系、生命周期钩子以及对数据进行操作的领域方法。
因为 Lucid 是 AdonisJS 围绕构建的数据库层,验证器、IoC 容器、Auth、测试工具和 Ace 命令都与它开箱即用地集成。你无需组装数据库栈;你只需编写应用代码。
快速概览
Lucid 的工作流从数据库而非 TypeScript 开始。你在迁移中描述结构变更,运行它们,然后 Lucid 从实际存在的表生成带类型的 schema 类。
首先编写一个描述新表的迁移。
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
async up() {
this.schema.createTable('posts', (table) => {
table.increments('id')
table.string('title').notNullable()
table.string('status').defaultTo('draft')
table.integer('author_id').unsigned().references('users.id')
table.timestamps(true, true)
})
}
}
运行迁移。Lucid 扫描数据库并重新生成 database/schema.ts,为它发现的每个表生成一个带类型的 schema 类。
node ace migration:run
在你的模型中继承生成的类。列会自动继承,因此你的模型只需声明该模型特有的内容:关联关系、业务逻辑和生命周期钩子。
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import { PostsSchema } from '#database/schema'
import User from './user.js'
export default class Post extends PostsSchema {
@belongsTo(() => User, { foreignKey: 'authorId' })
declare author: BelongsTo<typeof User>
isPublished(): boolean {
return this.status === 'published'
}
async publish() {
this.status = 'published'
await this.save()
}
}
查询和持久化记录,类型从生成的 schema 类端到端地流向你的控制器。
const posts = await Post.query()
.where('status', 'published')
.preload('author')
.orderBy('createdAt', 'desc')
const post = await Post.findOrFail(params.id)
await post.publish()
你的数据库是契约,迁移随时间演进它,你的模型承载操作数据的行为。TypeScript 自动跟随。
数据库是真相之源
大多数 TypeScript ORM 从代码到数据库工作。你将表声明为 TypeScript 类型或装饰器,ORM 通过对比实体定义与数据库来生成迁移。
这种方法对于全新项目效果良好,但当你的应用在生产中有真实数据时就不再适用。列重命名变成"删除并重建"操作,数据会丢失。复杂的数据回填无法表达为 schema 差异。将表拆分为两个表仍然需要你手写迁移。而采用现有遗留数据库实际上是不可能的,除非你从头开始重新创建其整个迁移历史。
Lucid 反转了这个方向。你直接编写迁移,作为对数据库全部功能有完整访问权限的真实 SQL 变更集。迁移运行后,Lucid 扫描数据库并生成 database/schema.ts——一个实际存在的 schema 的带类型投影。你的模型继承这些生成的类,无需重复声明即可继承列类型。
你在读写数据的地方获得强类型,而无需为一个无法表达真实生产工作的代码优先 schema 付出代价。
如果你正在采用现有数据库,可以运行 node ace schema:generate 为已存在的每个表生成带类型的 schema 类。不需要迁移历史,这使得 Lucid 对遗留项目和新项目都是实用的选择。
会做事的模型
Lucid 是一个 Active Record ORM。模型类映射到表,实例映射到行,方法存在于类上,紧挨着它们操作的数据。
大多数 TypeScript ORM 返回普通对象:携带数据但没有行为的记录。像"用户能做什么?"这样的问题在别处回答——通常在一个 service、一个 helper 或一个控制器中。久而久之,同一条规则在三个不同的地方被执行,每个副本都悄悄产生了细微的差异。
Active Record 通过将行为直接放在模型上——它本应属于的地方——消除了这种重复。
import { beforeSave, hasMany } from '@adonisjs/lucid/orm'
import type { HasMany } from '@adonisjs/lucid/types/relations'
import { DateTime } from 'luxon'
import { SubscriptionsSchema } from '#database/schema'
import Invoice from './invoice.js'
export default class Subscription extends SubscriptionsSchema {
@hasMany(() => Invoice)
declare invoices: HasMany<typeof Invoice>
@beforeSave()
static async touchRenewal(subscription: Subscription) {
if (subscription.$dirty.status === 'active') {
subscription.renewedAt = DateTime.now()
}
}
isActive(): boolean {
return this.status === 'active' && this.expiresAt > DateTime.now()
}
async cancel(reason: string) {
this.status = 'cancelled'
this.cancellationReason = reason
await this.save()
}
}
行为与它操作的数据共存,生命周期钩子自动执行不变量,而无需每个调用者都记住它们。调用 subscription.cancel() 总是会记录原因,beforeSave 钩子在订阅变为活跃时总是更新 renewedAt,关联关系在模型上定义一次,在你加载订阅的任何地方都可以复用。模型实例还通过 $dirty 和 $original 跟踪自己的状态,并在作为响应发送时通过 toJSON() 序列化自己。
这与塑造了 Laravel Eloquent、Rails Active Record 和 Elixir Ecto 中领域重型应用的模式相同。Lucid 毫无歉意地将其带到了 TypeScript。
SQL 的完整能力
Lucid 构建在 Knex 之上,这是一个久经考验的 SQL 构建器,处理连接池、事务、迁移以及 PostgreSQL、MySQL、SQLite、MSSQL 和 LibSQL 的方言支持。
这个基础意味着你不局限于 CRUD。当问题需要窗口函数、递归 CTE、JSON 操作或行锁时,你可以在查询构建器中表达它,而无需降级到原始 SQL 字符串。
窗口函数
使用 SQL 窗口函数在数据库中直接对行进行排名、去重结果或计算运行总计,无需在 JavaScript 中后处理。
import db from '@adonisjs/lucid/services/db'
const rankings = await db
.from('scores')
.select('user_id', 'game_id', 'points')
.select(
db.raw(`rank() OVER (PARTITION BY game_id ORDER BY points DESC) as rank`)
)
递归 CTE
使用查询构建器上的 withRecursive 方法,在单个查询中遍历树、图和层次数据。
import db from '@adonisjs/lucid/services/db'
const total = await db
.query()
.withRecursive('tree', (query) => {
query
.from('accounts')
.select('amount', 'id')
.where('id', 1)
.union((subquery) => {
subquery
.from('accounts as a')
.select('a.amount', 'a.id')
.innerJoin('tree', 'tree.id', 'a.parent_id')
})
})
.sum('amount as total')
.from('tree')
JSON 操作
使用 Lucid 的 whereJson、whereJsonSuperset 和 whereJsonSubset 助手,在 PostgreSQL 和 MySQL 上原生查询和过滤 JSON 列。
const users = await User.query()
.whereJsonSuperset('preferences', { theme: 'dark' })
行级锁
在事务中使用 forUpdate 获取行级锁,在数据库层序列化临界区,防止并发写操作相互冲突。
import db from '@adonisjs/lucid/services/db'
await db.transaction(async (trx) => {
const wallet = await Wallet.query({ client: trx })
.where('userId', userId)
.forUpdate()
.firstOrFail()
wallet.balance -= amount
await wallet.save()
})
与其他 ORM 的比较
Prisma 是 schema-first 的。你在 schema.prisma 文件中声明表,Prisma 从该声明生成带类型的客户端。它适合 AdonisJS 之外的、想要生成客户端且不介意代码优先 schema 的全新项目。Lucid 选择 Active Record 行为和数据库优先的 schema 而非生成的客户端。
Drizzle 是 SQL 之上的薄类型层,具有构造时类型安全。它适合当你想以最小抽象自己组装数据库栈时。Lucid 选择框架集成和 Active Record 约定而非最小抽象。
Kysely 是一个完全带类型的查询构建器。它适合当查询构造的类型安全是最高优先级,并且你准备在其之上构建自己的抽象时。Lucid 选择灵活性而非构造时类型,这使得 Auth、工厂、关联关系和预加载构建器能够跨每个模型通用工作。
TypeORM 和 MikroORM 是 TypeScript 的 Active Record 和 Data Mapper 实现。两者都是代码优先的,你声明实体,ORM 通过对比它们与数据库来生成迁移。Lucid 选择手写迁移和生成的 schema 类,使数据库保持为真相之源。
Lucid 何时是合适的选择
Lucid 是正确选择当:
- 你在构建 AdonisJS 应用。 框架围绕 Lucid 设计,因此选择其他方案意味着重新实现与 Auth、验证器、配置和测试工具的集成点。
- 你的领域有真实行为。 当如
order.cancel()、subscription.renew()和user.deactivate()的方法需要执行业务规则、记录审计条目和触发钩子时,Active Record 将该逻辑保留在一个地方并每次执行。 - 你维护生产数据库。 手写迁移可以表达重命名、回填、安全索引上线和多步重构,而对比生成的迁移无法安全地完成这些操作。
- 你正在采用遗留数据库。 运行
schema:generate从已存在的任何表生成带类型的 schema 类,无需迁移历史。 - 你运行多个数据库或读/写副本。 Lucid 按连接、按模型或按请求路由查询,并跨边界保持事务一致。
- 你编写超越 CRUD 的 SQL。 窗口函数、CTE、JSON 操作和锁在查询构建器中都是一等公民。
考虑替代方案当:
- 你不在使用 AdonisJS。 Lucid 的大部分价值来自框架集成,因此像 Drizzle 或 Prisma 的独立库在 AdonisJS 之外可能更合适。
- 构造时类型安全是硬性要求。 如果你需要查询构造本身完全带类型,Kysely 更合适。
- 你偏好 schema-first 工作流和生成的客户端。 Prisma 围绕此工作流塑造,比 Lucid 更适合。
关于类型安全的说明
Lucid 在输出端是强类型的。查询结果、模型实例和关联关系都由生成的 schema 类塑造,因此你在读写数据的任何地方都能获得自动补全和类型错误。
Lucid 在查询构造上并非完全类型化。Kysely 在此设定了标杆,但这伴随着一个刚性权衡:在 Kysely 之上的通用抽象实际上是不可能的,正如 Kysely 作者已确认。Lucid 采取相反的权衡,保持足够灵活以驱动 IoC 容器、Auth、工厂和每个需要跨任意模型通用工作的框架助手。
Lucid 的部分可能随时间变得更加严格,但在构造上与 Kysely 达到同等并非目标。这篇文章更深入地涵盖了推理。