上一篇讲到,DSL 里的 field 不能只是一个字符串。
如果表格列、接口参数、SQL 字段、导出字段和审计日志各叫各的,后面一定会乱。所以我们先做了一件很简单的事:给查询字段建一份受控目录。
字段名是什么,显示名是什么,类型是什么,能不能筛选,能不能排序,最终怎么执行,都应该有一个统一定义。
但做到这里以后,还会遇到另一个问题:
这份字段目录到底应该放在哪里?
最直接的做法,是把所有东西都写进一份模型里。底层表名、字段、JOIN、显示列、查询列、计算字段、指标、页面分组,全都塞进去。
短期看,这样最快。
长期看,它会变成另一种大 SQL 模板。
底层字段不等于业务查询入口
数据库表里的字段,首先是为了存储和系统运行服务的。
比如一个库存转移表里,可能有:
idnamestatescheduled_datelocation_idlocation_dest_idcompany_idwrite_date
这些字段都存在,也都可以被数据库查询。
但“能被数据库查询”,不等于“应该作为业务查询字段暴露出去”。
有些字段只是内部状态。
有些字段只是外键。
有些字段对开发者有意义,但对业务用户没有意义。
还有些字段需要变成 caption 才能看懂。比如 location_id 对用户来说不是一个数字 ID,而是“来源库位”;company_id 也不只是一个外键,而是“公司”这个组织维度。
如果直接把底层字段暴露给页面、AI 或外部工具,调用方就必须理解表结构、外键、JOIN 和一堆系统字段。这就又回到了“让调用方面对数据库”的老路。
TM 先描述数据从哪里来
所以我们先把底层来源拆出来。
在 Foggy 里,这一层叫 TM,也就是 Table Model。
TM 关心的是:
- 这个模型对应哪张表或哪个集合;
- 主键是什么;
- 哪些字段是普通属性;
- 哪些字段是度量;
- 哪些字段可以作为维度;
- 维度要怎么 JOIN;
- caption 从哪一列来;
- 日期、金额、状态这些字段是什么类型。
它更接近“底层数据结构的可查询描述”。
比如 Odoo 的库存转移模型里,TM 会知道 stock_picking 是主表,partner_id 可以关联联系人,picking_type_id 可以关联操作类型,location_id 和 location_dest_id 分别表示来源和目标库位。
如果用一个极小的示意片段表示,大概是这样:
export const model = {
name: 'StockTransferModel',
tableName: 'stock_transfer',
properties: [
{ column: 'name', caption: '运单号', type: 'STRING' },
{ column: 'inbound_time', name: 'inboundTime', caption: '入库时间', type: 'DATETIME' }
],
dimensions: [
{
name: 'station',
tableName: 'station',
foreignKey: 'station_id',
primaryKey: 'id',
captionColumn: 'name',
caption: '网点'
}
],
measures: [
{ column: 'stock_qty', name: 'stockQty', caption: '当前库存数量', type: 'INTEGER', aggregation: 'sum' }
]
};
这不是完整 TM 语法,只是为了说明 TM 里会先放清楚几类东西:普通属性、可关联的维度、可统计的度量,以及它们分别来自哪些底层表和字段。
这一步不是给用户做页面,也不是给 AI 做提示词。
它是先把数据库里那些原本散落在 SQL 里的表、列、外键、维度关系收起来。
QM 再决定业务上怎么查
但 TM 还不够。
因为一个底层表模型可能有很多种使用方式。
同一张订单表,可以用于:
- 销售订单列表;
- 销售额分析;
- 客户成交统计;
- 发货进度查询;
- 逾期订单看板。
这些场景底层可能都依赖同一批表,但它们要暴露的字段、默认分组、指标口径、说明文字不一定一样。
这就是 QM,也就是 Query Model 要做的事。
QM 关心的是:
- 这个查询入口叫什么;
- 对外展示哪些字段;
- 字段分到哪些 column group;
- 哪些维度 caption 要暴露;
- 哪些指标可以直接查询;
- 哪些计算字段可以作为业务字段出现;
- 这些字段组合起来适合回答哪类问题。
换句话说,TM 描述“底层有什么”,QM 描述“这个查询入口允许怎么用”。
一个简单的 QM 片段可能长这样:
const sp = loadTableModel('OdooStockPickingModel');
export const queryModel = {
name: 'OdooStockPickingQueryModel',
caption: 'Inventory Transfer Analysis',
model: sp,
columnGroups: [
{
caption: 'Transfer Information',
items: [
{ ref: sp.name },
{ ref: sp.state },
{ ref: sp.scheduledDate }
]
},
{
caption: 'Locations',
items: [
{ ref: sp.locationSrc },
{ ref: sp.locationDest }
]
}
]
};
这里没有把所有数据库字段都抛出来。
它只是从 TM 里挑出这个查询入口需要的字段,并把它们组织成业务上能理解的结构。
为什么不合在一起
TM 和 QM 分开,主要不是为了多一层抽象,而是为了避免几个问题。
第一,避免底层结构直接污染业务入口。
表里有字段,不代表页面要显示,不代表外部工具可以查,也不代表它适合进入 AI 的上下文。
第二,避免每个查询入口重复写 JOIN。
维度怎么关联、caption 从哪里来,应该在 TM 里稳定下来。QM 只引用它,不在每个页面里再写一遍。
第三,避免一个表只能服务一个场景。
同一个 TM 可以被多个 QM 复用。不同 QM 可以有不同字段组合和业务说明。
第四,给后续能力留入口。
计算字段、指标口径、字段说明、字典值、导出、审计,都需要稳定的字段来源。如果 TM/QM 混在一起,后面每加一个能力都会继续挤进同一份配置。
这一步解决的是工程边界
所以,从字段语义走到 TM/QM,不是为了把模型系统做复杂。
它解决的是一个很具体的工程边界:
底层可查询,不等于业务可暴露。
TM 把底层数据来源描述清楚。
QM 把业务查询入口描述清楚。
DSL 再基于 QM 去表达“这一次具体怎么查”。
这样三层分开以后,系统才不会让页面、AI、导出和审计各自猜字段,也不会让调用方绕回数据库表结构。
到这里,查询入口基本有了形状:
- SQL 模板解决了动态查询怎么生成;
- DSL 解决了一次查询怎么表达;
- 语义字段解决了 field 到底是什么;
- TM/QM 解决了字段目录怎么落到工程里。
下一步就可以进入更硬的一件事了:
当查询入口已经稳定以后,执行前还要不要注入当前用户、公司、团队、数据范围这些上下文?
这就是下一篇要讲的权限前置。
相关代码:Odoo Stock Picking TM / QM 示例
相关文档:TM / QM 建模