Webiny Headless CMS 的 CmsSortMapper 排序映射机制:从 `title_ASC` 到 `values_title_ASC`

原创2026-10-08 15:44:101,743 阅读
文章标签:CMS后端前端云原生AI 应用

Webiny Headless CMS 的 CmsSortMapper 排序映射机制:从 title_ASC 到 values_title_ASC

本指南面向在 Webiny Headless CMS 中开发私有模型(private model)与自定义查询能力的开发者,系统讲解 CmsSortMapper(Sort Mapper)如何将 GraphQL 层的 sort 指令转换为 CMS 存储层可执行的排序参数。读完本文,你将掌握 sort 映射的触发时机、values_ 前缀产生的存储原理、映射器的接口契约与正则匹配规则,并能通过源码与测试用例定位、验证映射行为。

一、为什么要有一个 Sort Mapper?

在 Webiny Headless CMS 中,内容条目(entry)的用户自定义字段值并不会平铺存储在数据库记录根部,而是统一存放在一个嵌套的 values 对象里。这一点在官方 DEVELOPER 文档中有明确说明:

"we store all user defined values in a nested values object in the database, so all sorting must be applied to that object."

这意味着:GraphQL 查询层与数据库存储层对"排序字段"的表达方式天然不一致。当客户端(或内部方法)针对私有模型传入一个形如 title_ASC 的排序指令时,数据库中的 title 实际上位于 values.title,因此排序指令必须先被转换为 values_title_ASC,才能真正作用到正确的存储字段上。

CmsSortMapper 正是负责完成这一转换的组件。它隶属于 packages/api-headless-cms 包,源码位于 packages/api-headless-cms/src/features/sortMapper 目录,是一个通过 Webiny 的 createAbstraction / createFeature 机制注册的容器抽象(abstraction)。

一个直观的转换示例

根据 DEVELOPER.md 的说明,假设私有模型拥有字段 title,调用方传入:

{
    sort: ["title_ASC"]
}

在将 sort 变量传入 CMS 的 list / get 等方法的之前,必须让 sort 经过 CmsSortMapper.map 方法,转换后得到:

{
    sort: ["values_title_ASC"]
}

转换后的 sort 才能被 CMS 方法正常使用。这一"先映射、后查询"的约定,与同一目录下的 CmsWhereMapper(见 packages/api-headless-cms/src/features/whereMapper/DEVELOPER.md)对 where 的处理思路完全对称——后者会把 title_contains: "My Title" 包裹为 values: { title_contains: "My Title" }。排序与过滤共用同一套存储假设,这也是 Webiny Headless CMS 数据模型设计的核心约束之一。

二、Sort Mapper 的接口契约

CmsSortMapper 的抽象定义位于 packages/api-headless-cms/src/features/sortMapper/abstractions.ts:

export interface ICmsSortMapperParams {
    fields: Pick<CmsModelField, "fieldId">[];
    input: CmsEntryListSort | undefined;
}

export interface ICmsSortMapper {
    map(params: ICmsSortMapperParams): CmsEntryListSort | undefined;
}

/** Map CMS sort parameters to storage queries. */
export const CmsSortMapper = createAbstraction<ICmsSortMapper>("CmsSortMapper");

接口要点:

  • fields:当前模型的字段列表,只需 fieldId。映射器据此判断某个 sort 指令是否指向"用户自定义字段"(需要加 values_ 前缀),还是系统字段(原样保留)。
  • input:待转换的排序指令数组,类型为 CmsEntryListSort。
  • 返回值:转换后的排序数组;当 input 为 undefined 时返回 undefined。

CmsEntryListSort 在 packages/api-headless-cms/src/types/types.ts 中定义为模板字面量类型:

export type CmsEntryListSortAsc = `${string}_ASC`;
export type CmsEntryListSortDesc = `${string}_DESC`;
export type CmsEntryListSort = (CmsEntryListSortAsc | CmsEntryListSortDesc)[];

即:每个排序指令必须形如 <something>_ASC 或 <something>_DESC。

该抽象同时通过包的公共出口对外暴露,供其他模块依赖注入使用,相关导出见 packages/api-headless-cms/src/index.ts 与 packages/api-headless-cms/src/exports/api/cms/entry.ts。

三、映射器的实现原理

默认实现位于 packages/api-headless-cms/src/features/sortMapper/SortMapper.ts,核心逻辑分为三步。

1. 构造字段索引

map 首先把模型字段的 fieldId 收集成一个数组:

const fields = modelFields.map(field => {
    return field.fieldId;
});

const isField = (key: string): boolean => {
    return fields.includes(key);
};

isField 的作用是判断某个裸字段名是否为当前模型的用户自定义字段。源码注释特别强调了一个设计细节:

"mapSort has already stripped the direction, so what arrives here is a bare fieldId and is compared whole. Taking the segment before the first _ would fail every fieldId that contains one: on_sale would be looked up as on."

