跳转至

Dosi 语义契约

OSI 核心规范刻意把执行语义留作隐含:指标就是一个名字加一段裸 SQL 字符串, 关系就是一份列映射,字段最多说明"它是不是时间维度"。 本文档是 Dosi 如何填补这些空白的规范性契约。它与引擎一同版本化; 这里的行为变更即破坏性变更。

规则以 S-<area>-<n> 编号,便于在 issue 和测试中引用。

1. 表达式

  • S-EXPR-1 每个 Expression 都优先从它的 ANSI_SQL 方言条目编译, 没有则取第一个 SQL 家族的条目(SNOWFLAKEDATABRICKS)。 MDX/TABLEAU/MAQL 条目会被忽略;只有非 SQL 条目的表达式, 对指标来说是编译错误,对字段来说是警告。
  • S-EXPR-2 表达式片段按单个标量表达式解析。 除此以外的一切(多个投影项、尾随别名、语句)都是解析错误 (这同时也是注入防线:用户文本在引擎的任何地方都不会通过字符串拼接进入 SQL)。

2. 指标推断

  • S-METRIC-1 指标表达式必须至少包含一次聚合调用。支持的聚合: SUMCOUNTCOUNT(DISTINCT x)AVGMINMAX。 其它任何聚合函数都是 unsupported_aggregate
  • S-METRIC-2 分类作用在剥掉括号后的根节点上:
  • 单次聚合调用 → aggregate(聚合型)指标;
  • agg / agg(两侧都是裸聚合)→ ratio(比率型); 该除法会被包成 CAST(numerator AS DOUBLE) / denominator, 以避免在做截断的引擎上发生整数除法;
  • 其余一切 → expression(表达式型);每棵聚合子树成为一个度量, 外围的算术运算原样保留(包括 NULLIFCASE 等)。 注意 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 是 sumcountcount_distinctaverageminmaxCOUNT(*){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 来源。边严格按多→一的方向走 (fromto),因此沿路径行走绝不会让起点的行数翻倍。 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)该度量归属数据集和整个必需集合的数据集。 对重复敏感的聚合(SUMAVGCOUNT)还被额外限制在各自的归属数据集上: 在已经扇出的 JOIN 上计算它们会导致重复计数。 COUNT DISTINCTMINMAX 则可以在任何地方计算。
  • 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 作为连接键 —— 同样的合并会以一个 keys CTE 的形式发出(各分支键元组的 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 除数)。 指标上的 Datus fill_nulls_with 扩展会覆盖这个默认行为, 并且对任何种类的指标都适用(docs/datus-extensions.md#d-fill)。

7. 维度、粒度、时间

  • S-TIME-1 group-by 项写作 dataset.field 或唯一的裸字段名; 输出列名为 {field},或者在应用了查询时粒度后为 {field}__{grain}。 输出名重复是错误。
  • S-TIME-2 粒度(dayweekmonthquarteryear)只能作用于 声明了 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;错误码才是。