Filed unassigned from #4811 / PR #4951,那里为了给 null-guard 闸门定判据把这几处逐一实测了一遍。本单只记录发现,不含修法承诺。
背景:为什么全量性是个契约,而不是实现细节
实测 @marcbachmann/cel-js,同一条谓词在两种绑定下语义恰好相反:
| 谓词 |
全量绑定 {a: null} |
稀疏绑定 {} |
has(record.a) |
true |
false |
record.a < record.b |
FAULT no such overload |
FAULT No such key: a |
record.a != null |
false |
FAULT No such key: a |
#4649/#1871 之所以引入 materializeDeclaredFields,正是为了让 record.x == null / != null 这类写法可用:在全量绑定下它返回布尔值,在稀疏绑定下它自身就 fault。
所以「记录是否全量」不是某个求值点的内部选择 —— 它决定了作者被允许写什么。同一个 record.x != null,在一处是正确守卫,在另一处是必然 fault。
发现:全量化只做在两个接缝上
已物化:
packages/objectql/src/validation/rule-validator.ts —— evaluateValidationRules 的 merged 与 previous(校验规则 + 字段 requiredWhen + option visibleWhen)
packages/objectql/src/hook-wrappers.ts —— 生命周期 hook 的 record / previous
未物化的三处,各自都在对可空已声明字段求值 CEL:
-
字段 readonlyWhen —— rule-validator.ts 里的 stripReadonlyWhenFields 合并 { ...previous, ...data } 后直接求值,从不物化。与同一个字段上的 requiredWhen 结论相反,而 requiredWhen 就在同一个文件里物化过。且它 fail-open(readonlyWhen for 'x' failed to evaluate — change allowed through),所以谓词 fault 时字段不再只读,改动照常写入。
-
flow 触发记录 —— packages/triggers/trigger-record-change/src/record-change-trigger.ts 把记录播种为 { ...(inputDoc ?? {}), ...after }。注释本身就写明「fields the driver did not echo back」,即明确知道 after 不保证带全列。start / edge condition 与 {record.x} 插值都读它。
-
action visible / disabled 的客户端绑定 —— 绑定是客户端已取到的那条记录;objectui 该路径上不存在任何物化步骤(ActionEngine / toPredicateInput / useCondition 全链只读已有 record)。列表行只带视图投影列,所以同一条谓词在 record_header 与 list_item 上看到的键集都不同。
后果
需要的决定(不是实现细节)
三处是否都应当补上 materializeDeclaredFields,从而把「谓词看到的记录 = 对象声明的形状」变成平台层面的统一保证?还是其中某几处刻意保持稀疏(例如 action 绑定跨进程,全量化意味着 REST 读取要补齐所有声明列)?
无论结论如何,结论本身需要被写下来并可引用 —— 现状是两个接缝物化了、三个没有,而没有任何文档或注释说这是有意的。
复现
const { evaluate } = require('@marcbachmann/cel-js');
evaluate('record.a != null', { record: { a: null } }); // false
evaluate('record.a != null', { record: {} }); // throws: No such key: a
Filed unassigned from #4811 / PR #4951,那里为了给 null-guard 闸门定判据把这几处逐一实测了一遍。本单只记录发现,不含修法承诺。
背景:为什么全量性是个契约,而不是实现细节
实测
@marcbachmann/cel-js,同一条谓词在两种绑定下语义恰好相反:{a: null}{}has(record.a)truefalserecord.a < record.bno such overloadNo such key: arecord.a != nullfalseNo such key: a#4649/#1871 之所以引入
materializeDeclaredFields,正是为了让record.x == null/!= null这类写法可用:在全量绑定下它返回布尔值,在稀疏绑定下它自身就 fault。所以「记录是否全量」不是某个求值点的内部选择 —— 它决定了作者被允许写什么。同一个
record.x != null,在一处是正确守卫,在另一处是必然 fault。发现:全量化只做在两个接缝上
已物化:
packages/objectql/src/validation/rule-validator.ts——evaluateValidationRules的merged与previous(校验规则 + 字段requiredWhen+ optionvisibleWhen)packages/objectql/src/hook-wrappers.ts—— 生命周期 hook 的record/previous未物化的三处,各自都在对可空已声明字段求值 CEL:
字段
readonlyWhen——rule-validator.ts里的stripReadonlyWhenFields合并{ ...previous, ...data }后直接求值,从不物化。与同一个字段上的requiredWhen结论相反,而requiredWhen就在同一个文件里物化过。且它 fail-open(readonlyWhen for 'x' failed to evaluate — change allowed through),所以谓词 fault 时字段不再只读,改动照常写入。flow 触发记录 ——
packages/triggers/trigger-record-change/src/record-change-trigger.ts把记录播种为{ ...(inputDoc ?? {}), ...after }。注释本身就写明「fields the driver did not echo back」,即明确知道after不保证带全列。start / edge condition 与{record.x}插值都读它。action
visible/disabled的客户端绑定 —— 绑定是客户端已取到的那条记录;objectui该路径上不存在任何物化步骤(ActionEngine/toPredicateInput/useCondition全链只读已有 record)。列表行只带视图投影列,所以同一条谓词在record_header与list_item上看到的键集都不同。后果
!= null,稀疏处has()才对、!= null会 fault。而元数据里没有任何东西标明某个 slot 属于哪一类。packages/lint/src/validate-null-guards.ts的台账里)。把这三处补齐,那三面就可以一并纳入闸门。readonlyWhen与 flow condition 两处都是 fail-open / 静默分支,属于「看起来生效、实则什么都没做」这一族。需要的决定(不是实现细节)
三处是否都应当补上
materializeDeclaredFields,从而把「谓词看到的记录 = 对象声明的形状」变成平台层面的统一保证?还是其中某几处刻意保持稀疏(例如 action 绑定跨进程,全量化意味着 REST 读取要补齐所有声明列)?无论结论如何,结论本身需要被写下来并可引用 —— 现状是两个接缝物化了、三个没有,而没有任何文档或注释说这是有意的。
复现