Directus SchemaBuilder 完全指南:用代码构造数据库 SchemaOverview 的链式构建器
【免费下载链接】directusThe flexible backend for all your projects 🐰 Turn your DB into a headless CMS, admin panels, or apps with a custom UI, instant APIs, auth & more.项目地址: https://gitcode.com/GitHub_Trending/di/directus
@directus/schema-builder 是 Directus 工作区中一个面向内部的 TypeScript 工具包,它允许开发者用链式调用(Fluent API)"凭空"在内存中构造一份完整的 Directus 数据库 Schema 描述(即SchemaOverview),用于单元测试中模拟数据表结构与各类关系(o2m / m2o / m2m / m2a / translations)。读完本文你将掌握 SchemaBuilder 的完整 API、所有字段类型与关系方法的默认语义、它产出的数据结构形态,以及在 Directus 仓库测试代码中的真实用法。
它是什么:定位、输入与输出
根据包自身声明(见 packages/schema-builder/readme.md),@directus/schema-builder的定位是:
Directus SchemaBuilder for mocking/constructing a database schema based on code, intended for internal use only.
也就是说它只面向 Directus 内部(单元/集成测试)使用,不面向最终用户的数据建模。它做的事情是把"代码声明的集合与字段"翻译成一份内存对象,这份对象的结构由 packages/types/src/schema.ts 中定义的SchemaOverview决定:
type SchemaOverview = { collections: { [name: string]: CollectionOverview; }; relations: Relation[]; };其中每个CollectionOverview又包含collection、primary、singleton、sortField、note、accountability与fields;每个FieldOverview包含field、defaultValue、nullable、type、dbType、special、alias、searchable等元信息。Builder 的.build()最终正是产出这样一个对象——它是内存中的"Schema 快照",并不会真的去数据库建表。
包的公共导出很简单,见 packages/schema-builder/src/index.ts,共四个构建器类:
SchemaBuilder—— 顶层入口,聚合所有集合与关系;CollectionBuilder—— 描述一个集合(表);FieldBuilder—— 描述集合中的单个字段,并提供字段类型快捷方法与关系声明;RelationBuilder—— 描述集合间关系(o2m / m2o / a2o)。
从目录结构看(见 packages/schema-builder/src),builder.ts、collection.ts、field.ts、relation.ts、defaults.ts五个核心文件组成了实现,builder.test.ts用 Vitest 内联快照逐一验证了各关系形态产出的 Schema。
快速上手:从文档中的三个最小示例说起
原文档给出了三段最核心的用法,先逐一还原,这是理解后续所有 API 的基础。
示例 1:只有标量字段的集合
const schema = new SchemaBuilder() .collection('articles', (c) => { c.field('id').id(); c.field('title').string(); c.field('content').text(); c.field('published').dateTime(); }) .build();示例 2:一对多(o2m)关系
const schema = new SchemaBuilder() .collection('countries', (c) => { c.field('id').id(); c.field('cities').o2m('cities', 'country_id'); }) .collection('cities', (c) => { c.field('id').id(); }) .build();cities字段是一个o2m 别名字段:它本身不占真实的数据库列,代表"cities表中country_id指回countries"这个反向关系。
示例 3:多对多(m2m)关系
const schema = new SchemaBuilder() .collection('articles', (c) => { c.field('id').id(); c.field('tags').m2m('tags'); }) .build();只需要一行.m2m('tags'),Builder 就会自动补全中间表articles_tags_junction以及对应的两条关系(见下方"多对多"一节),你甚至无需显式声明tags集合。
把三者统一起来看:.collection(name, callback)是构建入口,callback 接收一个CollectionBuilder;集合里用.field(name)拿到字段构建器后再追加类型或关系方法。这段 API 形态对应 builder.ts 中的collection()方法与 field.ts 中实现的各种字段方法。
SchemaBuilder:顶层入口的行为细节
SchemaBuilder 内部维护_collections与_relations两个数组,_relation_counter负责给关系自动分配自增 id。重点方法如下。
.collection(name, callback)
- 如果同名集合尚不存在:新建
CollectionBuilder,执行回调,然后入列。 - 如果同名集合已经存在:直接对已有的构建器再次执行回调——因此你可以跨多个
.collection()调用为同一个集合追加字段,这从源码中的existing_index !== -1分支可以确认。 - 回调内写
c.field(...)即可增删配置字段。
.options(collectionOptions)
用来为"刚刚创建的那个集合"补充集合级元信息,可选字段是singleton、accountability、note。源码中带有两条防御性断言:
assert(this._collections.length > 0, "You need at least 1 collection to configure it's options"); assert(this._last_collection_configured === false, 'You can only configure a collection once');也就是说:必须先.collection(...)再.options(...),且每个集合只能配置一次。Builder 用内部标志位_last_collection_configured追踪"最后一次回调是否已被 options 消费"。
.build()
遍历所有集合构建器,逐个生成CollectionOverview并写入schema.collections(若重名会抛Collection X already exists),随后再遍历关系构建器,把生成的Relation压入schema.relations。最终返回完整的SchemaOverview。
字段类型速查:每种类型方法的默认值
所有类型方法的内部实现都遵循同一模式:校验字段当前处于"尚未定型(initial)"状态后,用 defaults.ts 中预置的类型默认对象覆盖数据。下表按源码逐项整理:
| 方法 | type | dbType | special | 备注 |
|---|---|---|---|---|
.id() | integer | integer | [] | defaultValue: 'AUTO_INCREMENT'、nullable: false,并自动把该字段标为主键 |
.integer() | integer | integer | [] | 常规整型 |
.bigInteger() | bigInteger | bigint | [] | |
.float() | float | real | [] | |
.decimal() | decimal | numeric | [] | |
.string() | string | character varying | [] | |
.text() | text | text | [] | |
.boolean() | boolean | boolean | ['cast-boolean'] | |
.json() | json | json | [] | |
.csv() | csv | text | ['cast-csv'] | |
.date() | date | date | [] | |
.dateTime() | dateTime | timestamp without time zone | [] | |
.timestamp() | timestamp | timestamp with time zone | [] | |
.time() | time | time without time zone | [] | |
.uuid() | uuid | uuid | ['uuid'] | |
.hash() | hash | character varying | ['hash'] |
注意 defaults.ts 源码注释明确写着:
Note that
dbTypevaries across databases. This is based on Postgres.
即所有dbType字符串都以 PostgreSQL 为基准(例如 dateTime 对应timestamp without time zone),如果需要模拟其他数据库,需结合自身测试环境调整,或者用.options()覆盖。
此外,所有普通字段共用一份FIELD_DEFAULTS(见 defaults.ts):
{ defaultValue: null, nullable: true, generated: false, precision: null, scale: null, special: [], note: null, validation: null, alias: false, searchable: true, }即默认"可空、非生成列、无特殊类型、可被搜索";而.id()额外把nullable置为false、把默认值置为'AUTO_INCREMENT',并连带触发主键声明。
FieldBuilder 的辅助方法与状态机
.primary():把当前字段标记为集合主键,写入集合数据的primary;若集合已有主键会抛出The primary key is already set on the collection ...。源码还要求字段必须挂载在集合上,否则断言失败。通常用.id()一步完成,不必手动调.primary()。.sort():把当前字段标记为集合的排序字段(写入集合的sortField),且每个集合只允许一个排序字段。.options({ ... }):修改字段的其它元数据(如defaultValue、note、validation等),但必须在调用过类型方法之后,否则会抛Cannot configure field before specifing a type。.overwrite():把字段重置回"只有名字、尚无类型"的初始状态,之后再重新指定类型。
字段内部是一个initial/finished二态数据模型(见 field.ts 中InitialFieldOverview/FinishedFieldOverview),几乎所有类型方法与.options()都围绕这个状态做断言,这保证了"先定类型、后补选项"的使用顺序。
集合级配置与排序字段
集合的默认元信息来自 defaults.ts 的COLLECTION_DEFAULTS:
{ singleton: false, sortField: null, note: null, accountability: 'all', }accountability的合法值在 packages/types/src/schema.ts 中被定义为'all' | 'activity' | null。若需要构造 singleton 集合或限制审计范围,可以这样写:
const schema = new SchemaBuilder() .collection('settings', (c) => { c.field('id').id(); c.field('site_name').string(); }) .options({ singleton: true, note: '站点配置' }) .build();一个同时用到排序字段的完整示例(参考 items.test.ts 中的实际写法):
const schema = new SchemaBuilder() .collection('test', (c) => { c.field('id').id(); c.field('status').string(); c.field('sort').integer().sort(); c.field('name').string(); }) .build();这里.integer().sort()表示一个整型字段同时被登记为集合排序字段。
关系构建:从 o2m 到多态关系的完整形态
关系是 SchemaBuilder 最有价值的部分。四种关系方法(.o2m()、.m2o()、.m2m()、.m2a()、.a2o()、.translations())都会自动把声明字段变成type: 'alias'、dbType: null的虚拟字段(m2o 与 a2o 例外,见下文),并把对应的RelationBuilder实例注册到 SchemaBuilder 的_relations列表,等待.build()时统一产出。核心逻辑均在 field.ts 与 relation.ts。
一对多 o2m(One-to-Many)
已在开头的文档示例中出现。关键点:
- 声明方的
cities字段会被建成type: 'alias'、special: ['o2m']、dbType: null; - 生成的 Relation 记录从"many 侧"描述关系:
collection: 'cities'、field: 'country_id'、related_collection: 'countries',同时meta.one_field: 'cities'指向声明方字段。
以文档示例调用.build()后,产物(与 builder.test.ts 中Create o2m relation测试的快照一致)包含:
- 两个集合
countries、cities(都带自增id主键); cities上自动生成了外键字段country_id(integer);- 一条关系:
meta.one_deselect_action: 'nullify'、meta.sort_field: null、schema.on_delete: 'SET NULL'、schema.on_update: 'NO ACTION'、constraint_name: 'countries_cities_foreign'。
如果你只声明了父集合而没有为子集合调用.collection('cities', ...),RelationBuilder 的build()也会在 schema 上兜底生成相关集合(各补一个id主键)与外键字段,见 relation.ts。自动生成外键字段时,它会读取主表主键类型,只有integer或string主键才能自动补列,其它类型会抛断言错误——这是从key_type === 'integer' || key_type === 'string'分支可以确认的约束。
多对一 m2o(Many-to-One)
写法是把外键字段放在"many"侧:
const schema = new SchemaBuilder() .collection('cities', (c) => { c.field('id').id(); c.field('country').m2o('countries'); }) .build();与 o2m 不同,.m2o(related_collection)声明的字段是真实存储列:type: 'integer'、dbType: 'integer'、special: ['m2o'](见 field.ts 的m2o()实现)。产出的关系meta.one_field默认是null(可在 options 中补充反向字段名),constraint_name形如cities_country_foreign,countries集合若未声明也会被自动补全。如果关联目标的主键不是自增整数(例如 uuid),可以在构造关系中通过 options 手工覆盖字段类型,因为自动补全只针对integer/string主键。
多对多 m2m(Many-to-Many)
对应文档第三个示例。源码(field.ts 的m2m())显示它会自动完成三件事:
- 把
tags字段建成special: ['m2m']的别名字段; - 生成中间集合,命名为
{当前集合}_{关联集合}_junction,即articles_tags_junction; - 生成两条关系:
articles侧 o2m:junction 的articles_id→articles,meta.junction_field: 'tags_id';- junction 的
tags_id→tags的 m2o。
从 builder.test.ts 的Create m2m relation快照可看到最终 schema 里自动出现了articles、articles_tags_junction、tags三个集合,junction 含id、articles_id、tags_id三个字段。m2m 因此是一种高度声明式的关系——开发者只需指明"谁跟谁多对多"。
多对一多态 a2o 与别名 m2a(Many-to-Any)
先看 a2o(Many-to-Any 的存储侧)。当某个集合需要"引用多种集合中的任意一条记录"时使用:
const schema = new SchemaBuilder() .collection('blog', (c) => { c.field('id').id(); c.field('blocks').a2o(['text', 'image']); }) .build();a2o 是一个真实整数列(type: 'integer'、special: []),同时 builder 会为集合补一个记录来源类型字段collection(string)。从快照可见生成的关系related_collection: null、schema: null,而meta.one_collection_field: 'collection'、meta.one_allowed_collections: ['text', 'image']——这正是 Directus 多态关系在元数据层的标准表达;text、image两个目标集合也会被自动创建。
m2a(Many-to-Any alias)则是把多态关系以别名形式暴露在父集合上,相当于"a2o + o2m 的组合封装"。调用方式为:
const schema = new SchemaBuilder() .collection('blog', (c) => { c.field('id').id(); c.field('blocks').m2a(['text', 'image']); }) .build();源码中 m2a 会生成中间集合{当前集合}_builder(此处为blog_builder),并注册一"o2m"一"a2o"两条关系,同时写入meta.junction_field。整体语义与 Directus 内容区的"block 编辑器"建模一致。
翻译关系 translations
.translations()是 m2m 的语义化封装,专门表达"可翻译内容 + 语言"结构。默认写法:
const schema = new SchemaBuilder() .collection('blog', (c) => { c.field('id').id(); c.field('translations').translations(); }) .build();从 field.ts 的实现看,它会:
- 生成字段
translations(special: ['translations']别名字段); - 若默认语言集合
languages尚未声明,则自动注册一个带code(string 主键)、name(string)、direction(string,默认'ltr')三个字段的languages集合; - 生成中间集合
{当前集合}_translations(例如blog_translations); - 注册两条关系,junction_field 分别指向父集合主键与
languages_code。
translations(language_collection)的第一个参数允许替换默认的languages语言集合名。
关系的元数据与底层默认值
每条关系最终都是 relation.ts 中RelationBuilder的产物,其默认值来自 defaults.ts:
- meta 层:
sort_field: null、one_deselect_action: 'nullify'; - schema 层:
foreign_key_schema: 'public'、on_update: 'NO ACTION'、on_delete: 'SET NULL'; - 每条关系的
meta.id由 SchemaBuilder 的自增计数器(next_relation_index())分配,保证同一 schema 内关系 id 不重复。
若需覆盖,可在关系声明的末尾通过 options 链修改,例如设置反向排序字段、变更外键约束名等:
const schema = new SchemaBuilder() .collection('countries', (c) => { c.field('id').id(); c.field('cities').o2m('cities', 'country_id', (relation) => relation.options({ meta: { sort_field: 'sort' } }), ); }) .collection('cities', (c) => { c.field('id').id(); c.field('country_id').integer(); c.field('sort').integer(); }) .build();m2m / m2a / translations 的关系回调签名略有不同,例如 m2m 的回调收到{ o2m_relation, m2o_relation }两个关系对象,o2m/m2o 的回调则直接收到单个RelationBuilder(这些签名差异在 field.ts 中有明确类型定义),返回新的关系对象即可替换默认生成结果。
它在 Directus 仓库中如何被使用
虽然这个包对外定位是"internal use only",但在 Directus 仓库内部它已被大量使用——几乎每个需要 schema 的 service / permissions / run-ast 单元测试都通过它快速构造 schema,省去了手写巨型 JSON fixture。以 items.test.ts 为例,测试顶部直接用 Builder 声明被测集合:
const schema = new SchemaBuilder() .collection('test', (c) => { c.field('id').id(); c.field('status').string(); c.field('sort').integer().sort(); c.field('name').string(); }) .collection('directus_versions', (c) => { c.field('id').id(); c.field('item').string(); c.field('collection').string(); c.field('key').string(); }) .build();随后把生成的schema注入new ItemsService('test', { knex: db, schema }),配合 Knex mock 驱动被测试服务。全仓库范围搜索@directus/schema-builder的引用,可以覆盖 permissions 模块的 process-ast、validate-access、fetch-allowed-field-map,database 的 run-ast / get-ast-from-query 与 add-join、filter、sort 等应用层,以及 services 层的 items、fields、collections、users、roles、translations、versions、payload、graphql 等测试(例如 api/src/permissions/modules/process-ast/process-ast.test.ts、api/src/database/run-ast/utils/get-column.test.ts、api/src/services/graphql/utils/aggregate-query.test.ts),由此可见它与 Directus 测试体系深度绑定。
单元测试方面,packages/schema-builder/src/builder.test.ts 用 Vitest 的toMatchInlineSnapshot覆盖了 primitive、o2m、m2o、m2m、m2a、a2o、translations 等全部形态,本文所有关于"自动生成哪些集合/字段"的描述都能在对应快照中逐字段核对。
构建与测试命令
作为 pnpm workspace 成员包,@directus/schema-builder的脚本定义在 packages/schema-builder/package.json:
pnpm --filter @directus/schema-builder build—— 使用 tsdown 将src/index.ts构建到dist,并生成.d.ts类型声明;pnpm --filter @directus/schema-builder dev—— 监听模式构建,适合开发期反复验证;pnpm --filter @directus/schema-builder test—— 运行 Vitest(即执行builder.test.ts中的全部用例);pnpm --filter @directus/schema-builder test:coverage—— 带覆盖率报告运行测试。
在仓库根目录执行pnpm install之后即可使用上述命令。它只依赖@directus/types与lodash-es(用于merge深合并关系选项),依赖面非常小。
使用要点与边界
根据本文对源码的梳理,使用时有几点值得注意:
- 它只产出内存 Schema 描述:
.build()得到的是SchemaOverview结构(collections+relations),用于喂给需要 schema 参数的 Directus Service/工具函数或做断言,不会触发任何真实的数据库 DDL。 - 字段必须先定类型、后配 options:
.options()依赖字段已处于finished状态,错误顺序会直接抛断言错误;需要重配时先用.overwrite()把字段重置回未定型状态。 - 每个集合只能配置一次 options、只能有一个主键、最多一个排序字段,这些不变量由源码中多个
assert保证。 - dbType 以 PostgreSQL 为基准(见 defaults.ts 注释),模拟其它数据库方言的测试需要自行覆盖。
- 引用关系集合会自动补全:o2m/m2o 会按主键类型(仅
integer/string)自动生成外键列,m2m / m2a / translations 会按固定命名规则自动生成 junction 集合,声明前无需手工补齐所有目标集合——这是它相比手写 fixture 最大的生产力优势。 - 关系元数据默认值偏 "宽松":例如
one_deselect_action: 'nullify'、外键on_delete: 'SET NULL'、on_update: 'NO ACTION',在需要精确复刻生产 schema 语义时,请用 relation options 显式覆盖,而不是依赖默认值。
综上,SchemaBuilder 提供了一条"用可读代码声明测试用 Schema"的路径:标量字段用一行一个方法,关系用一行一个语义化声明,剩余的外键列、junction 集合、关系元数据全部交给构建器补齐。对任何需要为 Directus 服务层编写测试的开发者而言,它都是理解 Directus 如何描述"集合 + 关系"这套 SchemaOverview 模型的直观入口。
【免费下载链接】directusThe flexible backend for all your projects 🐰 Turn your DB into a headless CMS, admin panels, or apps with a custom UI, instant APIs, auth & more.项目地址: https://gitcode.com/GitHub_Trending/di/directus
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考