跳转至

指南:Datus 扩展

OSI 有两处行为没有定义,可以按模型分别控制:

  • 连接类型: 指标 JOIN 到某个维度时,没匹配上的行是保留还是丢弃。
  • 空值填充: 没有数据的分组,显示 0(或任意数字)而不是空白。

两者都写在模型的 custom_extensions 里,这是 OSI 的官方字段。 模型仍然是 100% 合法的 OSI:不认识 Datus 的工具直接忽略, Dosi 也只拿它们在 OSI 未作规定之处细化行为。 精确契约见 datus-extensions.md,这一页只讲怎么用。

所有 Datus 扩展共用同一种形态:一条 custom_extensions 条目, vendor_nameDATUSdata 是一小段 JSON 字符串。

custom_extensions:
  - vendor_name: DATUS
    data: '{"v": "1.1", "<option>": <value>}'

1. 选择 JOIN 如何处理未匹配的行

问题

假设在 line_items 上度量 revenue,想按 orders.status 拆开看。 有些 line item 指向的订单不在订单表里(迟到的行、软删除的订单、 数据质量缺口),这部分营收该怎么算?

Dosi 默认保留它们,也就是 LEFT JOIN:未匹配的营收落在 status 为空白(NULL)的那一行。对账场景下这是对的,每一块钱都有交代, 孤儿行也不例外。

归因场景要的是另一种口径 —— "按状态看营收"只该统计真正有状态的那部分, 孤儿行应当丢掉。

做法

join_type 把这个关系声明成 INNER

relationships:
  - name: line_items_to_orders
    from: line_items
    to: orders
    from_columns: [order_id]
    to_columns: [order_id]
    custom_extensions:
      - vendor_name: DATUS
        data: '{"v": "1.0", "join_type": "inner"}'

有什么变化

同一条查询,孤儿营收为 50

join_type status revenue
left(默认) paid 300
(空白) 50 ← 孤儿行被保留
inner paid 300
(孤儿行被丢弃)

该选哪个

选它 适用场景
left(默认,或省略) 对账/审计:任何一行都不该悄悄消失。
inner 归因:度量只统计被连接实体确实存在的那部分。

这是关系的属性,所以每一条走这个 JOIN 的查询口径都一致, 不用每条查询各记一遍。


2. 空分组显示数字而不是空白

问题

cityorder_countsignups。某个城市这个周期有注册、没有订单, 它的 order_count 返回空白(NULL)而不是 0。看板上这会被读成"没有数据", 可真实答案是"零笔订单"。

纯计数指标 Dosi 已经自动填 0 —— 什么都没有的计数就是零。 但 SUM(比如 revenue)默认仍是空白,因为"零行的求和"本来就没有定义: 是 0 还是未知?这一步交给你按指标决定。

做法

给指标加上 fill_nulls_with

metrics:
  - name: revenue
    expression:
      dialects:
        - dialect: ANSI_SQL
          expression: SUM(orders.amount)
    custom_extensions:
      - vendor_name: DATUS
        data: '{"v": "1.0", "fill_nulls_with": 0}'

有什么变化

cityrevenuesignups,其中 Denver 有注册但没有订单:

city revenue(默认) revenuefill_nulls_with: 0
Austin 900 900
Denver (空白) 0

fill_nulls_withSUM、比率、表达式各类指标都有效,填什么数字都行 (0 最常见)。要让某个计数填成 0 以外的值,它也会盖过自动的计数填充。

有一件事它不会做

它只填指标的最终值,不碰内部的任何一部分。 revenue / order_count 这样的比率是整体填充,分母里的 order_count 绝不会被悄悄改成 0 —— 那会除以零。最终拿到的是一个安全算出来的填充值。


3. 为每张表指明业务时间轴

问题

"上季度按月看营收和库存变动" —— 可哪一列才是"时间"? orders 既有 order_date 又有 ship_date,库存表则是 move_date。 没有声明,引擎就拒绝去猜:每次都得写全 --group-by orders.order_date:month, 而且永远没法在一条查询里对齐两张表各自的时间列。

做法

声明主时间维度,每个数据集声明一次,指标级可以覆盖:

datasets:
  - name: orders
    custom_extensions:
      - vendor_name: DATUS
        data: '{"v": "1.1", "time_dimension": "order_date"}'
    fields:
      - name: order_date
        dimension: { is_time: true }
      - name: ship_date
        dimension: { is_time: true }

metrics:
  - name: shipped_revenue          # same SUM, but on the shipping axis
    expression:
      dialects: [{ dialect: ANSI_SQL, expression: SUM(orders.amount) }]
    custom_extensions:
      - vendor_name: DATUS
        data: '{"v": "1.1", "time_dimension": "ship_date"}'

只有一个 is_time 字段的数据集不需要这个扩展,那个字段自动就是主时间。

有什么变化

保留查询名 metric_time 可以用了:

dosi query --metrics revenue,moves --group-by metric_time:month

每个指标各按自己那张表的主时间列截断,结果在共享的 metric_time__month 输出上对齐。没写 --time-dimension、group-by 里也没有时间列的时间范围, 会去过滤每个指标各自的主时间,而不是直接报错。

