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作为连接键 —— 同样的合并会以一个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)。 - 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;错误码才是。