上一篇写到,表格每一列都要能筛选以后,固定 Form 参数会越来越吃力,所以需要一套结构化查询 DSL。
DSL 能把一次查询说清楚:
- 我要哪些列;
- 对哪些字段加条件;
- 每个条件用什么操作符;
- 怎么分页;
- 怎么排序。
但 DSL 本身还不够。
因为 DSL 里会出现一个很关键的东西:field。
比如:
{
"field": "inboundTime",
"op": "[)",
"value": ["2026-06-01", "2026-07-01"]
}
这里的 inboundTime 到底是什么?
它是数据库里的字段名吗?是前端表格列名吗?是 Java Form 里的参数名吗?它能不能筛选?能不能排序?为什么可以用范围操作符?
如果这些问题没有一个统一答案,DSL 也只是把原来散落在 Form 和 SQL 里的混乱,换成了 JSON 里的混乱。
这就是语义层开始变得必要的地方。
表格列不等于数据库字段
业务表格里的列,经常不是数据库字段的一一映射。
比如一个库存或运单表格里可能有这些列:
- 运单号;
- 入库时间;
- 在库时长;
- 当前库存数量;
- 开单网点;
- 入库操作人;
- 目的地。
“运单号”可能就是一个物理列。
“入库时间”也可能是物理列,但前端展示的是格式化后的时间,筛选时要走日期范围。
“在库时长”就不一定是物理列了。它可能是当前时间减去入库时间算出来的结果。
“开单网点”前端显示的是网点名称,底层可能存的是网点 ID,查询时要关联网点表,或者走一个维度 caption。
“入库操作人”显示的是人名,但真正筛选时,可能要按用户 ID、组织关系或人员维度查。
也就是说,用户看到的是一列,后端真正执行时可能是物理列、关联字段、显示名、计算表达式、枚举映射或指标。
如果每个页面都自己维护一份映射,问题很快会出现:
- 页面显示叫“入库时间”,后端参数叫
startDate/endDate; - 导出逻辑又用另一套字段名;
- SQL 模板里写的是
stock.inbound_time; - AI 或外部查询看到的又是
inboundTime; - 后续还可能要和权限、导出、审计等逻辑关联。
这些名字和含义一旦漂移,维护成本就会上来。
先对齐几个词
这里顺手解释几个后面会反复出现的词,不展开成数仓或 BI 教程。
属性,可以理解成一条记录上的普通字段,比如运单号、状态、入库时间、操作人。
维度,是用来观察、筛选或分组数据的业务方向,比如客户、仓库、网点、公司、日期。很多维度底层是外键,展示给用户时通常要变成名称,也就是 caption。
度量,是可以被统计的数值,比如数量、金额、时长、次数。它通常会和 sum、avg、count、max 这类聚合方式一起出现。
level 是维度里的层级,比如日期里的年、月、日,组织里的公司、部门、人员,地区里的省、市、区。
这些概念继续展开可以讲很多,但在这条主线里先够用就行:它们只是帮助系统判断,一个字段到底是普通展示字段,还是可以分组的维度,还是可以汇总的指标。
语义层先做一件简单的事
先不把语义层想得太复杂。在这个阶段,它要做的第一件事其实很简单:
给查询字段建一份受控目录。
这份目录至少要回答几个问题。
第一,这个字段叫什么。
这里不是只给数据库列起别名,而是要有一个稳定的查询字段名。前端、DSL、AI、审计日志都用它,不再每个地方各叫各的。
第二,这个字段展示给用户叫什么。
比如 inboundTime 展示为“入库时间”,stockQty 展示为“当前库存数量”。显示名可以改,但查询字段名要稳定。
第三,这个字段是什么类型。
字符串、数字、日期、枚举、人员、组织、布尔值,对应的输入方式和操作符都不一样。
日期字段可以做范围筛选。数字字段可以大于、小于、区间。字符串字段可以等于、包含、前缀匹配。枚举字段适合单选或多选。人员和组织字段可能还要接字典或树结构。
第四,这个字段允许做什么。
一个字段能显示,不代表能筛选。能筛选,不代表能排序。能排序,不代表适合分组。
比如“在库时长”可以显示,也可以做范围筛选,但如果它是实时计算出来的表达式,是否允许排序就要看执行成本和底层实现。
“操作人名称”可以显示和筛选,但分组时可能更应该按操作人 ID,而不是按名称。
这些能力不能只靠前端猜,也不能让每个后端接口自己写一套判断。
一个小例子
可以把字段定义想象成这样:
field: inboundTime
caption: 入库时间
type: datetime
filterable: true
sortable: true
operators: [range, isNull, isNotNull]
expr: stock.inbound_time
再看“在库时长”:
field: inventoryAge
caption: 在库时长
type: number
filterable: true
sortable: false
operators: [range, >, <]
expr: now() - stock.inbound_time
这个示例不是完整语法,只是说明语义层要保存什么信息。
它把几个原本分散的问题放在一起:
- 前端知道用什么控件;
- DSL 知道可以引用哪个 field;
- 后端知道怎么转成执行表达式;
- 审计日志知道用户到底筛了哪个业务字段;
- 后续的导出、审计、权限逻辑也有稳定的字段入口。
这比“前端传一个字段名,后端拼一段 SQL”稳得多。
语义层不是让查询更自由
这里容易有一个误解:做语义层,是不是为了让用户或 AI 想查什么就查什么?
不是。
至少在企业系统里,语义层更重要的作用是收窄边界。
它告诉系统:
- 哪些字段可以出现在查询里;
- 哪些字段只能展示,不能筛选;
- 哪些字段可以排序或分组;
- 哪些操作符对这个字段是合法的;
- 这个字段最终应该如何执行;
- 后续导出、审计、权限逻辑应该引用哪个字段入口。
这样 DSL 才有校验目标。
如果没有语义层,DSL 里的 field 就只是一个字符串。系统只能在执行时再去猜它对应什么,或者在 SQL 生成失败后才发现问题。
有了语义层,很多问题可以提前处理:
- 字段不存在,直接拒绝;
- 字段不可筛选,直接拒绝;
- 日期字段用了字符串包含操作,直接拒绝;
- 计算字段成本太高,限制排序或导出。
这一步看起来不华丽,但它决定了后面查询能不能稳定扩展。
从写 SQL 到设计查询字段
到这里,查询开发的重心已经开始变化。
早期我们关心的是 SQL 怎么写得不乱,所以做了模板、helper、权限条件注入。
后来客户要求每一列都能筛选,我们开始需要 DSL,让前端和后端用同一种结构表达查询条件。
再往后,仅有 DSL 还不够。我们需要明确每个字段的业务含义、类型、能力和执行方式。
也就是说,问题从“怎么拼查询”变成了“哪些东西可以被查询,以及应该怎么被查询”。
这就是语义层最早要设计的东西。
它不是一上来就做复杂 BI,也不是把所有数据库能力都包装起来。它先把表格列、DSL 字段和执行表达式收敛到同一份字段定义里。
等字段目录稳定以后,下一个问题就会浮出来:
这份字段目录怎么落到工程里?
也就是:哪些东西放在表模型里,哪些东西放在查询模型里。底层表结构、关联关系、维度 caption、计算字段、页面可见列,不应该继续混在一份 SQL 模板或一组接口参数里。下一篇就从这里继续。
相关代码:foggy-dataset Java 引擎模块
相关文档:QM 语法手册