也就是说,isField 是全量比较,而不是按第一个 _ 切分前缀。这是为了支持字段 ID 本身包含下划线的情况(如 on_sale)——如果按 _ 切分,on_sale 会被错误地当作 on 去查表。

2. 逐条解析并改写排序指令

mapSort 对每个排序指令执行正则匹配:

const match = sort.match(/^(values_)?([a-zA-Z][a-zA-Z0-9_]*)_(ASC|DESC)$/);
if (!match) {
    return null;
}

const [, hasValues, field, direction] = match;

if (hasValues) {
    return sort;                       // 已带 values_ 前缀:原样返回,防止二次加前缀
}

if (isField(field)) {
    return `values_${field}_${direction}`;  // 模型字段:改写为 values_ 前缀
}

return sort;                           // 系统字段或未知字段:原样返回

正则 ^(values_)?([a-zA-Z][a-zA-Z0-9_]*)_(ASC|DESC)$ 的含义:

分组 匹配内容 说明
(values_)? 可选的 values_ 前缀 用于识别"已被映射过"的指令,防止重复映射
([a-zA-Z][a-zA-Z0-9_]*) 字段名 以字母开头,可包含字母、数字、下划线——因此支持 on_sale 这类含下划线的字段 ID
(ASC|DESC) 排序方向 只接受大写的 ASC / DESC

三个分支的行为总结:

  • 已带 values_ 前缀:说明此前已被映射,直接原样返回,避免 values_values_... 双重前缀;
  • 是模型字段:改写为 values_${field}_${direction};
  • 系统字段或非模型字段:原样透传,不添加前缀。

注意一个与 CmsWhereMapper 的差异:where 映射把模型字段包裹进 values: { ... } 嵌套对象,而 sort 映射是把 values_ 直接拼进字段名。这是因为排序指令是扁平的字符串数组,没有嵌套结构可供包裹,只能通过命名前缀表达"该字段位于 values 对象内"。

3. 过滤非法指令

无法匹配正则的指令(如缺少方向、方向拼写错误、字段名以非字母开头等)会被映射为 null,最终通过 filter 从结果中剔除:

.filter((item): item is CmsEntryListSort[0] => !!item);

这意味着非法排序指令会被静默丢弃。源码注释对此有明确警告:被丢弃的指令意味着查询实际以无序状态执行,而调用方却可能误以为排序生效。这也是 QueryEntriesTool 中会额外做长度校验的原因(见下文第五节)。

4. 空输入的处理

map 的开头对空输入做了短路处理:

if (!input) {
    return undefined;
}
  • input 为 undefined 时返回 undefined;
  • input 为 [] 空数组时,走正常映射流程并返回空数组(测试用例对此有断言)。

四、映射结果与 GraphQL 枚举的呼应

values_ 前缀并不是 Sort Mapper 单方面的"发明",它与 GraphQL 模式生成侧的排序枚举完全对应。在 packages/api-headless-cms/src/utils/renderSortEnum.ts 中,为模型字段生成的可排序枚举值同样带有 values_ 前缀:

for (const field of fields) {
    const plugin = fieldRegistry.get(getBaseFieldType(field));
    if (!plugin?.isSortable) {
        continue;
    }
    results.push(`values_${field.fieldId}_ASC`);
    results.push(`values_${field.fieldId}_DESC`);
}

同时,系统字段(id 以及 ENTRY_META_FIELDS 中的时间类元字段)则使用不带前缀的形式(如 id_ASC)。也就是说:

  • GraphQL 侧的 Sort 枚举:模型字段以 values_<fieldId>_ASC|DESC 形式暴露;
  • Sort Mapper 侧:当客户端(或内部方法)传入不带前缀的 <fieldId>_ASC 时,映射器负责把它"补全"成与枚举一致的形式。

两条路径殊途同归,共同保证了"查询参数"与"存储字段"在 values 命名空间下的对齐。

五、真实调用场景:AI 工具 QueryEntriesTool

Sort Mapper 的实际调用方之一是 AI 功能模块中的查询工具 packages/api-headless-cms/src/features/ai/QueryEntriesTool.ts。该工具通过构造函数注入 CmsSortMapper.Interface:

private sortMapper: CmsSortMapper.Interface

在执行查询时,AI 模型返回的扁平排序指令会先经映射器转换:

const requested = input.sort as CmsEntryListSort;
const sort = this.sortMapper.map({ fields: model.fields, input: requested });

随后,工具通过长度比对来侦测映射失败——因为映射器对无法解析的指令会静默丢弃,而"静默"在这里是不能接受的(查询会以无序状态执行,模型却把结果当作有序返回):

if (!sort || sort.length !== input.sort.length) {
    throw new Error(
        `Could not read every sort directive in [${input.sort.join(", ")}]. Each one must be \`<fieldId>_ASC\` or \`<fieldId>_DESC\`, using a field ID from describeContentModel.`
    );
}

