跳转至

明细查询

同一份语义模型,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 收集这次查询需要的 全部数据集,为每一个找到关系路径,然后生成连接:

$ dosi select --from orders --fields orders.order_id --where "customers.region = 'US'"

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>"
}

把其中一个前缀贴回去,查询就能跑:

$ dosi select --from orders --fields orders.order_id,orders_to_ship_region.region_name

关系前缀在 --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:

$ dosi select --from logins --fields logins.player_id --distinct

它只合并行,不改变有哪些行,所以一对多的遍历仍然直接拒绝,而不是先放大再去重。

行数上限

--limit 默认 100,上限截到 10000。四个接口面都是这个规则, 所以 Agent 忘了写 limit 只会拿到一份样本,不会扫全表。

CLI 之外

同一个 DetailQuery 通到每个接口面,语义一致、错误一致。

$ curl -s localhost:8080/v1/select/execute \
    -H 'content-type: application/json' \
    -d '{"from":"orders","fields":["orders.order_id","customers.name"],"limit":10}'

还有 POST /v1/select/compile 与 POST /v1/select/explain, 见 REST API。

工具 select、compile_select、explain_select, 见 MCP 服务。

engine.select({"from": "orders", "fields": ["orders.order_id"], "limit": 10})

信任结果之前先看连接

--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 数据集上。

另请参阅