指南:Datus 扩展¶
OSI 有两处行为没有定义,可以按模型分别控制:
两者都写在模型的 custom_extensions 里,这是 OSI 的官方字段。
模型仍然是 100% 合法的 OSI:不认识 Datus 的工具直接忽略,
Dosi 也只拿它们在 OSI 未作规定之处细化行为。
精确契约见 datus-extensions.md,这一页只讲怎么用。
所有 Datus 扩展共用同一种形态:一条 custom_extensions 条目,
vendor_name 为 DATUS,data 是一小段 JSON 字符串。
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. 空分组显示数字而不是空白¶
问题¶
按 city 看 order_count 和 signups。某个城市这个周期有注册、没有订单,
它的 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}'
有什么变化¶
按 city 看 revenue 和 signups,其中 Denver 有注册但没有订单:
city |
revenue(默认) |
revenue(fill_nulls_with: 0) |
|---|---|---|
| Austin | 900 | 900 |
| Denver | (空白) | 0 |
fill_nulls_with 对 SUM、比率、表达式各类指标都有效,填什么数字都行
(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 可以用了:
每个指标各按自己那张表的主时间列截断,结果在共享的 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:month 或 metric_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 |
day…year |
未知,任意粒度均可 |
钉住 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。