跳转至

Dosi 语义契约

Apache Ossie(原 OSI)核心规范刻意把执行语义留作隐含:指标就是一个名字加一段裸 SQL 字符串, 关系就是一份列映射,字段可以声明逻辑类型和是否作为时间维度。 本文档是 Dosi 如何填补这些空白的规范性契约。它与引擎一同版本化; 这里的行为变更即破坏性变更。

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

字段和指标均支持 Ossie 核心规范的可选 datatype:String、Integer、 Decimal、Float、Boolean、Date、Time、DateTime、DateTimeTz、Opaque, 大小写必须一致。它描述表达式结果的类型,不一定是源列类型。类型未知时省略; 已知类型不在标准枚举内时使用 Opaque。Decimal 不指定精度和小数位数; DateTime 不含时区,DateTimeTz 通过偏移量或时区信息确定一个时刻。 该声明不会自动添加类型转换、改变指标分类或设置输出显示格式,两种引擎模式都支持。

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 自关系与成环的行走绝不跟进。
  • S-JOIN-4 声明了 cardinality: many_to_many 的关系(Datus D-CONFORM, docs/datus-extensions.md#d-conform)不是连接边:连接图在任一方向都不会沿它 行走,把它写进关系路径是 invalid_relationship_path。它的列对声明的是同一个维度 在两个数据集上的两种拼写 —— 所有这类列对的传递闭包构成一个一致性类 —— 由 S-FAN-7 消费。

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 只要归属数据集本身是候选基,度量就在归属数据集上计算。 聚合所写的数据集就是它的行集,所以 COUNT(DISTINCT dim.k) 统计的是分组内 dim 的全部实体,无论它单独出现还是与事实表度量组成比率:一个定义只出一个数, 与查询里还有什么无关。只有归属数据集到不了必需集合的度量才会移动: 把 SUM(fact.x) / COUNT(DISTINCT dim.k) 按一个只有 fact 能到达的维度分组时, 计数在事实表的 JOIN 之上计算,统计的是事实表观察到的实体, 因为没有别的基能服务这个分组。离开归属数据集计算的度量以 <measure>__at_<base> 投影,同一度量在一次查询里的两个放置不会共用一个列名。 若希望无论怎样分组都统计观察到的实体,改在事实表的外键上聚合: COUNT(DISTINCT fact.dim_fk) 归属事实表,永远不会移动。 time range 或 metric_time 会把事实表的时间列加入必需集合, 所以带时间范围的比率里,维度计数同样跟着事实表走。
  • 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)。
  • S-FAN-7 一致性解析按分支进行。若某个 group-by 维度、WHERE 列或时间范围列 所在的数据集,分支的基沿多→一的边到不了,它就解析成其一致性类(S-JOIN-4)里 位于该基上的那个成员 —— 或者基能到达的唯一那个成员 —— 并按查询的输出名投影, 于是各分支仍然按同一列合并(S-FAN-3)。当各事实表的主时间维度构成一个类时, metric_time 也这样解析:每个分支按自己的日期列过滤和分组。粒度截断作用在解析后 的列上,并受该列自己的 D-GRAIN 下限约束。只有配对列会这样解析;未配对的列照旧是 unconformed_dimension / no_join_path。没有一致性类的模型,规划结果与从前完全相同。

