Directus SchemaBuilder 完全指南:用代码构造数据库 SchemaOverview 的链式构建器
2026/9/9 21:11:41 网站建设 项目流程

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又包含collectionprimarysingletonsortFieldnoteaccountabilityfields;每个FieldOverview包含fielddefaultValuenullabletypedbTypespecialaliassearchable等元信息。Builder 的.build()最终正是产出这样一个对象——它是内存中的"Schema 快照",并不会真的去数据库建表。

包的公共导出很简单,见 packages/schema-builder/src/index.ts,共四个构建器类:

  • SchemaBuilder—— 顶层入口,聚合所有集合与关系;
  • CollectionBuilder—— 描述一个集合(表);
  • FieldBuilder—— 描述集合中的单个字段,并提供字段类型快捷方法与关系声明;
  • RelationBuilder—— 描述集合间关系(o2m / m2o / a2o)。

从目录结构看(见 packages/schema-builder/src),builder.tscollection.tsfield.tsrelation.tsdefaults.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)

用来为"刚刚创建的那个集合"补充集合级元信息,可选字段是singletonaccountabilitynote。源码中带有两条防御性断言:

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 中预置的类型默认对象覆盖数据。下表按源码逐项整理:

方法typedbTypespecial备注
.id()integerinteger[]defaultValue: 'AUTO_INCREMENT'nullable: false,并自动把该字段标为主键
.integer()integerinteger[]常规整型
.bigInteger()bigIntegerbigint[]
.float()floatreal[]
.decimal()decimalnumeric[]
.string()stringcharacter varying[]
.text()texttext[]
.boolean()booleanboolean['cast-boolean']
.json()jsonjson[]
.csv()csvtext['cast-csv']
.date()datedate[]
.dateTime()dateTimetimestamp without time zone[]
.timestamp()timestamptimestamp with time zone[]
.time()timetime without time zone[]
.uuid()uuiduuid['uuid']
.hash()hashcharacter varying['hash']

注意 defaults.ts 源码注释明确写着:

Note thatdbTypevaries 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({ ... }):修改字段的其它元数据(如defaultValuenotevalidation等),但必须在调用过类型方法之后,否则会抛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测试的快照一致)包含:

  • 两个集合countriescities(都带自增id主键);
  • cities自动生成了外键字段country_id(integer);
  • 一条关系:meta.one_deselect_action: 'nullify'meta.sort_field: nullschema.on_delete: 'SET NULL'schema.on_update: 'NO ACTION'constraint_name: 'countries_cities_foreign'

如果你只声明了父集合而没有为子集合调用.collection('cities', ...),RelationBuilder 的build()也会在 schema 上兜底生成相关集合(各补一个id主键)与外键字段,见 relation.ts。自动生成外键字段时,它会读取主表主键类型,只有integerstring主键才能自动补列,其它类型会抛断言错误——这是从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_foreigncountries集合若未声明也会被自动补全。如果关联目标的主键不是自增整数(例如 uuid),可以在构造关系中通过 options 手工覆盖字段类型,因为自动补全只针对integer/string主键。

多对多 m2m(Many-to-Many)

对应文档第三个示例。源码(field.ts 的m2m())显示它会自动完成三件事:

  1. tags字段建成special: ['m2m']的别名字段;
  2. 生成中间集合,命名为{当前集合}_{关联集合}_junction,即articles_tags_junction
  3. 生成两条关系:
    • articles侧 o2m:junction 的articles_idarticlesmeta.junction_field: 'tags_id'
    • junction 的tags_idtags的 m2o。

从 builder.test.ts 的Create m2m relation快照可看到最终 schema 里自动出现了articlesarticles_tags_junctiontags三个集合,junction 含idarticles_idtags_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: nullschema: null,而meta.one_collection_field: 'collection'meta.one_allowed_collections: ['text', 'image']——这正是 Directus 多态关系在元数据层的标准表达;textimage两个目标集合也会被自动创建。

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 的实现看,它会:

  • 生成字段translationsspecial: ['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: nullone_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/typeslodash-es(用于merge深合并关系选项),依赖面非常小。

使用要点与边界

根据本文对源码的梳理,使用时有几点值得注意:

  1. 它只产出内存 Schema 描述.build()得到的是SchemaOverview结构(collections+relations),用于喂给需要 schema 参数的 Directus Service/工具函数或做断言,不会触发任何真实的数据库 DDL。
  2. 字段必须先定类型、后配 options.options()依赖字段已处于finished状态,错误顺序会直接抛断言错误;需要重配时先用.overwrite()把字段重置回未定型状态。
  3. 每个集合只能配置一次 options、只能有一个主键、最多一个排序字段,这些不变量由源码中多个assert保证。
  4. dbType 以 PostgreSQL 为基准(见 defaults.ts 注释),模拟其它数据库方言的测试需要自行覆盖。
  5. 引用关系集合会自动补全:o2m/m2o 会按主键类型(仅integer/string)自动生成外键列,m2m / m2a / translations 会按固定命名规则自动生成 junction 集合,声明前无需手工补齐所有目标集合——这是它相比手写 fixture 最大的生产力优势。
  6. 关系元数据默认值偏 "宽松":例如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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询