Skip to content

模型 ​

一个模型对应一张 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表名,默认为名称的复数下划线形式
--dialectsqlite、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)
softDeletefalsetrue 使用 deletedAt 列,传字符串则使用指定列
timestampstrue列存在时自动维护 createdAt / updatedAt。{ createdAt: 'addedAt', updatedAt: false } 可改名或关闭其中一个,false 全部关闭
readonly[]只读字段:新增时写入,之后不再更新
optimLockfalse乐观锁: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 / whereNotInIN / NOT IN 列表
whereNull / whereNotNullIS NULL / IS NOT NULL
whereBetween / whereNotBetween区间 [min, max]
whereLike / whereNotLikeLIKE 匹配
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,例如判断是哪个字段重复:

    ts
    try {
      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 通过模型查找用户,见认证。

基于 MIT 许可证发布