字段有了语义以后,为什么还要区分 TM 和 QM

1 阅读1分钟

上一篇讲到,DSL 里的 field 不能只是一个字符串。

如果表格列、接口参数、SQL 字段、导出字段和审计日志各叫各的,后面一定会乱。所以我们先做了一件很简单的事:给查询字段建一份受控目录。

字段名是什么,显示名是什么,类型是什么,能不能筛选,能不能排序,最终怎么执行,都应该有一个统一定义。

但做到这里以后,还会遇到另一个问题:

这份字段目录到底应该放在哪里?

最直接的做法,是把所有东西都写进一份模型里。底层表名、字段、JOIN、显示列、查询列、计算字段、指标、页面分组,全都塞进去。

短期看,这样最快。

长期看,它会变成另一种大 SQL 模板。

底层字段不等于业务查询入口

数据库表里的字段,首先是为了存储和系统运行服务的。

比如一个库存转移表里,可能有:

  • id
  • name
  • state
  • scheduled_date
  • location_id
  • location_dest_id
  • company_id
  • write_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_idlocation_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 示例

github.com/foggy-proje…

相关文档:TM / QM 建模

foggy-projects.github.io/foggy-data-…