两个搭档

  • time_granularity(放在时间字段上):声明该列存储时的粒度。 在按月快照的列上写 '{"time_granularity": "month"}', 一次 :day 请求就会变成清晰的 grain_too_fine 错误, 而不是悄悄给出错误数字。
  • dataset(放在指标上):COUNT(*) 没点名任何列, 多数据集模型里引擎无法为它做归属。'{"dataset": "chat_record"}' 能把它钉住,且只在 SQL 本身没交代时生效 —— 点名了列的聚合仍走自己的数据集。

4. 做期间对比与逐期累计

问题

"这个月和上个月比怎么样?""3 个月移动平均是多少?""年初至今的营收?"—— 这些都没法用一个 OSI 聚合表达出来。而在指标表达式里写 LAG(...) OVER (...) 会被直接拒绝(window_in_metric): 裸窗口 SQL 校验不了、换个分组重算不了,范围也没法安全地重新划定。

做法

window 键把派生方式声明在指标上,指标自己的表达式仍是那个朴素的基础聚合:

metrics:
  - name: revenue_mom_growth          # 环比增幅
    expression:
      dialects: [{ dialect: ANSI_SQL, expression: "SUM(orders.amount)" }]
    custom_extensions:
      - vendor_name: DATUS
        data: '{"v": 1, "window": {"type": "pop", "offset": "1 month"}}'

  - name: revenue_3m_avg              # 近3月移动平均
    custom_extensions:
      - vendor_name: DATUS
        data: '{"v": 1, "window": {"type": "rolling", "function": "avg", "periods": 3}}'

  - name: revenue_ytd                 # 年度累计
    custom_extensions:
      - vendor_name: DATUS
        data: '{"v": 1, "window": {"type": "cumulative", "function": "sum", "reset": "year"}}'

查询方式和普通指标一样,只要按时间轴分组并带上粒度orders.order_date:monthmetric_time:month)。 其余每个 group-by 维度都会给窗口分区,比如在每个大区内部各算各的环比:

dosi query --metrics revenue,revenue_mom_growth,revenue_ytd \
  --group-by orders.region --group-by metric_time:month \
  --start-time 2025-05-01 --end-time 2025-11-01

有什么变化

期间对比会编译成日历正确的自连接:没有上一个月的那个月读作 NULL, 绝不会取成"上一条存在的行"。回看区间由引擎自动加载 —— 5 月开始的查询会去取 4 月,年中开始的 YTD 会回取到 1 月 1 日, 最后再把输出裁剪回请求的范围。

这些可以在一条查询里混用,环比、滚动平均、YTD、QTD 并排都行。 只有一个例外:永不重置的累计总额,在设置了起始时间时 不能和带回看/重置的指标共处一条查询,因为放宽后的扫描范围会改变它累计的内容 (引擎会明说)。滚动类指标还接受 "require_full_window": true, 让最初几个分桶返回 NULL,而不是对不完整的窗框求平均。

两个坑

  • 时间轴必须带粒度出现在 group-by 里。漏了会得到一个结构化错误, 重试提示会点名到底该补哪一项。
  • 带偏移的指标记得配 --start-time。不配的话,数据里第一个周期没有前驱, 于是(正确地)读作 NULL。

完整能力面见 window-extension.md:语法糖展开成的通用 offset/frame 形态、value|delta|percent_change|ratio 四种计算方式、 重置语义,以及 v1 的限制。


速查表

目标 位置 选项 取值 默认
丢弃还是保留未匹配的 JOIN 行 relationship join_type "left""inner" "left"
填充空分组的指标值 metric fill_nulls_with 任意数字 计数 → 0,其余为空白
指明业务时间轴 dataset / metric time_dimension 字段名(metric 上也可写 ds.field 唯一的 is_time 字段,否则无
声明某列存储的粒度 时间字段 time_granularity dayyear 未知,任意粒度均可
钉住 COUNT(*) 的归属表 metric dataset 数据集名 由 SQL 推导,否则报错
派生同环比/滚动/累计 metric window pop | rolling | cumulative | offset/frame 普通聚合

值得知道

  • 模型仍是合法的 OSI。 custom_extensions 是标准 OSI 字段, dosi validate 和上游 OSI 校验器都能通过。非 Datus 的工具会忽略 DATUS 那条。
  • 不写 = 保持现状。 只在需要改默认值的地方加,其余一概不受影响。
  • 写错会报出来,不会被忽略。 格式不对的 datus 载荷 (坏 JSON、join_type: "outer"、填了非数字)都是明确的编译错误。 出错就大声失败,不会悄悄按默认值执行。
  • 看一眼计划。 dosi query --explain 会打印每个 JOIN 的类型 (left / inner),据此可以确认扩展是否生效。
  • "v" 可选,但值得写。 不写,行为与上面展示的完全一致。 写上("v": "1.1",与 dosi info 报告的一致),引擎就能在双方对不上时 提醒你:模型用了比所声明版本更新的选项会告警;模型比引擎新, 也会告警并点名被丢弃的选项。dosi info 可以查到引擎实现的版本和它读取的全部选项。
  • 扩展只在 datus 模式(默认)下生效。--osi-basic 跑严格标准 OSI, DATUS 条目会被忽略,并打印一条告警说明改用了哪个默认值, 模型照样能加载和查询。见 cli.md

精确的 JSON schema、版本策略和优先级规则见 datus-extensions.md