Webiny Headless CMS SQL 存储:CMS 字段类型到 SQL 列的完整映射指南
Webiny Headless CMS SQL 存储:CMS 字段类型到 SQL 列的完整映射指南
本文是 Webiny Headless CMS SQL 存储方案(@webiny/api-headless-cms-sql)的核心设计文档解读,完整讲解 CMS 字段类型如何映射为真实 SQL 列——包括标量字段、嵌套对象、动态区(Dynamic Zone)、引用字段与列表字段的存储格式、可搜索性与可排序性,以及 SQL 与 DDB+OpenSearch 组合方案在 list-of-objects 过滤能力上的"对等性差距"。读者读完可掌握字段映射规则、列命名规范(含哈希折叠)、元数据列清单与跨方言 JSONB 查询技巧,可直接用于内容模型设计时预估表结构、判断哪些字段能在 SQL 层被原生过滤与排序。
核心原则:每个 CMS 字段对应一个真实 SQL 列
在 SQL 存储设计中,绝大多数 CMS 字段都会被映射为表中的真实 SQL 列,而不是像 DynamoDB 那样整体存成一个 JSON 文档。这一选择带来的直接收益是:字段值可以享受 SQL 原生的过滤(WHERE)、排序(ORDER BY)与索引(CREATE INDEX)能力,查询可以直接使用原生操作符(=、>、<、LIKE、BETWEEN 等)。
例外只有两类:
- 列表字段(list fields) —— 数组无法映射为单列,以 JSON 字符串形式存储;
- 深度嵌套结构(deeply nested structures) —— 嵌套层级过深的子结构不再拆列,而是整体序列化为 JSON。
整个映射体系由 FieldTypeMapper(CMS 字段类型 → Knex 列类型)驱动,配合 EntrySchemaManager 在模型变更时执行 ALTER TABLE ADD COLUMN。列名直接取自字段的 storageId(格式为 type@fieldId),storageId 不可变——即使字段在后台 UI 中改名(fieldId 变化),列名也不会变,因此重命名字段不需要迁移列。该方案的整体设计动机与"列即真实类型"的取舍细节可参考 README.md 与 schema-management.md。
设计演进提示:仓库内的 SINGLE_TABLE_DESIGN.md 记录了一次"单表 JSON blob"重构方向(一列
data存整个 entry)。该文档明确对比了"列每字段"与"单 blob"两种方案的取舍,并以 types.ts(9 字段IEntryRow)和 mappers.ts(entryToRow/rowToEntry直接JSON.stringify/JSON.parse)给出了新设计的实现雏形。本文介绍的是原文档所描述的 column-per-field 设计,两者并存于仓库中,阅读时请注意区分。
标量字段映射
标量字段是映射中最简单、最"1:1"的一类:每个字段直接对应一个强类型 SQL 列,SQL 层可以做原生过滤与排序。
| CMS 字段类型 | SQL 列类型 | 存储格式 | 可搜索 | 可排序 |
|---|---|---|---|---|
text |
string (varchar) |
纯文本 | 是 | 是 |
long-text |
text |
纯文本(transform override 绕过压缩) | 是(仅 contains) |
否 |
number |
float |
数值 | 是 | 是 |
boolean |
boolean |
true/false(Knex 按方言映射) |
是 | 是 |
datetime(date) |
string |
ISO 8601 文本 | 是 | 是 |
datetime(time) |
float |
数值秒 | 是 | 是 |
datetime(dateTimeWithout/WithTimezone) |
string |
ISO 8601 文本 | 是 | 是 |
几个需要特别注意的点:
long-text存储为纯文本,但只能做contains搜索,且不可排序。设计上它与 DDB 的 gzip 压缩 transform 解耦(压缩是面向 DDB 400KB 条目上限的约束,SQL 没有条目大小限制),因此 SQL 模式下通过 transform override 绕过压缩,保证LIKE能直接作用于明文。- 日期以 ISO 8601 文本存储:因为 ISO 8601 的字典序与时间序一致,跨 PostgreSQL/MySQL/SQLite 三方言都可直接使用
>/</BETWEEN比较,无需依赖各方言的本地化日期类型(各方言原生日期类型对比见 dialect-differences.md 的 Date/Time 一节)。 time以数值秒存储(hours*3600 + minutes*60 + seconds):避免TIME类型在三方言中的不一致行为,同时获得原生的数值比较与排序。
复杂字段映射
| CMS 字段类型 | SQL 列类型 | 存储格式 | 可搜索 | 可排序 |
|---|---|---|---|---|
rich-text |
text |
JSON 字符串(transform override 绕过压缩) | 否 | 否 |
json |
text |
JSON 字符串(transform override 绕过压缩) | 否 | 否 |
searchable-json |
text |
JSON 字符串(默认不压缩) | 是 | 是 |
file |
text |
JSON 字符串({id, name, ...}) |
否 | 否 |
这类字段把 JSON 序列化后的数据存进 text 列。设计要点:
rich-text与json不可搜索,因此它们的 JSON 内部格式对查询无影响——查询时它们只是"整块文本";searchable-json是可搜索的,其 JSON 内容会被当作文本参与查询(相当于整体LIKE匹配);file字段保存完整的文件描述 JSON({id, name, ...}),同样不做内部字段拆解。
对象字段(嵌套)映射
拆解为子列:父对象没有自己的列
对象字段(object)会被拆解为子列:父对象本身不占列,只有叶子字段落列。
示例:模型字段 address(object),子字段 city(text)、zip(number)。
| 条目值 | SQL 列 |
|---|---|
{ address: { city: "NYC", zip: 10001 } } |
object@address__text@city = "NYC"、object@address__number@zip = 10001 |
列名规则为 object@<字段ID>__<类型>@<子字段ID>,继续嵌套则逐段拼接。
深度嵌套(2 层及以上):中间段哈希折叠
嵌套过深时,列名会无限变长、难以管理。因此对中间段做 8 字符 SHA-256 哈希折叠:
address.street.number →
object@address__{hash8("object@street")}__number@number
hash8 是确定性哈希——同一路径永远产生同一列名,因此列名可预测、可复现,无需在代码中保存映射关系。
List-of-Objects:整体存为不透明 JSON blob
list: true 的对象字段不拆解,而是整体存入单个 JSON 列:
[{city: "NYC", zip: 10001}, {city: "LA", zip: 90001}]
→ 单个列中的 JSON 字符串
这是一个根本性限制。 列表中的对象无法扁平化为列:单个条目可能包含 0、1 或 N 个列表元素——你无法预知需要多少组列。这是一对多关系,列模型表达不了。
嵌套会加剧这个问题。 考虑如下模型:
sections: object (list: true)
├── title: text
├── content: dynamicZone
│ ├── hero template:
│ │ └── heading: text
│ └── richBlock template:
│ └── items: object (list: true)
│ ├── label: text
│ └── value: number
└── order: number
整个结构——对象列表 → 动态区 → 模板 → 嵌套对象列表——被序列化进一个 JSON 列(object@sections):
[
{
"title": "Intro",
"content": { "_templateId": "hero", "heading": "Welcome" },
"order": 1
},
{
"title": "Details",
"content": {
"_templateId": "richBlock",
"items": [{"label": "A", "value": 1}, {"label": "B", "value": 2}]
},
"order": 2
}
]
blob 内部的任何内容都无法在 SQL 层被过滤或排序——sections.title、sections.content.heading、sections.content.items.label 全都不可查。
与 OpenSearch 的对等性差距(Parity Gap)
OpenSearch 可以过滤 list-of-objects 内部:它索引嵌套对象并支持嵌套查询,因此"找出任意 section 的 title 包含 'Intro' 的条目"这类查询在 DDB+OS 中可行,在纯 SQL 中却不行。要在 SQL 中实现类似能力,只有三种路径:
- JSONB 查询 —— 方言相关、深度受限、规模大时性能差;
- 独立 junction 表 —— 每个 list-of-objects 一张规范化关联表,能获得完整 SQL 查询能力,但会大幅增加 schema 复杂度与 join 开销;
- 物化搜索列(materialized search columns) —— 写入时把高频查询的值提取到真实列中。
结论:任何依赖"在 list-of-objects 内部过滤"的内容模型,在 SQL 模式下查询能力都会缩水。这是 SQL 与 DDB+OS 之间公开承认的能力差距,建模时必须提前规避或接受。
NULL 对象折叠(Null Object Collapse)
从 SQL 读回时,如果对象的所有子列均为 NULL,对象会折叠为 null:
object@address__text@city = NULL
object@address__number@zip = NULL
→ { address: null } (而不是 { address: { city: null, zip: null } })
也就是说"空对象"与"字段缺失"在读回后被统一为 null,调用方无需区分二者。
动态区(Dynamic Zone)字段
动态区包含多个模板,每个模板字段不同。所有模板的字段都会被扁平化进同一张表。
示例:动态区 content 含模板:
hero:title(text)、image(file)gallery:caption(text)、columns(number)
共生成 4 列:
dynamicZone@content__text@titledynamicZone@content__file@imagedynamicZone@content__text@captiondynamicZone@content__number@columns
使用 hero 模板的条目只会填充 title 和 image,caption、columns 为 NULL。这会产生稀疏行(sparse rows)——一个已知且被接受的权衡:NULL 很廉价,三方言都能承载大量 NULL 列(各方言排序行为差异见 dialect-differences.md 的 NULL Sort Order 一节)。
列数估算:N 个模板 × 每个模板平均 M 个字段 = N×M 列。3 个动态区、每个 5 个模板、每模板 10 个字段 = 150 列。设计文档明确:这没问题——所有方言都能处理数百列,NULL 存储成本可忽略。
引用字段(Reference Fields)
每个 ref 字段会生成两列:
| 列 | 类型 | 内容 | 用途 |
|---|---|---|---|
ref@author |
text |
完整 ref JSON:{"entryId": "abc#001", "id": "abc"} |
数据存储 |
ref@author__entryId |
text |
提取出的 entryId:"abc" |
过滤 |
配套的 __entryId 列让过滤无需解析 JSON,直接走原生 SQL:
WHERE "ref@author__entryId" = 'abc'
多值 ref 字段(list)时,两列都存 JSON 数组:
- 主列:
[{"entryId": "abc#001"}, {"entryId": "def#001"}] - 配套列:
["abc", "def"]
这种"存储列 + 提取列"的拆分,是 SQL 模式下对引用类过滤需求的标准答案——保留完整数据的同时,为最频繁的查询路径(按 entryId 过滤)提供廉价列。
列表字段(multipleValues: true)
任何 list: true 的字段类型,都把数组以 JSON 字符串形式存在单个列中:
| 字段类型 | 单值 | 列表值 |
|---|---|---|
text |
"hello" |
'["hello","world"]' |
number |
42 |
'[1, 2, 3]' |
ref |
'{"entryId":"abc"}' |
'[{"entryId":"abc"},{"entryId":"def"}]' |
查询列表字段
要查询列表内部的单个元素,必须使用方言相关的 JSONB 操作(Knex 不抽象 JSON 操作,需要带方言检测的 raw SQL):
| 方言 | 查询:"列表包含 'red'" |
|---|---|
| PostgreSQL | column::jsonb @> '"red"' |
| MySQL | JSON_CONTAINS(column, '"red"') |
| SQLite | EXISTS (SELECT 1 FROM json_each(column) WHERE value = 'red') |
性能差异也随方言不同:PostgreSQL 可建 GIN 索引、MySQL 8.0.17+ 可建多值索引(Multi-Valued Index),而 SQLite 没有 JSON 索引、只能全表扫描。好在典型 CMS 数组很小(标签、分类、多选,通常 2~20 个元素),对 PostgreSQL/MySQL 来说加好索引后不是问题,SQLite 才是短板。完整分析与索引类型对比见 dialect-differences.md。
元数据列(系统级)
每个条目表都带有固定元数据列,它们不来自 CMS 字段定义:
| 列 | 类型 | 用途 |
|---|---|---|
id |
string (PK) | 条目版本 ID(entryId#version) |
entryId |
string (indexed) | 条目 ID(所有版本共享) |
version |
integer | 版本号 |
status |
string | draft、published、unpublished |
locked |
boolean | 版本是否锁定 |
isLatest |
boolean (indexed) | 最新版本标记 |
isPublished |
boolean (indexed) | 已发布版本标记 |
tenant |
string (indexed) | 租户 ID(仅共享表模式) |
wbyDeleted |
boolean | 软删除(回收站)标记 |
live_version |
integer | 已发布版本的版本号(未发布为 null) |
location |
text | JSON location 对象 |
location_folderId |
string | 提取的文件夹 ID,用于快速过滤 |
expiresAt |
float | TTL 用的 Unix 时间戳 |
另有身份列(createdBy、modifiedBy 等),拆分为 _id 加完整 JSON 文本两列(即"ID 提取列 + 全量 JSON"模式,与 ref 字段的 __entryId 配套列同一思路)。关于 isLatest/isPublished 标记与"每版本一行"的版本生命周期(对比 DDB 的 L/P/REV 三记录模型),README.md 的 One Row per Revision 一节有完整表格示例;SQL 存储操作层中 9 字段 IEntryRow 的当前实现见 types.ts。
列类型参考(FieldTypeMapper)
FieldTypeMapper 的完整映射清单:
| CMS 类型 | Knex 列类型 |
|---|---|
text |
string |
long-text |
text |
rich-text |
text |
number |
float |
boolean |
boolean |
datetime |
string |
file |
text |
ref |
text |
ref__entryId |
text |
object |
text(仅用于 list-of-objects) |
dynamicZone |
text(仅用于 list-of-zones) |
json |
text |
searchable-json |
text |
规律总结:
- 标量类型映射到真正的强类型列(
string、float、boolean); - 所有"复杂/序列化"类型一律落
text列; object与dynamicZone只有在列表形态时才以text兜底存储——非列表的对象会被拆解为子列、非列表的动态区会被展开为各模板字段列,这一点与上文对象字段、动态区两节完全对应。
与上层存储操作层的衔接
字段映射不是孤立的——它服务于 22 个 CmsEntryStorageOperations 方法(get、list、create、publish、moveToBin 等)。在 column-per-field 设计中,写路径的 entryToRow 负责把 storage entry 扁平化为行(meta 字段 + 身份列拆分 + 对象/数组 JSON.stringify + ref 提取 __entryId),读路径的 rowToEntry 负责反向重建(嵌套对象重组、JSON 解析、身份字段解析、NULL 对象折叠)。
当前仓库的 SQL 存储实现位于 packages/api-headless-cms-sql/src/operations/entry,其中的 mappers.ts 展示的已是单表 JSON blob 时代的极简 entryToRow/rowToEntry,而 queryHelpers.ts 中的 syncEntryToLatest、mergeEntryLevelMeta(同步除 createdOn/createdBy 等不可变字段外的所有 *On/*By 条目级元数据)则体现了"跨版本元数据同步"这一与映射无关、但同样影响行结构的机制。由此可见,字段→列映射始终只是 SQL 存储层的一个切面,模型定义、表管理(EntryTableManager/TableNameResolver)、操作方法与行映射共同构成完整方案——总览可继续阅读 README.md 的 Architecture 与 DI Architecture 章节,以及 schema-management.md、query-performance.md、data-handling.md、transactions.md、pain-points.md 等兄弟文档。
关键结论速查
- 默认原则:标量与单值复杂字段都落真实 SQL 列,获得原生过滤/排序/索引能力;例外只有 list 字段与深度嵌套结构。
- 嵌套对象:只拆叶子字段,中间段超 2 层用 8 字符 SHA-256 确定性哈希折叠列名;list-of-objects 整体进 JSON 列,其内部不可被 SQL 过滤。
- 动态区:所有模板字段扁平化进同一张表,产生稀疏行但 NULL 廉价;列数 ≈ N 模板 × M 字段。
- ref 字段:双列存储(全量 JSON + 提取
__entryId),过滤走配套列,免 JSON 解析。 - list 字段:单列 JSON 数组,元素级查询依赖方言 JSONB 操作(PG
@>、MySQLJSON_CONTAINS、SQLitejson_each)。 - 元数据列:固定 13+ 列(含
id/entryId/version/isLatest/isPublished/tenant/live_version等)不随模型字段变化。 - 规划模型时:若业务需要"在对象列表/动态区内部过滤",要么接受 SQL 模式的能力降级,要么改用物化搜索列或 junction 表方案;否则优先把需要过滤的数据建模为标量/单值对象字段。