这段代码为所有 CmsSortMapper 的调用方提供了一个重要实践参考:如果你需要保证"每个排序指令都生效",请在调用后比较输入/输出长度,而不是信任返回值本身。

六、功能注册与依赖注入

CmsSortMapper 遵循 Webiny 的 feature/abstraction 模式进行注册。在 packages/api-headless-cms/src/features/sortMapper/feature.ts 中:

export const CmsSortMapperFeature = createFeature({
    name: "CmsSortMapper",
    register(container) {
        container.register(SortMapper).inSingletonScope();
    }
});
  • SortMapper 通过 CmsSortMapper.createImplementation({ implementation: SortImpl, dependencies: [] }) 创建为抽象的具体实现(无额外依赖);
  • 该 Feature 在 packages/api-headless-cms/src/HeadlessCmsFeature.ts 中被纳入 Headless CMS 的主 Feature 注册流程。

因此在 api-headless-cms 的运行时容器中,任何模块只要在依赖声明中列出 CmsSortMapper.Interface,即可获得默认映射实现(参见 QueryEntriesTool 的 dependencies: [GetModelUseCase, ListLatestEntriesUseCase, CmsWhereMapper, CmsSortMapper])。

七、行为矩阵与测试验证

CmsSortMapper 的边界行为在 packages/api-headless-cms/tests/features/mapper/SortMapper.test.ts 中有完整覆盖。汇总如下:

输入 模型字段 输出 说明
undefined 任意 undefined 短路返回
[] 任意 [] 空数组透传
["id_ASC", "createdBy_DESC", "savedOn_ASC"] 含 title 原样返回 系统字段不映射
["title_ASC", "title_DESC"] 含 title ["values_title_ASC", "values_title_DESC"] 模型字段加前缀
["values_title_ASC"] 含 title ["values_title_ASC"] 不重复映射
["title_ASC", "createdBy_DESC"] 含 title ["values_title_ASC", "createdBy_DESC"] 混合场景
["invalidSort", "no_direction", "_INVALID_ASC", "title_INVALID"] 任意 [] 非法指令被剔除
["nonExistentField_ASC"] 不含该字段 ["nonExistentField_ASC"] 未知字段原样透传
["Title_ASC"] 含 title(小写) ["Title_ASC"] 大小写敏感,不匹配则不加前缀
["field123_ASC"] 含 field123 ["values_field123_ASC"] 支持字母数字组合
["on_sale_DESC"] 含 on_sale ["values_on_sale_DESC"] 支持字段 ID 内含下划线

其中,字段 ID 内含下划线的用例(on_sale)尤其值得注意。源码注释记录了这一历史教训:早期正则在字段名部分排除了 _,导致 on_sale_DESC 完全无法匹配,而"无法匹配的指令会被丢弃"——结果是查询在调用方毫不知情的情况下以无序状态执行。当前正则 /^(values_)?([a-zA-Z][a-zA-Z0-9_]*)_(ASC|DESC)$/ 允许字段名中出现下划线,正是为了修复这一隐患。

八、开发者自查清单

在接入 CmsSortMapper 或排查排序问题时,建议按以下清单核对:

  1. 映射时机:传入 CMS list/get 等方法的 sort 变量,是否在入口处先经过 CmsSortMapper.map?漏掉映射会让模型字段排序落到不存在的存储字段上。
  2. 区分字段来源:系统字段(id、createdOn、createdBy、savedOn 等)不需要映射;只有出现在模型 fields 中的 fieldId 才会被加前缀。
  3. 字段 ID 命名:字段 ID 允许包含下划线(如 on_sale),映射器已支持;但字段名必须以字母开头,且方向必须是大写 ASC / DESC。
  4. 防止双重前缀:values_ 前缀本身是幂等的——已带前缀的指令会原样通过,无需担心二次映射。
  5. 静默丢弃风险:非法指令会被过滤掉。如果业务上必须保证排序严格生效,请参照 QueryEntriesTool 的做法,比较映射前后数组长度并在不一致时显式报错。

九、小结

CmsSortMapper 是 Webiny Headless CMS"私有模型 + 嵌套 values 存储"设计下的一层关键适配器:它把 GraphQL 层扁平的 <fieldId>_ASC|DESC 排序指令,改写为存储层可用的 values_<fieldId>_ASC|DESC。本文从接口契约、正则实现、GraphQL 枚举呼应、AI 工具调用实践与测试行为矩阵五个维度,完整还原了该组件的设计与约束。若你正在为 Webiny 开发自定义查询能力或私有模型扩展,建议通读 SortMapper.ts 源码与 SortMapper.test.ts 测试,两者是理解其行为边界的最佳入口。

登录后查看全文
webiny-js