Objection.js 的现代替代方案:迁移到 Sutando
Objection.js 多年来一直是 Node.js 生态中流行的 ORM。基于 Knex.js 构建,它提供了灵活的、SQL 友好的数据库操作方式。但随着原作者宣布不再维护,TypeScript 支持也落后于时代,许多团队开始寻找现代替代方案。本指南将带你从 Objection.js 迁移到…
Objection.js 多年来一直是 Node.js 生态中流行的 ORM。基于 Knex.js 构建,它提供了灵活的、SQL 友好的数据库操作方式。但随着原作者宣布不再维护,TypeScript 支持也落后于时代,许多团队开始寻找现代替代方案。本指南将带你从 Objection.js 迁移到 Sutando。
为什么离开 Objection.js?
Objection.js 仍然是一个可用的库,但以下几个因素让迁移值得考虑:
维护状态
在 GitHub 讨论区 中,原作者 Sami Koskimäki 宣布不再积极维护该项目:
- 没有活跃维护者:项目的 bus factor 极高,唯一的维护者已经离开
- 没有新功能:只有社区 fork 在接收更新
- 发布缓慢:补丁版本之间间隔数月,甚至更久
TypeScript 支持
Objection.js 设计于 TypeScript 普及之前。类型系统可以工作,但需要手动声明类型,无法提供现代 TypeScript 优先 ORM 的无缝类型推断。
没有内置迁移
Objection.js 完全依赖 Knex 进行迁移。虽然 Knex 很可靠,但你需要管理两个独立的工具,它们有不同的 API 和心智模型。
为什么选择 Sutando?
Sutando 是一个现代的 Node.js Active Record ORM,灵感来自 Laravel 的 Eloquent。它解决了 Objection.js 用户面临的痛点:
| 特性 | Objection.js | Sutando |
|---|---|---|
| 模式 | Query Builder + ORM | Active Record |
| TypeScript | 手动类型 | 一等公民 TS 支持 |
| 迁移 | 通过 Knex(独立工具) | 内置 schema builder |
| 维护 | 停滞 | 活跃 |
| 关联 | withGraphFetched |
with(), hasMany(), belongsTo() |
| 软删除 | 手动实现 | 内置 |
| 钩子 | $beforeInsert 等 |
creating, saved 等 |
| 作用域 | 手动实现 | 内置 |
概念映射:Objection.js → Sutando
模型
Objection.js:
import { Model } from 'objection';
import knex from 'knex';
const db = knex({
client: 'pg',
connection: process.env.DATABASE_URL,
});
Model.knex(db);
class User extends Model {
static tableName = 'users';
static jsonSchema = {
type: 'object',
required: ['name', 'email'],
properties: {
id: { type: 'integer' },
name: { type: 'string', minLength: 1, maxLength: 255 },
email: { type: 'string', format: 'email' },
},
};
static relationMappings = {
posts: {
relation: Model.HasManyRelation,
modelClass: Post,
join: { from: 'users.id', to: 'posts.user_id' },
},
};
}
Sutando:
import { sutando, Model } from 'sutando';
sutando.addConnection({
client: 'pg',
connection: process.env.DATABASE_URL,
});
class User extends Model {
protected table = 'users';
relationPosts() {
return this.hasMany(Post);
}
}
查询
Objection.js:
// 简单查询
const users = await User.query().where('age', '>', 18);
// 带关联
const user = await User.query()
.withGraphFetched('[posts.comments]')
.findById(1);
// 插入
const user = await User.query().insert({ name: 'Alice', email: 'alice@example.com' });
// 更新
await User.query().patch({ name: 'Bob' }).where('id', 1);
// 删除
await User.query().deleteById(1);
Sutando:
// 简单查询
const users = await User.query().where('age', '>', 18).get();
// 带关联
const user = await User.query().with('posts.comments').find(1);
// 插入
const user = new User({ name: 'Alice', email: 'alice@example.com' });
await user.save();
// 更新
const user = await User.query().find(1);
user.name = 'Bob';
await user.save();
// 删除
const user = await User.query().find(1);
await user.delete();
关联
Objection.js 使用 relationMappings 声明式对象语法:
static relationMappings = {
posts: {
relation: Model.HasManyRelation,
modelClass: Post,
join: { from: 'users.id', to: 'posts.user_id' },
},
profile: {
relation: Model.BelongsToOneRelation,
modelClass: Profile,
join: { from: 'users.profile_id', to: 'profiles.id' },
},
};
Sutando 使用基于方法的定义,类似 Eloquent:
relationPosts() {
return this.hasMany(Post);
}
relationProfile() {
return this.belongsTo(Profile);
}
事务
Objection.js:
await User.transaction(async (trx) => {
const user = await User.query(trx).insert({ name: 'Alice' });
await Post.query(trx).insert({ title: 'Hello', user_id: user.id });
});
Sutando:
const db = sutando.connection();
await db.transaction(async (trx) => {
const user = new User({ name: 'Alice' });
await user.save({ client: trx });
const post = new Post({ title: 'Hello', user_id: user.id });
await post.save({ client: trx });
});
钩子 / 事件
Objection.js:
class User extends Model {
static tableName = 'users';
$beforeInsert() {
this.created_at = new Date().toISOString();
}
$beforeUpdate() {
this.updated_at = new Date().toISOString();
}
}
Sutando:
import { Model } from 'sutando';
class User extends Model {
protected table = 'users';
static booted() {
this.creating(model => {
model.created_at = new Date();
});
this.updating(model => {
model.updated_at = new Date();
});
}
}
逐步迁移指南
第 1 步:安装 Sutando
npm install sutando
第 2 步:设置数据库连接
创建 db.ts 文件:
import { sutando } from 'sutando';
sutando.addConnection({
client: 'pg',
connection: process.env.DATABASE_URL,
});
export default sutando;
第 3 步:逐步转换模型
不需要一次性迁移所有内容。两个 ORM 可以在同一个项目中共存:
- 先转换最简单的模型(没有复杂关联的)
- 再转换有
hasMany/belongsTo关联的模型 - 最后转换有复杂图操作的模型
第 4 步:迁移数据库迁移
如果你在使用 Knex 迁移,Sutando 的 schema builder 是兼容的:
const { Migration } = require('sutando');
module.exports = class extends Migration {
async up(schema) {
await schema.createTable('users', (table) => {
table.increments('id');
table.string('name');
table.string('email').unique();
table.timestamps();
});
}
async down(schema) {
await schema.dropTableIfExists('users');
}
};
第 5 步:替换查询
遍历代码库,将 Objection.js 查询替换为 Sutando 等价代码。主要变化:
- Objection 的
Model.query()返回查询构建器;Sutando 中需要链式调用.get()或.first()来执行 withGraphFetched('[posts.comments]')变成with('posts.comments')insert()/patch()/deleteById()变成 Active Record 风格的save()/delete()
迁移后你获得了什么
- Active Record 模式:不再需要分离的查询构建器和模型——直接操作模型实例
- 内置软删除:用
SoftDeletes插件(compose(Model, SoftDeletes))即可获得deleted_at自动处理 - 内置作用域:通过
scope方法定义可复用的查询过滤器 - 一等公民 TypeScript:类型推断与基于类的模型自然配合
- Eloquent 风格 API:如果你用过 Laravel,学习曲线几乎为零
- 活跃维护:定期发布和新功能
结论
Objection.js 为 Node.js 社区做出了贡献,但它的维护状态和 TypeScript 局限性使其成为长期项目的隐患。Sutando 提供了一个熟悉的、维护良好的替代方案,Active Record 模式减少了样板代码,提升了开发体验。
迁移可以增量进行——两个 ORM 可以共存,你逐个模型地过渡。今天就从一个模型开始,亲自感受差异。
👉 开始使用 Sutando 或运行 npm install sutando 来试试。