Dosi 语义契约¶
OSI 核心规范刻意把执行语义留作隐含:指标就是一个名字加一段裸 SQL 字符串, 关系就是一份列映射,字段最多说明"它是不是时间维度"。 本文档是 Dosi 如何填补这些空白的规范性契约。它与引擎一同版本化; 这里的行为变更即破坏性变更。
规则以 S-<area>-<n> 编号,便于在 issue 和测试中引用。
1. 表达式¶
- S-EXPR-1 每个
Expression都优先从它的ANSI_SQL方言条目编译, 没有则取第一个 SQL 家族的条目(SNOWFLAKE、DATABRICKS)。MDX/TABLEAU/MAQL条目会被忽略;只有非 SQL 条目的表达式, 对指标来说是编译错误,对字段来说是警告。 - S-EXPR-2 表达式片段按单个标量表达式解析。 除此以外的一切(多个投影项、尾随别名、语句)都是解析错误 (这同时也是注入防线:用户文本在引擎的任何地方都不会通过字符串拼接进入 SQL)。
2. 指标推断¶
- S-METRIC-1 指标表达式必须至少包含一次聚合调用。支持的聚合:
SUM、COUNT、COUNT(DISTINCT x)、AVG、MIN、MAX。 其它任何聚合函数都是unsupported_aggregate。 - S-METRIC-2 分类作用在剥掉括号后的根节点上:
- 单次聚合调用 → aggregate(聚合型)指标;
agg / agg(两侧都是裸聚合)→ ratio(比率型); 该除法会被包成CAST(numerator AS DOUBLE) / denominator, 以避免在做截断的引擎上发生整数除法;- 其余一切 → expression(表达式型);每棵聚合子树成为一个度量,
外围的算术运算原样保留(包括
NULLIF、CASE等)。 注意SUM(a)/NULLIF(SUM(b),0)是表达式型而不是比率型, 并且不会被二次强转:以作者写的 SQL 为准。 - S-METRIC-3 在指标表达式中被拒绝的写法,均以结构化错误给出:
窗口函数(
window_in_metric)、子查询(subquery_in_metric)、 嵌套聚合(nested_aggregate)、任何聚合之外的列引用 (bare_column_in_metric)、不作用于任何列的聚合 (SUM(1)→bare_column_in_metric)、多列的COUNT(DISTINCT a, b), 以及对其它指标的引用 —— 指标引用只存在于 DATUS 的derive扩展键里 (docs/datus-extensions.md#d-derive),绝不出现在表达式 SQL 中; 派生指标的expression始终是一份自包含的展开形式。
3. 度量(合成产生)¶
- S-MEASURE-1 每一次不同的聚合调用都会成为一个名为
{dataset}_{stem}_{suffix}的度量:stem 是聚合参数经渲染并净化后的结果 (小写化,非字母数字的连续段 →_);suffix 是sum、count、count_distinct、average、min、max;COUNT(*)→{dataset}_rows_count。 - S-MEASURE-2 度量按签名去重(数据集 + 聚合种类 + 是否 distinct +
归一化后的参数),在同一个指标内和跨指标之间都生效。
两个不同的聚合若净化后得到同一个名字,则是编译错误
(
measure_name_collision),绝不会被悄悄合并。
4. 列 → 数据集归属¶
- S-ATTR-1
dataset.column精确解析;限定符必须是数据集名, 而列必须是它已声明的字段之一。指标和过滤条件里的列引用的是字段 (字段自身也可以是表达式),而不是物理列。 - S-ATTR-2 裸列名当且仅当恰好有一个数据集声明了同名字段时才能解析;
零个 →
unknown_column,多个 →ambiguous_column(并附候选项)。 - S-ATTR-3 一次聚合内部的所有列必须属于同一个数据集
(否则
cross_dataset_aggregate)。 - S-ATTR-4
COUNT(*)归属到该指标其它度量所在的那个唯一数据集, 或者归属到模型里唯一的数据集;否则count_star_needs_dataset。
5. 连接¶
- S-JOIN-1 关系是唯一的 JOIN 来源。边严格按多→一的方向走
(
from→to),因此沿路径行走绝不会让起点的行数翻倍。 JOIN 条件是该关系各列对的等值条件 AND 起来,默认是LEFT JOIN(多端的孤儿行会存活下来,落进 NULL 维度桶)。关系可以通过 Datus 的join_type扩展声明为INNER(丢弃孤儿行,归因语义; docs/datus-extensions.md#d-join)。 - S-JOIN-2 目标数据集可达,当且仅当恰好存在一条简单路径
(按边计:并行的关系算作不同路径)。零条路径 →
no_join_path; 多条 →ambiguous_join_path,并把每条候选路径都列清楚。 路径最长 6 跳。 - S-JOIN-3 自关系与成环的行走绝不跟进。
6. 扇出保护与分支归属¶
正确性的核心。术语:度量的归属数据集(home)指其列所在的数据集; 一次查询的必需集合(required set)是被 group-by 维度和过滤条件引用到的每个数据集。
- S-FAN-1 度量的候选计算基(candidate evaluation base):
那些同时能到达(S-JOIN-2)该度量归属数据集和整个必需集合的数据集。
对重复敏感的聚合(
SUM、AVG、COUNT)还被额外限制在各自的归属数据集上: 在已经扇出的 JOIN 上计算它们会导致重复计数。COUNT DISTINCT、MIN、MAX则可以在任何地方计算。 - S-FAN-2 在一个指标内部,若存在公共候选基能承载它所有的度量,
整个指标就在那里计算(优先选本身即某个度量归属数据集的基)。
由此推论:像
SUM(fact.x) / COUNT(DISTINCT dim.k)这样的跨数据集比率 总是在事实表的 JOIN 之上计算,统计的是在事实表中被观察到的实体, 无论有没有 group-by 都是如此。 - S-FAN-3 否则,每个度量在它自己归属数据集的粒度上、在它自己的分支里聚合。
各分支都产出所要求的维度列,并按这些列做
FULL OUTER JOIN合并; 第 k 个分支按COALESCE(m0.key … m(k-1).key)与mk.key的空值安全 等值条件连接(见 S-FAN-5),最终的键以跨分支的COALESCE投影出来。 没有 group-by 键时则用CROSS JOIN。当某个方言无法表达这种连接时 —— Postgres 家族要求 FULL JOIN 里必须有等值条件,MySQL/TiDB 没有 FULL JOIN, ClickHouse 不能用COALESCE作为连接键 —— 同样的合并会以一个keysCTE 的形式发出(各分支键元组的UNION),再由每个分支LEFT JOIN回去。 两种形态产出的行完全相同;这个选择在结果里不可见, 只在--explain/生成的 SQL 里能看到。 - S-FAN-4 对重复敏感的度量,若其归属数据集没有任何候选基,
则是
fan_out_risk错误,并附带重试提示。 引擎绝不会悄悄发出一条会重复计数的查询。 - S-FAN-5 取值为 NULL 的分组键在跨分支时合并成一行,而不是每个分支一行:
合并用的连接是空值安全的,渲染成可移植的
(a = b OR (a IS NULL AND b IS NULL))(裸=会让每个分支的 NULL 桶都匹配不上 —— SQL 里NULL = NULL的结果是 unknown)。ClickHouse 拒绝把这种 OR 展开式 当作连接键,所以在那里 —— 也仅在那里 —— 会发出等价的单一谓词IS NOT DISTINCT FROM。 - S-FAN-6 纯计数指标(其值恰好就是一个 COUNT / COUNT DISTINCT 度量)
对于只出现在其它分支里的分组读作 0 而不是 NULL:零行之上的计数就是 0。
这只在整个指标就是那个计数时适用;嵌在比率或表达式里的计数仍保持 NULL
以便向外传播(而且计数作为分母时绝不会变成字面量
0除数)。 指标上的 Datusfill_nulls_with扩展会覆盖这个默认行为, 并且对任何种类的指标都适用(docs/datus-extensions.md#d-fill)。
7. 维度、粒度、时间¶
- S-TIME-1 group-by 项写作
dataset.field或唯一的裸字段名; 输出列名为{field},或者在应用了查询时粒度后为{field}__{grain}。 输出名重复是错误。 - S-TIME-2 粒度(
day、week、month、quarter、year)只能作用于 声明了dimension.is_time: true的字段,下降为DATE_TRUNC(在缺少它的方言上用各自的惯用写法:MySQL/TiDB 用DATE()/STR_TO_DATE(DATE_FORMAT(...))/YEARWEEK改写, ISO 周从周一开始)。 - S-TIME-3 时间范围是 ISO 日期上左闭右开的
[start, end), 在聚合之前编译成field >= DATE start AND field < DATE end, 作用对象依次为:显式点名的那个时间维度,否则是 group-by 里唯一的时间项 (metric_time算作一项),否则 —— 在完全没有时间项时 —— 是每个指标各自的主时间维度(S-TIME-5)。 只有当 group-by 里同时存在多个时间项时,才仍然需要显式点名 (time_range_needs_dimension)。 - S-TIME-4 OSI 核心不携带粒度元数据;原生粒度就是字段表达式产出的那个粒度,
除非该字段通过 Datus 的
time_granularity扩展声明了一个 (docs/datus-extensions.md#d-grain)—— 那时请求一个严格更细的粒度会是grain_too_fine错误。窗口指标(同环比/滚动/累计)是消费 S-TIME-5 时间轴的 Datus D-WINDOW 扩展 —— 见 window-extension.md; 时间轴表(time spine)在上游仍处于提案阶段 —— 见 rfc-time-semantics.md。 - S-TIME-5 每个指标都可以有一个主(聚合)时间维度,其解析顺序为:
指标级的 Datus
time_dimension(docs/datus-extensions.md#d-time), 否则取该指标所涉数据集中唯一的那个主时间 —— 一个数据集的主时间是它显式的time_dimension扩展,否则是它唯一的is_time字段 (最后这条推断不读取任何扩展,在基础模式下同样适用)。 保留的查询名metric_time表示按它分组/过滤:在多分支计划中, 每个分支都在共享的输出名(metric_time/metric_time__{grain})之下 代入自己的主时间列,各分支再按这个名字合并, 于是彼此无关的事实表会各自对齐到自己的业务时间轴上。 一个无法解析出主时间的指标是no_primary_time_dimension; 两个共处同一聚合分支却有不同主时间的指标是metric_time_conflict。 模型中一个字面名为metric_time的字段会被这个保留名遮蔽 —— 请用dataset.metric_time来限定它。
8. 过滤条件¶
- S-FILTER-1
--where过滤条件是作用在维度字段上的标量布尔 SQL, 在每一个分支里都于聚合之前应用。列按 S-ATTR 规则解析; 过滤条件涉及的数据集会像维度一样 JOIN 进每个分支。 - S-FILTER-2 过滤条件中被拒绝的写法:子查询、窗口函数
(
unsupported_filter)、聚合(aggregate_in_where; HAVING 式的指标过滤属于后续阶段)。
9. 数据集¶
- S-DATA-1 含有空白字符的
source视为内联查询(编译为派生表); 否则视为表引用,按.切分成最多 catalog.schema.table 三段。 标识符加引号尚不支持。 - S-DATA-2 在一个分支内,每个数据集最多出现一次,并以其数据集名作为别名 (v1 不支持自连接)。
10. 错误¶
- S-ERR-1 每个编译/查询错误都带有一个稳定的 snake_case
code、 一条给人看的消息、涉及到的名字、当一个错误引用存在备选时给出的candidates, 以及当某种改写能成功时给出的suggested_retry。--format json会输出完整结构。错误文本不是稳定 API;错误码才是。