Webiny Headless CMS SQL 存储:CMS 字段类型到 SQL 列的完整映射指南

原创2026-10-07 13:16:181,629 阅读
文章标签:CMS后端前端云原生AI 应用

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 中实现类似能力,只有三种路径:

  1. JSONB 查询 —— 方言相关、深度受限、规模大时性能差;
  2. 独立 junction 表 —— 每个 list-of-objects 一张规范化关联表,能获得完整 SQL 查询能力,但会大幅增加 schema 复杂度与 join 开销;
  3. 物化搜索列(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@title
  • dynamicZone@content__file@image
  • dynamicZone@content__text@caption
  • dynamicZone@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 @>、MySQL JSON_CONTAINS、SQLite json_each)。
  • 元数据列:固定 13+ 列(含 id/entryId/version/isLatest/isPublished/tenant/live_version 等)不随模型字段变化。
  • 规划模型时:若业务需要"在对象列表/动态区内部过滤",要么接受 SQL 模式的能力降级,要么改用物化搜索列或 junction 表方案;否则优先把需要过滤的数据建模为标量/单值对象字段。
登录后查看全文
webiny-js