明细查询¶
同一份语义模型,Dosi 回答两类问题。
| 问题 | 命令 | 返回 |
|---|---|---|
| 上个月各地区的收入是多少? | dosi query |
聚合后的指标 |
| 列出 Enterprise 客户最大的 100 笔订单 | dosi select |
明细行 |
两者走同一个计划器、同一张关系图、同一套方言后端,只差一个算子:
query 做聚合,select 做投影。
$ dosi select \
--from orders \
--fields orders.order_id,orders.amount,customers.name \
--where "customers.tier = 'Enterprise'" \
--order -amount \
--limit 100
不用写 JOIN。只要点名想要哪些字段,Dosi 自己判断它们落在哪些数据集上、 哪些关系把它们连起来,以及这样连是否安全。
--from 定义粒度,不是 SQL 的 FROM¶
--from orders 的含义是一行结果对应一个订单,不是"SQL 里只能出现
orders"。
字段、过滤、排序、时间范围都可以引用别的数据集。Dosi 收集这次查询需要的 全部数据集,为每一个找到关系路径,然后生成连接:
customers 一个字段都没投影,但过滤需要它,于是照样连上。结果仍是一行
一个订单。
这一点成立,是因为 Dosi 只沿一个方向走关系:从多的一侧走向一的一侧。 每一跳最多给已有的每一行附上目标表的一行,所以整条路径走完,要的粒度 依然不变。
说得再直白些:一笔订单只对应一个客户,所以把 customers.name 拉过来,是
给这一行加一列,不会给结果加一行。它是一次查找,就像表格里的
VLOOKUP 只填出一列、不改变表有多少行。
反方向才是手写 SQL 悄悄出错的地方。一个客户有多笔订单,从 customers
往 orders 连,一个客户会被拆成好几行,这时对任何客户级的列求 SUM,
这个客户就会按订单数被重复计入。这就是经典的重复计数,而且不报错:查询
照常成功,只是数字是错的。Dosi 不会生成这种 SQL:在 --from customers
下要订单上的字段,查询会被
detail_fanout 拒绝,并把真正能回答这个问题的粒度
递回来(--from orders),而不是给你一份重复的结果。
字段的三种写法¶
| 写法 | 例子 | 什么时候用 |
|---|---|---|
| 裸字段 | amount |
该名字在模型里唯一 |
| 数据集限定 | orders.amount |
常规写法 |
| 关系前缀 | orders_to_buyer.name |
需要自己指定连接路径 |
第三种在模型能用多条路径抵达同一数据集时才用得上。假设 orders 到
regions 既能直连(发货地),也能经 customers(买家所在地)。这时问
regions.region_name 确实有歧义,Dosi 会说出来,而不是替你挑一条:
$ dosi select --from orders --fields regions.region_name --format json
{
"code": "ambiguous_join_path",
"candidates": [
"orders -[orders_to_customers]-> customers, customers -[customers_to_regions]-> regions",
"orders -[orders_to_ship_region]-> regions"
],
"suggested_retry": "name the path you mean by prefixing the field with its relationships, one of: orders_to_customers.customers_to_regions.<field> | orders_to_ship_region.<field>"
}
把其中一个前缀贴回去,查询就能跑:
关系前缀在 --where 里同样可用,路径多长都行;指标平面的 --group-by
也接受 —— 到处是同一种写法。
输出列名¶
输出列名 = 字段引用去掉根前缀,剩下的每个 . 换成 __:
引用(根为 orders) |
列名 |
|---|---|
orders.order_id |
order_id |
customers.name |
customers__name |
orders_to_ship_region.region_name |
orders_to_ship_region__region_name |
orders.created_at:month |
created_at__month |
根字段保持裸名,因为问的就是它;其余都带上来源,这样两个数据集各有一个
name 时不会悄悄撞在一起。两个字段会产生同一个列名时报
duplicate_output_name。
什么情况下拒绝¶
引擎绝不返回自己无法担保的行。有三种拒绝值得认识。
detail_fanout —— 要的数据集只能逆着连接方向抵达。一个客户有多笔
订单,所以从 --from customers 投影订单字段会把一个客户变成好几行:
$ dosi select --from customers --fields customers.name,orders.order_id --format json
{
"code": "detail_fanout",
"suggested_retry": "query at the finer grain instead: --from orders and project the customers fields you need"
}
改法就写在提示里:换到更细的粒度去问。--from orders --fields
orders.order_id,customers.name 给出同样的信息,一行一个订单,不重复。
ambiguous_join_path —— 多条路径都能抵达该字段。按上面的办法点名一条。
metric_in_detail_query —— 指标需要聚合、时间范围和分组,明细查询
这三样都没有。提示里直接给出该用哪个命令。
unknown_query_key —— 查询带了它没有定义的键,通常是拼写错误。直接
拒绝而不是忽略,因为被忽略的 distinct 或筛选条件会返回一份看起来合理、
回答的却是另一个问题的结果。candidates 列出查询定义的键。
每个拒绝都带机器可读的 code、涉及的名字,以及 suggested_retry。
参见错误码。
时间范围¶
--start-time / --end-time 给出半开区间 [start, end)。不写
--time-dimension 时,范围落到根数据集的主时间维度上:
$ dosi select \
--from orders \
--fields orders.order_id,orders.amount \
--start-time 2026-01-01 --end-time 2026-02-01
根数据集没有主时间维度、投影里也没有唯一的时间字段时,Dosi 会要求点名 一个,而不是猜。
固定粒度字段¶
根数据集上的 D-LOD fixed 字段是每个键一个值,
比如每个用户的活跃天数、每个玩家的付费笔数。明细查询读它和读普通字段一样:
可以投影、筛选、排序。根数据集的每一行会关联上它所属键的那一行汇总,而汇总里
每个键只有一行,所以值挂到了行上,行数仍然是根数据集的行数。
$ dosi select --from sessions --fields sessions.user_id \
--where "sessions.active_day >= 2" \
--context-filter "sessions.channel_id = 2" \
--start-time 2025-01-01 --end-time 2025-02-01 --distinct
分层规则和指标查询一致(见筛选的位置):
--start-time/--end-time和--context-filter是上下文。 它们既筛 行,也进入汇总:上面的例子数的是 1 月在渠道 2 上的活跃天数。--where是维度筛选。 它在值挂上之后挑行,不会进入汇总。把channel_id = 2挪到--where,数的就是 1 月所有渠道的天数,再保留渠道 2 的行,结果是另一份名单。--where的条件可以同时读存储列和汇总值 (active_day > 2 OR user_id = 'E')。--context-filter不能读固定粒度字段,因为它本身就是计算这个字段的汇总的输入 (报unsupported_filter)。
跨数据集的字段用法一样:
--where "flights.open_alert_count = 0" 列出没有 OPEN 告警的航班;上下文条件
只有在另一张表读得到时才会进入它的汇总。字段在根数据集上有自己的时间窗时,查询的
主窗口要写在 --where,不要用时间范围,否则两个窗口会在汇总里取交集。本版本里
字段必须在 --from 数据集上;在关联数据集上的字段会被拒绝,并提示该用哪个数据集
做根。
字段自己的时间窗是它 base 的参数,用
--param NAME=VALUE(其他接口面写 "params")绑定。例如 1 月活跃、2 月没登录
的玩家:
$ dosi select --from logins --fields logins.player_id --distinct \
--where "logins.login_day BETWEEN 20250101 AND 20250131 AND logins.window_days = 0" \
--param window_start=20250201 --param window_end=20250228
字段是一列,所以每个参数只能绑一个值;查询读到的字段都没有声明的参数,报
unknown_metric_param。
重复行¶
明细查询的一行对应根数据集的一行,所以从事实表上读出的 ID 名单,每个 ID
会按事实行重复出现:登录表上是一次登录一行,而不是一个玩家一行。
--distinct(其他接口面写 "distinct": true)会去掉投影值完全相同的行,
即 SELECT DISTINCT:
它只合并行,不改变有哪些行,所以一对多的遍历仍然直接拒绝,而不是先放大再去重。
行数上限¶
--limit 默认 100,上限截到 10000。四个接口面都是这个规则,
所以 Agent 忘了写 limit 只会拿到一份样本,不会扫全表。
CLI 之外¶
同一个 DetailQuery 通到每个接口面,语义一致、错误一致。
信任结果之前先看连接¶
--explain 打印逻辑计划:根数据集、选中的每一条连接,以及投影。
$ dosi select --from orders --fields orders.order_id,customers.name --explain
logical plan
Project[order_id, customers__name]
JoinRelated[orders_to_customers -> customers, left]
ReadDataset[orders]
连接默认 LEFT,所以匹配不上的行会带着 NULL 保留下来,不会凭空消失。
关系可以改成 INNER,见控制 JOIN 与空值填充。
本版本的限制¶
- 一次查询抵达每个数据集只能走一条路径。两条命名路径指向同一数据集 需要两个表别名,目前还没有,所以引擎宁可拒绝,也不把同一个连接读两遍。
- 排序键必须是已投影的列。想按哪个字段排序,就把它投影出来。
- 指标和字段不能混在一次查询里。分别查两个平面,结果自己拼。按实体汇总的值是 例外:把它声明成固定粒度字段,就能在这里读。
- 固定粒度字段必须在
--from数据集上。