简体中文
模型
一个模型对应一张 drizzle 表,字段和类型直接来自表结构,不需要重复声明。
定义模型
ts
// app/model/User.ts
import { Model } from '@hazejs/orm'
import { users } from '~/database/schema/users'
export class User extends Model.define(users) {
// 序列化(接口返回)时隐藏密码哈希
static override hidden = ['password']
}实例上可以直接访问每一列(user.name、user.email),类型由表结构推导。
用 haze make:model 同时生成模型和表结构:
bash
haze make:model Post # app/model/Post.ts + database/schema/posts.ts
haze make:model OrderItem --table items # 自定义表名(默认 order_items)
haze make:model Post --softDelete # 启用软删除
haze make:model Article --dialect mongodb| 参数 | 说明 |
|---|---|
--table | 表名,默认为名称的复数下划线形式 |
--dialect | sqlite、postgres、mysql 或 mongodb,默认按项目已安装的驱动推断 |
--softDelete | 在模型上设置 softDelete = true |
--app / -a | 多应用:生成到 app/<app>/model |
--force | 文件已存在时覆盖 |
已存在的表结构文件不会被覆盖。修改表结构后执行 haze db:generate && haze db:migrate(见快速开始)。
模型配置
通过静态属性配置模型:
| 属性 | 默认值 | 说明 |
|---|---|---|
connection | 默认连接 | config/database.ts 中的连接名 |
hidden | [] | toJSON() 时隐藏的字段;'posts.title' 隐藏已加载关联中的字段 |
visible | [] | 不为空时只输出这些字段 |
append | [] | toJSON() 时追加的虚拟字段(需要定义对应的 getXxxAttr) |
softDelete | false | true 使用 deletedAt 列,传字符串则使用指定列 |
timestamps | true | 列存在时自动维护 createdAt / updatedAt。{ createdAt: 'addedAt', updatedAt: false } 可改名或关闭其中一个,false 全部关闭 |
readonly | [] | 只读字段:新增时写入,之后不再更新 |
optimLock | false | 乐观锁:true 使用 lockVersion 列,传字符串则使用指定列 |
globalScope | [] | 对所有查询生效的全局作用域 |
时间戳按列类型写入 Date、字符串或毫秒数。
查询模型
ts
const user = await User.find(1) // User | undefined
const user = await User.findOrFail(1) // 不存在时抛出 ModelNotFoundException(404)
const everyone = await User.all()
const total = await User.count()
const ada = await User.where('email', 'ada@example.com').find()
const admins = await User.where('role', 'admin').order('id', 'desc').select()findOrFail() 抛出的 ModelNotFoundException 会渲染为 404,所以控制器里直接 return User.findOrFail(id) 即可。
分页
ts
const page = await User.where('role', 'member').order('id', 'desc').paginate(1, 20)
// { data: User[], total, page, perPage, lastPage }perPage 默认为 15。返回值是普通对象,可以直接作为控制器的返回值。
构造查询
Model.query() 返回的与 Db.table() 是同一个链式查询构造器(结果为模型);连接查询、子查询、分组、JSON 字段、时间条件、查询缓存等见查询构造器。Model.where() 等静态快捷方法会开始这样一个查询。基础写法:
ts
User.where('role', 'admin') // 等于
User.where('id', '>', 10) // 运算符
User.where({ role: 'admin', email: null }) // 对象:null 为 IS NULL,数组为 IN
User.where('id', [1, 2, 3]) // IN
User.where(q => q.where('role', 'admin').orWhere('id', 1)) // 括号分组
User.where(eq(users.id, 1)) // 任意 drizzle 表达式支持的运算符:=、!=、<>、>、>=、<、<=、like、not like、in、not in、between、not between。
| 方法 | 说明 |
|---|---|
where / orWhere | 条件。多次 where 以 AND 连接,orWhere 以 OR 连接,从左到右组合 |
whereIn / whereNotIn | IN / NOT IN 列表 |
whereNull / whereNotNull | IS NULL / IS NOT NULL |
whereBetween / whereNotBetween | 区间 [min, max] |
whereLike / whereNotLike | LIKE 匹配 |
when(value, q => ...) | value 非空时才执行回调 |
order(column, 'asc' | 'desc') | 也可以 order({ role: 'asc', id: 'desc' }) 或传 drizzle 表达式 |
limit / offset / page(page, perPage) | 不统计总数的分页 |
select() | 执行查询,返回全部结果 |
find() / findOrFail() | 第一条结果(模型查询传入 id 时按主键查找) |
value(column) / column(column) | 第一行的某个值 / 某一列的全部值 |
count / sum / avg / max / min / exists | 聚合 |
paginate(page, perPage) | 分页结果与总数 |
chunk(size, rows => ...) | 分批处理大量数据,回调返回 false 时停止 |
update(values) / delete() | 批量写入,返回影响行数 |
increment(column, step) / decrement(column, step) | 原子自增 / 自减 |
toSQL() | 生成的 SQL 与参数,调试用 |
静态快捷方法比它开始的查询更受限:Model.where() 只接受模型自身的列('posts.views' 这类点号路径或 'meta->city' 这类 JSON 路径需要用 Model.query().where(...)),Model.order() 只接受单个列,Model.count() 不接受列参数。关联相关的方法中,withCount()、has() 和 hasWhere() 有静态版本;withSum() / withAvg() / withMax() / withMin()、hasNot() 和 hasWhereOr() 只能在查询上调用(User.query().withSum('posts', 'views')),查询构造器的大多数方法(field()、whereLike()、lock()、max() 等)也是如此。
完整列表见查询构造器。insert() / upsert()(不经过模型事件的插入)通常配合 Db.table() 使用。
一个典型的筛选接口:
ts
// app/controller/PostController.ts
index(ctx: HttpContext) {
const keyword = ctx.query('q')
return Post.query()
.when(keyword, (q, value) => q.whereLike('title', `%${value}%`))
.order('id', 'desc')
.paginate(Number(ctx.query('page') ?? 1), 20)
}新增、修改与删除
ts
// 创建并保存(触发事件、写入时间戳)
const user = await User.create({ name: 'Ada', email: 'ada@example.com' })
// 修改后保存:只更新修改过的列
user.name = 'Ada Lovelace'
await user.save()
// 批量赋值:表中不存在的字段会被忽略
user.fill({ name: 'Ada', unknown: 1 })
await user.save()
await user.delete()
await User.destroy([1, 2, 3]) // 按主键删除,返回删除数量更多写入方式:
ts
await user.save({ name: 'Ada' }) // 赋值并保存
await User.create(input, ['name', 'email']) // 只从 input 中取这些字段
await user.allowField(['name', 'bio']).save(input) // 同上,作用于实例
await user.force().save() // 即使没有修改也执行更新(刷新 updatedAt)
await post.inc('views').save() // 原子更新 views = views + 1(还有 dec())
await User.update({ status: 0 }, { role: 'guest' }) // 查出匹配的模型并逐个保存
await User.update({ id: 1, name: 'Ada' }) // 按数据中的主键
await User.saveAll([{ id: 1, name: 'Ada' }, { name: 'Bob' }]) // 有主键则更新,否则新增,在事务中执行
await User.destroy({ status: 0 }) // 或 destroy(q => q.where('age', '<', 18))
const user = await User.findOrEmpty(id) // 不会是 undefined
if (user.isEmpty()) { /* ... */ }静态的 update()、saveAll() 和 destroy() 会逐个处理模型,因此事件、时间戳和软删除都会生效。这也意味着它们先查出匹配的行再逐条写入:N+1 条语句、不是原子的,并且读取整行(大 JSON 文档的表很慢)。需要多次写入保持原子性时使用 User.transaction(...),见事务。按需要选择写法:
| 需要 | 写法 |
|---|---|
模型事件、updatedAt、只读字段 | User.update(data, where) / User.destroy(ids)(先加载,再逐个保存 / 删除) |
按主键一条 UPDATE,维护 updatedAt,不触发事件 | User.updateById(id, data) |
| 带条件的原子操作,并根据影响行数判断是否成功 | User.where(...).update(data) / User.where(...).delete()(应用软删除与全局作用域)或 Db.table(t) |
ts
await Article.updateById(1, { body: hugeJson }) // UPDATE ... WHERE id = 1,返回影响行数
await Article.updateById([1, 2], { status: 0 })
// 条件更新,并判断是否成功
const claimed = await Job.where('id', id).where('status', 'pending').update({ status: 'running' })
if (claimed === 0) { /* 已被其他请求领取 */ }
await Article.query().delete(1) // 一条语句;开启软删除时为软删除updateById() 忽略值为 undefined 的键与只读字段,写入 updatedAt,把乐观锁版本加 1(不检查版本),遵守软删除、全局作用域与多租户,不触发模型事件。
create()(以及新模型的 save())用应用的时钟写入 createdAt / updatedAt,而不是数据库的时钟——模型赋了值时不会用到列的 defaultNow()。在 SQL 中与 now() 比较这些时间戳时要注意,或者自己赋值。create() 的值可以是 drizzle 的 sql 表达式,返回的模型中是写入后的值。
其他实例方法:isDirty(key?)、getDirty()、getOriginal(key?)、getData()(原始属性,类型为表的行)、refresh()(从数据库重新读取),以及 exists 属性。
复合主键
模型需要单列主键。表使用复合主键(primaryKey({ columns: [...] }))时,find(id)、通过 save() 更新、delete()、destroy() 与 updateById() 会抛出错误(“has no single-column primary key”)。这类表用 Db.table() 查询(或在模型查询上写明条件),upsert() 需要写 target:
ts
await Db.table(userRoles).where({ userId, roleId }).delete()
await Db.table(userRoles).upsert({ userId, roleId, grantedAt: new Date() }, { target: ['userId', 'roleId'] })只读字段
ts
export class User extends Model.define(users) {
static override readonly = ['email']
}只读字段在新增时写入,之后的 save() 会忽略对它的修改。
乐观锁
开启 optimLock 后,每次更新都会检查数据在读取之后没有被修改过,并递增版本号:
ts
export class Article extends Model.define(articles) {
static override optimLock = true // 需要整数列 lockVersion
}
const a = await Article.findOrFail(1)
const b = await Article.findOrFail(1)
a.title = 'One'
await a.save() // lockVersion 0 → 1
b.title = 'Two'
await b.save() // 抛出 OptimisticLockException(409)新建模型时版本列交给数据库的默认值(例如 integer('revision').notNull().default(1)),只有没有默认值的列才从 0 开始。
查询上的批量 update() / delete() 是一条 SQL 语句:不触发模型事件、不维护时间戳;没有任何条件时会拒绝执行,除非传入 { all: true }。只有自己写的条件才算(where()、局部作用域、传给 delete(ids) 的主键):模型自动附加的过滤——软删除的 deletedAt IS NULL、多租户列和全局作用域——仍然会加到语句里,但不算条件,所以不带条件的 Post.query().delete()、forceDelete()、restore() 同样会报错。
软删除
ts
// app/model/Post.ts
export class Post extends Model.define(posts) {
static override softDelete = true // 需要 deletedAt 列
}ts
await post.delete() // 写入 deletedAt
await Post.all() // 自动排除已删除的数据
await Post.withTrashed().select() // 包含已删除的数据
await Post.onlyTrashed().select() // 只查已删除的数据
await post.restore() // 清空 deletedAt
await Post.whereIn('id', [1, 2]).restore() // 批量恢复
await Post.onlyTrashed().restore({ all: true }) // 恢复全部已删除的数据
await post.forceDelete() // 物理删除
post.trashed() // boolean
await Post.query().delete([1, 2]) // 批量软删除(一条语句)
await Post.withTrashed().forceDelete([1, 2]) // 批量物理删除;也可 forceDelete({ all: true })
await Post.onlyTrashed().delete({ all: true }) // 清理已软删除的数据查询上的 delete() 执行软删除,但在 onlyTrashed() 之后会物理删除这些行。查询上的 forceDelete() 总是物理删除;和其他模型查询一样,它默认跳过已软删除的行,需要包含它们时加上 withTrashed()。
make:model 生成的表结构已经带有 deletedAt 列。
访问器、修改器与追加字段
定义 getXxxAttr(value, data) 在读取时转换值,定义 setXxxAttr(value, data) 在写入时转换值:
ts
// app/model/User.ts
export class User extends Model.define(users) {
static override hidden = ['password']
static override append = ['initials']
setEmailAttr(value: string) {
return value.trim().toLowerCase()
}
getInitialsAttr(_value: unknown, data: Record<string, unknown>) {
return String(data.name).split(' ').map(part => part[0]).join('')
}
}需要跳过访问器读取原始值时用 getRaw(key)。withAttr('name', (value, data) => ...) 为单个实例添加访问器,也可以作用于查询的每个结果:User.query().withAttr('name', ...)。
序列化
toJSON()(别名 toArray())会应用访问器、去掉 hidden 字段、加上 append 字段,并包含已加载的关联。控制器返回模型或分页结果时会自动序列化,所以模板中的 password 列不会出现在响应里。
可以按实例或按查询覆盖这些设置:
ts
user.hidden(['email']).toJSON()
user.visible(['id', 'name', 'posts.title']).toJSON() // 点号作用于已加载的关联
user.append(['initials']).toJSON()
user.hidden(['phone'], true) // true:与模型上的 hidden 合并
await User.query().visible(['id', 'name']).select() // 作用于每个结果查询作用域
ts
// app/model/Post.ts
import type { ModelQuery } from '@hazejs/orm'
export class Post extends Model.define(posts) {
static scopePublished(query: ModelQuery<Post>) {
query.where('published', true)
}
}
await Post.scope('published').order('id', 'desc').paginate(1, 20)多余的参数会透传:scope('ofAuthor', userId) 调用 scopeOfAuthor(query, userId)。
全局作用域
在 globalScope 中列出作用域名称,模型的所有查询(包括关联查询)都会自动应用:
ts
export class Post extends Model.define(posts) {
static override globalScope = ['tenant']
static scopeTenant(query: ModelQuery<Post>) {
query.where('tenantId', currentTenantId())
}
}
await Post.all() // WHERE tenant_id = ?
await Post.withoutGlobalScope(['tenant']).select() // 关闭其中一个
await Post.withoutGlobalScope().select() // 全部关闭全局作用域与其他条件以 AND 连接,orWhere() 不会绕过它。全局作用域只用于条件,其中设置的排序、预加载等会被忽略。
搜索器
withSearch(fields, data) 把筛选表单转换为查询条件:对每个字段,模型定义了 static searchXxxAttr(query, value, data) 时调用它,否则在值非空时按相等查询:
ts
export class User extends Model.define(users) {
static searchNameAttr(query: ModelQuery<User>, value: string) {
if (value)
query.whereLike('name', `${value}%`)
}
static searchCreatedAtAttr(query: ModelQuery<User>, value: string | undefined) {
if (value)
query.whereDay('createdAt', value)
}
}
// GET /users?name=Ad&status=1&createdAt=2024-05-01
await User.withSearch(['name', 'status', 'createdAt'], ctx.query()).paginate(1, 20)模型事件
在模型上定义以下实例方法即可(可以是异步的):onBeforeWrite、onAfterWrite、onBeforeInsert、onAfterInsert、onBeforeUpdate、onAfterUpdate、onBeforeDelete、onAfterDelete、onBeforeRestore、onAfterRestore。
ts
export class Post extends Model.define(posts) {
onBeforeInsert() {
this.slug ??= slugify(this.title)
}
onBeforeDelete() {
// before 事件返回 false 时取消写入
if (this.locked)
return false
}
}before 事件返回 false 时,save() 不写入直接返回,delete() 返回 false。
关联
关联定义为模型方法。这里介绍基础用法;多对多、远程一对多、多态关联、预加载约束、关联统计与 has() 查询见模型关联。
ts
// app/model/User.ts
export class User extends Model.define(users) {
posts() {
return this.hasMany(Post, 'userId')
}
}
// app/model/Post.ts
export class Post extends Model.define(posts) {
author() {
return this.belongsTo(User, 'userId')
}
}ts
const posts = await user.posts() // 直接 await 关联
const recent = await user.posts().query().order('id', 'desc').limit(5).select()
// 预加载,避免 N+1 查询;用点号嵌套
const users = await User.with('posts', 'posts.author').paginate(1, 20)
await user.load('posts')
user.getRelation('posts') // 已加载的结果本地键 / 所属键默认为主键,可以通过第三个参数指定。
ts
await User.withCount('posts').has('posts', '>=', 3).select() // 见模型关联异常
ModelNotFoundException:findOrFail()抛出,渲染为404。UniqueConstraintException:任意驱动的唯一键冲突,渲染为409,code为'unique_violation'。配置了多语言时默认消息按http.unique_violation翻译。异常带有驱动给出的信息:constraint(PostgreSQL 的约束、MySQL 的键、MongoDB 的索引)、table、columns(数据库中的列名;PostgreSQL、SQLite、MongoDB)与detail,例如判断是哪个字段重复:tstry { await User.create(input) } catch (error) { if (error instanceof UniqueConstraintException && error.columns?.includes('email')) throw new ValidationException([{ path: 'email', message: '邮箱已被注册' }]) throw error }OptimisticLockException:开启乐观锁时保存了过期的模型,渲染为409。
MongoDB
安装 mongodb,添加连接 { driver: 'mongodb', url: 'mongodb://…/app' },然后用 @hazejs/orm/mongo 定义模型,API 与 SQL 模型一致:
ts
// app/model/Article.ts
import { MongoModel } from '@hazejs/orm/mongo'
export interface ArticleDoc {
title: string
views: number
}
export class Article extends MongoModel.define<ArticleDoc>('articles') {
static override softDelete = true
}ts
import { Mongo } from '@hazejs/orm/mongo'
const article = await Article.findOrFail('6650…') // 字符串 id 自动转为 ObjectId
await Article.where('views', '>', 100).paginate(1, 20)
await Mongo.collection('articles').aggregate([{ $group: { _id: null, views: { $sum: '$views' } } }])
await Mongo.transaction(async () => { /* 需要副本集 */ })MongoDB 没有迁移,索引通过 (await Mongo.db()).collection(...).createIndex() 管理。haze make:model Article --dialect mongodb 会生成带文档接口的模型。
用模型做认证
modelUserProvider(() => User, { username: 'email', password: 'password' }) 让 config/auth.ts 通过模型查找用户,见认证。