数据模型决定应用保存什么,以及数据库必须保证哪些规则。在 NocoBase 应用中,用迁移记录结构变化,用 Repository 访问数据;database/main/collections/ 是生成的模型快照,不是手写模型的入口。
本篇以“任务可以属于一个项目”为例,说明如何设计字段、关系与约束,并在已有数据上演进模型。可以直接查阅各部分约定;要运行示例,再按下方说明准备应用。
结构、初始数据与业务记录
| 要做的事 | 放在哪里 | 处理原则 |
|---|---|---|
| 增加字段、关系、索引或约束 | database/main/migrations/ |
新增迁移,明确对旧数据的影响 |
| 随版本补齐历史数据 | 迁移 | 明确转换规则,不猜测业务归属 |
| 写入应用运行必需的初始数据 | database/main/seeds/ |
与用户日常创建的记录区分,不反复覆盖用户修改 |
| 提供可选演示数据 | 标记为 sample 的 seed | 由使用者明确选择是否载入 |
| 创建或修改日常业务记录 | 业务服务和接口 | 校验输入与权限,再通过 Repository 读写 |
| 查看解析后的 Collection | database/main/collections/ |
由工具生成和校验,不手动编辑 |
项目名称是用户填写的业务数据,因此本例不添加项目种子。业务角色也不能为了让示例可用而自动分配给所有已登录用户。
设计字段和关系时先回答什么
先明确必填和唯一是否是业务规则、历史记录是否能满足新规则、关联对象删除时如何处理,再选择字段和约束。不要仅根据表单当前长什么样来决定数据库结构。
例如,“项目名称必填”不意味着名称必须唯一;“任务可以归入项目”也不意味着旧任务必须立即有项目。数据库外键保证引用有效,接口校验负责名称内容等输入规则,权限检查负责当前用户是否可以使用这个项目。这些检查不能互相替代。
准备示例应用
项目专题沿用独立的历史示例:tutorialTasks 保存任务,tutorialProjects 保存项目,任务使用 completed 表示完成状态。当前入门示例则使用 tasks 和三阶段 status,两者不是同一套代码。运行本专题时使用下方指定的旧标签和配套配置;不要直接把这些迁移应用到入门示例的数据库。
获取并配置本文的示例应用
本篇接着 app-tutorials 的 04-task-due-date 阶段开发,基于默认模板 1.0.0-beta.78。仓库为私有,需要访问权限。已经完成这一阶段的读者可以在原项目中继续;从头开始时,在一个新目录获取这一阶段:
git clone --branch 04-task-due-date https://github.com/nocobase/app-tutorials.git team-tasks
cd team-tasks
git switch -c feat/task-projects
pnpm install --frozen-lockfile
pnpm nocobase config init --dialect sqlite
pnpm nocobase config check使用 Node.js 24 和 pnpm 11。已有项目不必重新初始化配置,也不要把已有数据库配给更早版本的代码。以下代码和命令均在应用根目录使用;本篇验证的数据库为 SQLite。
示例:项目与任务的关系
本次只引入最小的项目模型:
tutorialProjects.id:项目 ID,字符串主键,与任务现有的 ID 类型保持一致。tutorialProjects.name:项目名称,必填,最长 120 个字符;允许不同项目使用相同名称。tutorialProjects.createdAt:创建时间,由创建项目的业务代码提供。tutorialTasks.projectId:可空的项目 ID。一个项目可以有多个任务,一条任务最多属于一个项目。
任务保存项目 ID,读取时再取得项目名称。这样项目改名不需要逐条更新任务,也不会因同名项目而混淆归属。
旧任务没有可靠的项目归属,因此新字段先允许为空,表示“尚未归入项目”。迁移不猜测归属,也不创建一个默认项目接收所有历史任务。对于已经被任务引用的项目,本例拒绝删除;业务上可以先转移或解除关联,再删除项目。
使用 Agent
在应用目录中把本篇作为参考交给 Agent,再描述下面的需求。字段、接口和组件的具体约定见手动步骤,由 Agent 结合项目指引处理。
请给现有任务应用增加“项目”,这一轮先只完成数据存储,不增加页面和接口。
每个项目需要有名称和创建时间,名称必填。
一个项目可以包含多个任务,每个任务最多属于一个项目,也可以不属于任何项目。
原有任务继续保留,暂时不关联项目。
任务不能关联到不存在的项目;还有任务归属的项目不能被删除。
后续读取任务时,需要能够同时获取它所属的项目信息。
请按项目已有的数据变更指引实现,不清空数据库,也不改写以前已经执行过的数据变更。
请用独立测试数据验证这些规则,以及旧任务是否仍能正常使用。
同时说明撤销这次变更会失去哪些数据,只在测试环境验证撤销和重新应用。
这一轮不添加示例项目,也不改变用户权限。
完成后告诉我改了什么、验证了什么。检查 Agent 的结果时,除了看新增表和字段,还要看已有数据是否保留、约束是否真正生效,以及测试是否使用了真实数据库。可以对照下面的实现。
不使用 Agent
编写自包含的迁移
新建 database/main/migrations/202610240004_add_task_projects.ts:
import { defineMigration } from '@nocobase/db';
export default defineMigration({
name: '202610240004_add_task_projects',
async up({ builder }) {
await builder.createCollection('tutorialProjects', (collection) => {
collection.string('id', {
length: 36,
primaryKey: true,
nullable: false,
});
collection.string('name', { length: 120, nullable: false });
collection.datetime('createdAt', { nullable: false });
});
await builder.alterCollection('tutorialTasks', (collection) => {
collection.string('projectId', { length: 36, nullable: true });
collection.foreignKey('projectId', {
name: 'fk_tutorial_tasks_project',
references: { collection: 'tutorialProjects', fields: ['id'] },
onDelete: 'restrict',
});
collection.index('projectId', { name: 'idx_tutorial_tasks_project' });
});
await builder.addField('tutorialTasks', {
name: 'project',
type: 'belongsTo',
target: 'tutorialProjects',
targetKey: 'id',
foreignKey: 'projectId',
constraints: false,
});
},
async down({ builder }) {
await builder.dropField('tutorialTasks', 'project');
await builder.dropConstraint('tutorialTasks', 'fk_tutorial_tasks_project');
await builder.dropIndex('tutorialTasks', 'idx_tutorial_tasks_project');
await builder.dropField('tutorialTasks', 'projectId');
await builder.dropCollection('tutorialProjects');
},
});文件名去掉 .ts 后与 name 一致,并排在已有任务迁移之后。迁移中的表、字段和约束直接写在文件里,不从不断演进的业务服务或模型文件导入。
up() 先创建项目表,再扩展任务表。down() 按依赖关系反向移除,保留任务原有字段和记录,但会删除项目数据及任务的项目归属。它是结构上的逆操作,不是恢复业务数据的备份。
区分字段、关联和约束
这份迁移为同一个业务关系定义了三个部分:
- **字段
projectId**:真正保存到任务表中的项目 ID。nullable: true使旧任务和未归入项目的新任务继续有效。 - **关联
project**:告诉 Repository 如何从任务读取项目,foreignKey指向上面已经存在的字段,targetKey指向项目的主键。它不会另外保存一份项目名称。 - 外键约束:由数据库保证非空的
projectId必须指向存在的项目,并通过onDelete: 'restrict'拒绝删除仍被引用的项目。
这里已经显式创建并命名了外键,所以关联声明使用 constraints: false,避免再次创建约束;这不等于数据库没有外键。显式命名也让 down() 能准确移除该约束。
projectId 上的普通索引用于后续按项目筛选任务,不设唯一索引,因为同一个项目可以拥有多条任务。索引服务于查询,外键保证引用有效,二者承担不同职责。
字段声明也不替代接口的输入校验。例如,nullable: false 不会拒绝空白名称,各数据库对字符串长度的处理也可能不同;接口仍需要检查去除首尾空格后的名称和长度。
应用迁移与生成 Collection
先检查当前应用配置指向的数据库,再运行:
pnpm nocobase db apply --dry-run
pnpm nocobase db apply在已经运行过上一阶段的数据库中,计划应包含这份新迁移;在新建数据库中,计划还会包含此前尚未执行的应用及插件迁移、种子。db apply 先执行待运行的迁移,再执行待运行的种子,不只处理当前新增文件。
再次运行相同命令,已经成功执行的迁移不会重复执行。已有数据库中的任务保留标题、完成状态、负责人和截止日期,新增的 projectId 为 null。
命令会刷新 database/main/collections/ 下的模型快照。可以查看:
database/main/collections/
├── tutorialProjects/
│ ├── collection.json
│ ├── metadata.json
│ └── schema.json
└── tutorialTasks/
├── collection.json
├── metadata.json
└── schema.json这些文件用于查看数据库实际解析出的模型,是可再生成的缓存。不要手动修改它们,也不要从迁移中导入它们。验证缓存与数据库是否一致:
pnpm nocobase collections generate --check用真实数据库验证
新增 tests/database/tutorial-tasks-projects.test.ts。下面的测试先应用较早的迁移并插入一条旧任务,再应用本次迁移,检查模型与数据,最后回滚并重新应用。describeMigration() 管理隔离数据库和清理,不操作你的开发数据库。
完整迁移测试
import { fileURLToPath } from 'node:url';
import { expect } from 'vitest';
import { describeMigration } from '@nocobase/app-testing/server';
import metadata from '../../package.json' with { type: 'json' };
describeMigration('202610240004_add_task_projects', {
sources: [
{
packageName: metadata.name,
directory: fileURLToPath(
new URL('../../database/main/migrations/', import.meta.url),
),
},
],
before: async ({ connection }) => {
await connection.repository('tutorialTasks').createOne({
values: {
id: 'existing-task',
title: 'Prepare the launch checklist',
completed: true,
assigneeId: 'existing-assignee',
dueDate: '2026-11-01',
updatedAt: new Date('2026-10-24T00:00:00Z'),
},
});
},
up: async ({ connection, expectCollection }) => {
await expectCollection('tutorialProjects').toHaveField('name', {
type: 'string',
nullable: false,
});
await expectCollection('tutorialTasks').toHaveField('projectId', {
type: 'string',
nullable: true,
});
await expectCollection('tutorialTasks').toHaveIndex(['projectId']);
await expectCollection('tutorialTasks').toHaveForeignKey(
['projectId'],
'tutorialProjects',
{ referencedFields: ['id'], onDelete: 'restrict' },
);
const tasks = connection.repository('tutorialTasks');
expect(
await tasks.findOne({ filter: { id: 'existing-task' } }),
).toMatchObject({
title: 'Prepare the launch checklist',
completed: true,
assigneeId: 'existing-assignee',
dueDate: '2026-11-01',
projectId: null,
});
const projects = connection.repository('tutorialProjects');
await projects.createOne({
values: {
id: 'launch-project',
name: 'Product launch',
createdAt: new Date('2026-10-24T00:00:00Z'),
},
});
await tasks.updateOne({
filter: { id: 'existing-task' },
values: { projectId: 'launch-project' },
});
expect(
await tasks.findOne({
filter: { id: 'existing-task' },
select: (s) =>
s
.fields('id', 'projectId')
.include('project', (p) => p.fields('id', 'name')),
}),
).toMatchObject({
project: { id: 'launch-project', name: 'Product launch' },
});
await expect(
tasks.updateOne({
filter: { id: 'existing-task' },
values: { projectId: 'missing-project' },
}),
).rejects.toThrow();
await expect(
projects.deleteOne({ filter: { id: 'launch-project' } }),
).rejects.toThrow();
expect(
await tasks.findOne({ filter: { id: 'existing-task' } }),
).toMatchObject({
projectId: 'launch-project',
});
},
down: async ({ connection, expectCollection }) => {
await expectCollection('tutorialProjects').not.toExist();
await expectCollection('tutorialTasks').not.toHaveField('projectId');
const collection = await connection.collections.get('tutorialTasks');
expect(collection?.fields.some((field) => field.name === 'project')).toBe(
false,
);
expect(
await connection
.repository('tutorialTasks')
.findOne({ filter: { id: 'existing-task' } }),
).toMatchObject({
title: 'Prepare the launch checklist',
completed: true,
assigneeId: 'existing-assignee',
dueDate: '2026-11-01',
});
},
});运行本章测试,再运行已有任务的数据库和接口测试:
pnpm exec vitest run --config vitest.tutorial.config.ts tests/database/tutorial-tasks-projects.test.ts
pnpm exec vitest run --config vitest.tutorial.config.ts本章测试应出现在结果中,不能把“没有发现测试”当作通过。测试会验证:
- 升级后旧任务的业务字段保留,
projectId为空。 - 索引和外键实际存在,任务能关联项目并通过 Repository 读取项目名称。
- 不存在的项目 ID 被拒绝,仍被任务引用的项目不能删除,失败后原关联保留。
- 回滚移除新增模型但保留原任务,随后可以重新应用迁移。
测试中的 repository() 直接用于隔离数据库验证。正式接口必须检查调用者权限,再使用带有相应策略的 Repository;外键只能证明项目存在,不能证明当前用户可以访问或选择该项目。
模型继续演进时
已经合并或发布的迁移保持不变。 新增字段、修改约束或修复历史迁移时,再添加一份迁移。修改已执行文件不会让 db apply 自动重跑它,也不能靠重置数据库验证真实升级是否保留了数据。
如果以后要求每条任务必须有项目,先确定旧任务的归属,完成数据补齐并验证没有空值,再用后续迁移改为非空。补齐现有数据属于迁移;不要用种子反复覆盖用户已修改的任务。
种子用于应用运行所必需的初始数据。本章的项目是用户创建的业务记录,因此不添加种子。若需要演示内容,应明确使用可选的 sample seed,让新安装是否载入示例数据成为显式选择。
继续阅读
业务服务与 HTTP 接口说明怎样把数据操作开放给页面:校验输入、检查项目与任务的权限,并把业务错误转换为一致的 API 错误。应用开发概览介绍这些部分如何连起来。