7. 维度、粒度、时间

  • S-TIME-1 group-by 项写作 dataset.field 或唯一的裸字段名; 输出列名为 {field},或者在应用了查询时粒度后为 {field}__{grain}。 输出名重复是错误。
  • S-TIME-2 粒度(day、week、month、quarter、year)只能作用于 is_time 实际取值为 true 的字段,生成 DATE_TRUNC (在缺少它的方言上用各自的惯用写法:MySQL/TiDB 用 DATE() / STR_TO_DATE(DATE_FORMAT(...)) / YEARWEEK 改写, ISO 周从周一开始)。显式 dimension.is_time 优先;未声明时,Date、Time、 DateTime、DateTimeTz 默认为 true,即使整个 dimension 块都省略也是如此。 其他类型或未声明类型时默认为 false。审计时间戳可用 is_time: false 排除。 此规则在两种模式下都生效。接受 Time 元数据并不代表支持时分秒范围或日以下粒度; 当前时间查询仍要求字段值包含日期。
  • 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 Ossie 核心不携带粒度元数据;原生粒度就是字段表达式产出的那个粒度, 除非该字段通过 Datus 的 time_granularity 扩展声明了一个 (docs/datus-extensions.md#d-grain)—— 那时请求一个严格更细的粒度会是 grain_too_fine 错误。核心 datatype 声明逻辑类型,但不描述字符串或整数日期的编码 ('20260101'、202601)。这类字段需要通过 Datus 的 time 扩展 (docs/datus-extensions.md#d-format)声明存储布局;仅有 String 或 Integer 无法确定解析方式。未声明 time_granularity 时,编码还会提供原生粒度。 datatype: Date 的时间字段无需 Datus 声明就具有日粒度。 窗口指标(同环比/滚动/累计)是消费 S-TIME-5 时间轴的 Datus D-WINDOW 扩展 —— 见 window-extension.md; 时间轴表(time spine)在上游仍处于提案阶段。
  • 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。 同一底表上的两个指标主时间不同(比如 compose 用一致性维度的月份, 成员用自己事实表的月份),每条时间轴会在这张底表上各开一个分支, 各自按自己的轴过滤、分组,再像其他分支一样按分组键合并: 每个指标的结果都和单独查询时一样,代价是每多一条轴就多扫一次这张底表。 模型中一个字面名为 metric_time 的字段会被这个保留名遮蔽 —— 请用 dataset.metric_time 来限定它。

8. 过滤条件

  • S-FILTER-1 --where(where_sql)是标量布尔 SQL。它按顶层 AND 拆成若干子条件,每个子条件放到能求值它的位置上。依据是这个位置上有哪些列, 而不是猜测意图:
  • 引用了所选指标的子条件筛选结果行:所有指标、窗口层级和 compose 都算完之后,order_by / limit 之前生效,所以「高于 X 的分组里取前 10」 就是字面意思。在 time_ranges 下指标名写成 {metric}__{tag};
  • 在窗口查询里,只引用分组维度的子条件也筛选结果, 保留下来的行保持全体参与计算时得到的名次、分区行数和权重;
  • 其余子条件(引用了未参与分组的字段,或按粒度分组的时间字段) 在聚合之前筛选输入行,在每一个分支里都生效。列按 S-ATTR 规则解析; 过滤条件涉及的数据集会像维度一样 JOIN 进每个分支。分支里带有固定粒度汇总时 (D-LOD),这类子条件是维度筛选: 在汇总算好之后应用,从不约束汇总本身。

非窗口查询里,分组维度上的条件仍留在输入端:对分组键先筛后聚合和先聚合后筛, 得到的行和值完全相同,而放在输入端能裁剪扫描。引用优先按字段解析, 所以即使某个指标与字段同名,已有的条件也不会换位置。 - S-FILTER-2 context_filter 是行级 SQL,永远筛选输入, 也就是窗口排名、计数、加权所用的人群。同一个条件在两处回答的是不同的问题: where_sql: "market = 'North'" 看的是华北在所有市场里的名次, context_filter: "market = 'North'" 是只让华北参与排名。 时间范围(time_range / time_ranges)也在这个位置;窗口需要回看时会放宽扫描范围, 算完窗口再裁回请求的边界,所以它是计算窗口,不是展示范围。 这两者都是固定粒度汇总的上下文,汇总就在它们筛过的行上计算, 所以都不能引用 LOD 的值(unsupported_filter)。 - S-FILTER-3 被拒绝的写法:子查询、窗口函数和绑定参数(unsupported_filter); 内联聚合(aggregate_in_where,应声明为指标再按名引用); 查询没有选择的指标(result_filter_metric_not_selected); 一个子条件同时引用指标和未分组的字段(result_filter_unprojected, 它没有可放的位置,请把字段加入分组,或拆成两个子条件)。

explain 会显示每个位置:投影根之上是 ResultFilter[…],扫描上是 Filter。 结果筛选编译成包在整条查询外面的一层 SELECT (WITH …, result_rows AS (…) SELECT … FROM result_rows WHERE …), 因为最外层窗口是在它自己的投影里计算的,写在那一层的 WHERE 会先于窗口执行。

9. 数据集

  • S-DATA-1 含有空白字符的 source 视为内联查询(编译为派生表); 否则视为表引用,按 . 切分成最多 catalog.schema.table 三段。 标识符加引号尚不支持。
  • S-DATA-2 在一个分支内,每个数据集最多出现一次,并以其数据集名作为别名 (v1 不支持自连接)。

10. 字段引用与显式路径

两个查询平面用同一段代码解析字段引用。

  • S-VIA-1 引用有三种写法:field、dataset.field、 relationship[.relationship…].field。首段先按数据集名匹配, 所以模型里若有关系与数据集同名,原有引用仍解析到数据集—— 新增这种写法不会改变任何既有引用的含义。
  • S-VIA-2 关系前缀引用逐条边点名自己的连接路径。这些边必须首尾相接 (每条边从上一条抵达的数据集出发)、不得重复访问某个数据集, 并受图本身的 6 跳上限约束。否则报 unknown_relationship / invalid_relationship_path。
  • S-VIA-3 指标查询的分支基础数据集在解析之后才确定,所以命名路径会把它 钉死:基础数据集必须是路径首条边的 from。两条命名路径起点不一致 是错误;起点无法承载该查询的度量同样是错误。
  • S-VIA-4 过滤条件接受全部三种写法,路径长度不限。三段及以上的引用不是 SQL 意义上的列——它按结构体字段访问解析——所以计划器把它和普通列分开解析。 过滤条件里的每一个引用都必须对着模型解析成功,否则整条过滤被拒绝; 没有任何引用能未经校验地进入生成的 SQL。
  • S-VIA-5 一次查询抵达每个数据集只走一条路径。lowering 以数据集名作表别名 (S-DATA-2),两条路径指向同一数据集会读同一个别名两次; 这种情况报 not_implemented,绝不返回两列相同的值。

11. 明细查询(投影平面)

  • S-SELECT-1 from 固定结果粒度:一行输出对应该数据集的一行。 其余数据集只沿多→一的边抵达(S-JOIN-1),所以路径上没有任何连接 能让根的行数变多。只能逆向抵达的数据集报 detail_fanout, 其 suggested_retry 给出能在更细粒度上回答该问题的根。
  • S-SELECT-2 必需集合 = 投影、过滤列、时间范围点到的全部数据集。 它们都会被连接;只有投影成为输出列。
  • S-SELECT-3 输出列名 = 引用去掉 from 前缀、其余每个 . 换成 __, 施加粒度时再接 __{grain}。根字段保持裸名,其余带上路径。 两个字段产生同一列名报 duplicate_output_name。
  • S-SELECT-4 明细查询只投影行级字段。投影里出现指标名报 metric_in_detail_query;where 里出现聚合报 aggregate_in_where (S-FILTER-2)。
  • S-SELECT-5 不写 dimension 时,时间范围落到根数据集的主时间维度 (D-TIME);没有则落到投影里唯一的时间字段;再没有就报 time_range_needs_dimension。
  • S-SELECT-6 排序键必须是已投影的输出列(unknown_order_key)。 limit 默认 100,四个接口面都截到 10000。

12. 错误

  • S-ERR-1 每个编译/查询错误都带有一个稳定的 snake_case code、 一条给人看的消息、涉及到的名字、当一个错误引用存在备选时给出的 candidates, 以及当某种改写能成功时给出的 suggested_retry。 --format json 会输出完整结构。错误文本不是稳定 API;错误码才是。