- 数据库
- 后端
【免费下载链接】objection.js
An SQL-friendly ORM for Node.js
Objection.js 是一个 SQL-friendly 的 Node.js ORM,它的全部核心都建立在“模型(Model)”之上:一个Model子类对应一张数据库表,该类的实例对应表中的一行。本篇指南以官方 Models 指南 为骨架,结合仓库源码与真实示例,系统讲解如何定义模型、配置tableName/idColumn/jsonSchema/relationMappings,以及为什么 Objection.js 没有全局配置、如何用BaseModel统一共享配置。读完本文,你将能独立编写出一个生产可用的模型层,并理解校验、复合主键、关系映射背后的源码级实现。
一、核心概念:表与行的面向对象映射
在 Objection.js 中,模型的定义遵循一条最简原则:
一个 Model 子类代表一张数据库表,这个类的实例代表表中的一行。
创建一个模型就是继承Model类,并在子类上通过静态属性声明它与数据库表的映射关系:
- tableName:模型对应的表名(唯一必选属性);
- idColumn:表的主键列名;
- jsonSchema:可选的输入校验 JSON Schema;
- relationMappings:模型与其他模型之间的关系(关联)定义。
其中关系映射通过静态的relationMappings属性声明,详细的关系类型与用法见 Relations 指南。
二、最简模型:只有 tableName 就够
官方文档给出的“最小可用”模型只有几行代码:
const { Model } = require('objection'); class MinimalModel extends Model { static get tableName() { return 'someTableName'; } } module.exports = MinimalModel;仓库中的 minimal 示例 与此完全一致,只额外导出了具名类:
'use strict'; const { Model } = require('objection'); class Person extends Model { // Table name is the only required property. static get tableName() { return 'persons'; } } module.exports = { Person, };从源码看,tableName确实是硬性要求。Model.getTableName()会先兼容“静态属性”与“静态 getter”两种写法(isFunction(tableName)分支),随后校验:
// lib/model/Model.js#L341-L353 static getTableName() { let tableName = this.tableName; if (isFunction(tableName)) { tableName = this.tableName(); } if (!isString(tableName)) { throw new Error(`Model ${this.name} must have a static property tableName`); } return tableName; }而基类Model的默认值是Model.tableName = null(见 lib/model/Model.js),也就是说不声明tableName会在运行时直接抛出异常。
从源码结构看:getter 与静态属性的等价性
Objection.js 允许static get xxx()与static xxx = value两种声明方式并存。源码中几乎所有配置读取入口都做了isFunction兼容处理,例如getTableName、getIdColumn、getRelationMappings(见 lib/model/Model.js#L869-L877),因此你可以根据自己的代码风格自由选择。
三、idColumn:主键列与复合主键
每个模型都必须有一个标识列(identifier column),用于唯一标识一行。标识列名通过静态属性 idColumn 指定,默认值为"id",这一点在基类定义中写得很明确:
// lib/model/Model.js#L719 Model.idColumn = 'id';只有当表的主键不叫id时才需要显式声明:
class Person extends Model { static get idColumn() { return 'some_column_name'; } }复合主键是一等公民
idColumn可以传入列名数组来表示复合主键,Objection.js 把复合键当作一等公民处理:
class Movie extends Model { static get tableName() { return 'movies'; } // 复合主键:由 languageCode 和 id 两列共同标识一行 static get idColumn() { return ['languageCode', 'id']; } }源码中,getIdRelationProperty()会把声明的标识列统一加工为“表名限定”的列引用,并通过RelationProperty管理:
// lib/model/Model.js#L839-L846 function getIdRelationProperty(modelClass) { const idColumn = asArray(modelClass.getIdColumn()); return new RelationProperty( idColumn.map((idCol) => `${modelClass.getTableName()}.${idCol}`), () => modelClass, ); }getIdColumnArray()/getIdPropertyArray()分别返回列名与属性名数组,getIdProperty()则在单列时返回字符串、多列时返回数组(lib/model/Model.js#L440-L456)。复合主键相关的实战细节($compositeKey查询、关系中的复合键)可进一步阅读 复合键配方。
注意:
idColumn也可以返回null,用于告诉 Objection.js 该模型没有主键——这在“联接表(join table)”模型中可能有用(见 static-properties.md 中idColumn一节)。
四、jsonSchema:输入校验,而非数据库 Schema
模型可以(可选地)定义 jsonSchema 对象用于输入校验。官方文档特别强调了两点:
- 这不是数据库 Schema!Objection.js不会根据它生成任何表或列;
- 每当一个模型实例被创建(无论是显式
new还是隐式创建),它都会被拿来与jsonSchema比对校验。所谓隐式创建,就是调用 insert、insertGraph、patch 等接收模型属性的方法时,属性会被转换成模型实例再校验。从数据库读取数据时不做校验。
例如:
class Person extends Model { static get jsonSchema() { return { type: 'object', required: ['firstName', 'lastName'], properties: { id: { type: 'integer' }, parentId: { type: ['integer', 'null'] }, firstName: { type: 'string', minLength: 1, maxLength: 255 }, lastName: { type: 'string', minLength: 1, maxLength: 255 }, age: { type: 'number' }, // 声明为 object/array 的属性在写入数据库时会被自动 // 转成 JSON 字符串,读取时再转回对象/数组。 // 想改变这一行为,可以覆写 Model.jsonAttributes。 address: { type: 'object', properties: { street: { type: 'string' }, city: { type: 'string' }, zipCode: { type: 'string' }, }, }, }, }; } }校验的源码级实现
Objection.js 的校验不是简单散落在各个方法里,而是收敛到统一的校验流程中(见 lib/model/modelValidate.js):
function validate(model, json, options = {}) { json = json || model; const inputJson = json; const validatingModelInstance = inputJson && inputJson.$isObjectionModel; if (options.skipValidation) { return json; } // ... 对模型实例做浅拷贝后再校验,避免污染原对象 const modelClass = model.constructor; const validator = modelClass.getValidator(); const args = { options, model, json, ctx: Object.create(null) }; validator.beforeValidate(args); json = validator.validate(args); validator.afterValidate(args); // ... }默认校验器是 AjvValidator,它基于ajv构建:
- 使用
allErrors: true收集全部错误,而不是“报第一个错就停”; - 默认
useDefaults: true,即 JSON Schema 里的default值会在校验时被写入数据; - 额外维护了一个不设默认值的 Ajv 实例(
ajvNoDefaults),专门用于校验patch对象——patch 允许只提交部分字段,所以required约束会被剔除(见compilePatchValidator/jsonSchemaWithoutRequired); - 编译后的校验函数会按
modelClass.uniqueTag()或序列化的 schema 做缓存,避免重复编译。
校验失败时抛出的异常是ValidationError(type: ModelValidation,data为按属性路径聚合的错误哈希),错误路径中的/会被转换为.,例如firstName上的错误会落在data.firstName上。仓库示例 koa/app.js 展示了典型处理方式:
if (err instanceof ValidationError) { ctx.status = 400; ctx.body = { error: 'ValidationError', errors: err.data, }; }如果你想替换默认的 Ajv 实现(例如接入自己的校验库),可以覆写Model.createValidator();更完整的定制方案见 自定义校验配方,钩子($beforeValidate、$afterValidate)的使用见 Hooks 指南。
jsonAttributes:object/array 属性的自动 JSON 序列化
文档示例中address被声明为object,这触发了 Objection.js 的自动 JSON 属性机制。源码 lib/model/modelJsonAttributes.js 中的getJsonAttributes()会扫描jsonSchema.properties,凡是type包含object或array(含anyOf/oneOf组合)的属性都会被加入 JSON 属性列表:
if (types.indexOf('object') !== -1 || types.indexOf('array') !== -1) { jsonAttributes.push(propName); }写入数据库时formatJsonAttributes把这些属性JSON.stringify成字符串;读取时parseJsonAttributes再把字符串JSON.parse回对象/数组(解析失败则保留原值,见 lib/model/modelJsonAttributes.js#L15-L23)。结合 PostgreSQL 的json/jsonb列类型,这是“用一行数据库记录表示一份文档”的利器。
如果想手动指定(而不是依赖自动探测),可以覆写jsonAttributes:
class Person extends Model { static get jsonAttributes() { return ['someProp', 'someOtherProp']; } }五、完整示例:一个带方法、校验与关系的模型
官方文档给出了一个“麻雀虽小五脏俱全”的Person模型——它也是 koa 示例 的真实模型。注意其中三个核心设计点:
- 自定义方法:类方法(如
fullName())可以直接定义在模型上;如果希望方法的返回值出现在输出 JSON 中,需要配合virtualAttributes; - jsonSchema 校验:见上一节;
- relationMappings 关系:在 getter 内部
require关联模型,这是避免require循环的常用手法。
const { Model } = require('objection'); class Person extends Model { // Table name is the only required property. static get tableName() { return 'persons'; } // idColumn 默认返回 'id',主键不是 id 时才需要显式声明。 static get idColumn() { return 'id'; } // 自定义方法。想让它出现在输出 JSON 中,见 virtualAttributes。 fullName() { return this.firstName + ' ' + this.lastName; } // 可选的 JSON Schema。这不是数据库 Schema! // 不会据此生成任何表或列,仅用于输入校验。 static get jsonSchema() { return { type: 'object', required: ['firstName', 'lastName'], properties: { id: { type: 'integer' }, parentId: { type: ['integer', 'null'] }, firstName: { type: 'string', minLength: 1, maxLength: 255 }, lastName: { type: 'string', minLength: 1, maxLength: 255 }, age: { type: 'number' }, // object/array 属性自动做 JSON 字符串往返转换 address: { type: 'object', properties: { street: { type: 'string' }, city: { type: 'string' }, zipCode: { type: 'string' }, }, }, }, }; } // 与其他模型的关系定义。 static get relationMappings() { // 在 getter 内部 require 模型,是避免 require 循环的方式之一。 const Animal = require('./Animal'); const Movie = require('./Movie'); return { pets: { relation: Model.HasManyRelation, // 关联模型可以是:Model 子类构造器,或导出该类的绝对文件路径。 modelClass: Animal, join: { from: 'persons.id', to: 'animals.ownerId', }, }, movies: { relation: Model.ManyToManyRelation, modelClass: Movie, join: { from: 'persons.id', // ManyToMany 关系需要通过 `through` 描述联接表。 through: { // 如果联接表有模型类,可以这样声明: // modelClass: PersonMovie, from: 'persons_movies.personId', to: 'persons_movies.movieId', }, to: 'movies.id', }, }, children: { relation: Model.HasManyRelation, modelClass: Person, join: { from: 'persons.id', to: 'persons.parentId', }, }, parent: { relation: Model.BelongsToOneRelation, modelClass: Person, join: { from: 'persons.parentId', to: 'persons.id', }, }, }; } }仓库里的 koa/models/Person.js 还在同一模型上增加了modifiers(可复用的查询片段searchByName),演示了“模型 = 表 + 校验 + 关系 + 查询封装”的完整形态。
六、relationMappings 与关系类型的深入说明
relationMappings是一个对象(或返回对象的函数/getter),键是关系名,值是一个 relation mapping。join对象中的from/to定义了两表关联所经由的列,这些列不要求是主键,可以是任意列,甚至可以是 JSON 列内部的字段(配合 ref 辅助函数,如ref('animals.json:details.ownerId').castInt())。对于ManyToManyRelation,还需要用through对象声明联接表(from/to指向联接表两侧的外键,也可通过extra声明要随关系读写到联接表的额外列,见 static-properties.md)。
modelClass支持三种取值(见 static-properties.md 与 relations 指南):
- 模型类构造器(如上面的
Animal); - 导出模型类的绝对文件路径;
- 相对 modelPaths 数组中某个目录的路径。
其中后两种“路径”写法很适合规避require循环。Objection.js 内置五种关系类型,全部挂在Model静态侧(见 lib/model/Model.js#L703-L707):
| 静态类型 | 语义 | 典型场景 |
|---|---|---|
Model.BelongsToOneRelation | 属于一个 | persons.parentId -> persons.id |
Model.HasOneRelation | 拥有一个 | 一对一 |
Model.HasManyRelation | 拥有多个 | persons.id -> animals.ownerId |
Model.ManyToManyRelation | 多对多(需through联接表) | persons <-> movies |
Model.HasOneThroughRelation | 经联接表拥有一个 | 如“最爱的电影” |
源码层面,关系映射由getRelationMappings()解析(支持 getter/函数写法,见 lib/model/Model.js#L869-L877),并通过getRelationUnsafe()把 mapping 实例化为具体的 Relation 对象缓存(lib/model/Model.js#L603-L620)。关系一旦定义好,就可以用withGraphFetched/withGraphJoined做嵌套加载,用$relatedQuery做关联查询,详细 API 见 eager-methods 与 relations 指南。
七、没有全局配置:Model.knex、多数据库与 BaseModel 模式
Objection.js 的一个标志性设计是:
所有配置都通过
Model类完成,不存在全局配置或全局状态,也没有所谓的 “objection 实例”。
这意味着你可以创建彼此隔离的组件,比如在同一应用里同时使用多个不同配置的数据库。最常见的做法是让所有模型共享同一份配置——官方推荐模式是创建一个BaseModel,让所有模型继承它:
// models/BaseModel.js const { Model } = require('objection'); class BaseModel extends Model { // 共享配置集中在这里,例如 modelPaths、jsonSchema、columnNameMappers 等。 static get modelPaths() { return [__dirname]; } } module.exports = { BaseModel }; // models/Person.js const { BaseModel } = require('./BaseModel'); class Person extends BaseModel { static get tableName() { return 'persons'; } }(该模式同样出现在 static-properties.md 的modelPaths一节。)
数据库连接的绑定同样“无全局”:在启动代码里把 knex 实例绑定到模型类上即可。仓库 koa 示例 正是这样做的:
const Knex = require('knex'); const knexConfig = require('./knexfile'); const { Model } = require('objection'); // 初始化 knex。 const knex = Knex(knexConfig.development); // 把所有模型绑定到同一个 knex 实例。 // 如果服务器只有一个数据库,做到这一步就够了。 // 多数据库系统请看 Model.bindKnex() 方法。 Model.knex(knex);源码中Model.knex(...)设置/读取内部$$knex(lib/model/Model.js#L515-L521),Model.bindKnex(knex)则返回一个绑定到指定 knex 的模型子类(lib/model/Model.js#L563-L565),供多数据库场景使用。
八、数据库 Schema 交给 Migrations
官方文档明确了一个工程哲学:除了idColumn,不要在模型里定义属性、索引或任何数据库 Schema 相关内容。数据库 Schema 在 Objection.js 中被视为独立关注点,应通过 knex migrations 管理。理由也很务实:任何非平凡项目最终都需要 migrations,若同时在“模型”和“migrations”两处维护 Schema,长期来看只会让事情更复杂。
仓库示例中的表结构正是通过 migration 文件建立的(见 koa migrations),模型文件里只关心业务映射,两者职责清晰分离。安装与初始化步骤(npm install objection knex及对应数据库驱动)见 安装指南。
九、更多可配置的静态属性速查
除了上述核心属性,static-properties.md 还列出了一系列实用配置(均可在BaseModel中统一设置,默认值均可在 lib/model/Model.js#L717-L737 中找到):
virtualAttributes:默认null。把 getter/方法的结果序列化进toJSON()输出(不写入数据库),例如fullName();toJSON({ virtuals: ['fullName'] })可以只输出子集。modifiers:默认{}。可复用的查询片段,配合modify()、withGraphFetched('[movies(goodMovies)]')、modifyGraph()使用,详见 modifiers 配方。modelPaths:默认[]。供关系按相对路径解析模型类,配合modelClass: 'Animal'这类写法使用。concurrency:默认4(mssql 为1)。单个连接上最多并发执行的查询数;knex 连接池默认大小为 10,因此整体最大并发 ≈concurrency * 池大小(见 lib/model/Model.js#L381-L402 与 static-properties.md 说明)。cloneObjectAttributes:默认true。序列化时是否克隆对象属性(如 jsonb 列);大 JSON 字段导致性能瓶颈时可设为false。columnNameMappers:默认null。列名 ↔ 属性名转换器,内置snakeCaseMappers()(见 snake_case 转换配方)。uidProp/uidRefProp/dbRefProp/propRefRegex:图插入(insertGraph)中用于临时标识、引用、指向已有行的内部属性名,默认分别为'#id'、'#ref'、'#dbRef'与对应正则,且不能用模型自身的属性名覆盖。pickJsonSchemaProperties:默认false。设为true时,插入/更新只挑选jsonSchema.properties中声明的属性写入数据库。defaultGraphOptions:默认{ minimize: false, separator: ':', aliases: {}, maxBatchSize: 10000 },作为withGraphFetched/withGraphJoined的默认选项。useLimitInFirst:默认false。为兼容旧行为,first()默认不加limit(1);有需要可设为true。QueryBuilder:自定义查询构建器子类,覆盖所有由query()/$query()/$relatedQuery()创建的查询,见 自定义查询构建器配方。
十、小结
把本篇要点串起来:一个 Objection.js 模型 =表名(必填)+ 主键列(默认id)+ 输入校验(可选jsonSchema)+ 关系映射(可选relationMappings)+ 自定义方法与查询封装(可选);校验只发生在写入路径上,Schema 管理交给 migrations;所有配置都挂在Model类上、无全局状态,因此多数据库隔离与BaseModel共享配置都顺理成章。在此基础上,你可以进一步探索 模型静态方法(query、relatedQuery、bindKnex、transaction)、实例方法($query、$relatedQuery、$toJson、$clone)、校验指南 与 关系指南,把模型层真正用活。
- 数据库
- 后端
【免费下载链接】objection.js
An SQL-friendly ORM for Node.js
相关推荐
Objection.js 关系映射:五种关系类型的完整指南
Objection.js 关系映射:五种关系类型的完整指南 本文详细介绍了 Objection.js 中的五种核心关系类型:BelongsToOneRelati
数据库后端sandspiel 元素大全:10种基础物质的相互作用原理
sandspiel 元素大全:10种基础物质的相互作用原理 sandspiel 是一款创意细胞自动机浏览器游戏,玩家可以通过组合不同元素创造出丰富的物理效果和动
Cosmos-Predict2.5终极指南:如何用世界模拟模型预测未来视频状态
Cosmos Predict2.5终极指南:如何用世界模拟模型预测未来视频状态 Cosmos Predict2.5作为Cosmos世界基础模型(WFMs)家族的
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考