Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .changeset/required-when-parent-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@objectstack/objectql': patch
'@objectstack/lint': patch
---

fix(objectql,lint): 服务端为 `requiredWhen` 绑定 parent 作用域,并把构建期硬闸扩到同一格

`readonlyWhen` 的 parent 作用域洞在 #4889 已经补上;同一个字段上、由同一个求值器处理的
`requiredWhen` 隔一个槽位还漏着。detail 对象上声明的
``requiredWhen: P`parent.status == 'sent'` `` ——「表头一旦 Sent,每一行都必须填写说明」——
只在内联表格里被求值,服务端从来只绑 `record` / `previous`,谓词直接 fault 走 fail-open
分支,写入带着空字段落库,API 还回 200。

注意它与 #4889 是**镜像**而不是同一种故障:`readonlyWhen` fail-open 是**写进了本该冻结的字段**,
`requiredWhen` fail-open 是**收下了本该被拒的记录**。两者都是同一处声明点上的 `declared ≠ enforced`
(PD #10)。

本次按维护者 2026-08-06 的裁决落 A + C 两条,**刻意不对称于 #4889**:

- **A —— 绑作用域,求值语义不动。** 引擎用 #4889 已经建好的
`resolveMasterDetailParent(s)` 解析主表头行并传入求值器,insert / 单 id update /
bulk update 三个调用点都覆盖。**不可求值仍然 fail-open**(记日志、跳过、放行):
表头此刻读不到就 422 掉一次本来合法的写入,比 `readonlyWhen` 那边「拒掉一个字段」响得多。
这是 issue 的 B 案,明确不做,留给 ADR-0058 D5 下一次复审。
- **C —— 改在构建期拦。** `@objectstack/lint` 的 parent 作用域闸原本只盖 `readonlyWhen`,
现在同样判 `requiredWhen`:对象没有恰好一个 `master_detail` 关系时,`parent` 不是元数据
陈述过的事实,声明直接判 error。两格共用同一个闸,但**报错文案不同** —— 两边运行时的失败
方向相反(`readonlyWhen` fail-closed ⇒ 字段永远写不进;`requiredWhen` fail-open ⇒ 要求
永远不生效),文案指错了就等于给了相反的修法。运行时敢保持 fail-open,正是因为这道闸
拦住了那条会无声烂掉的声明。

同一次改动里补了 ADR-0113 非回归判定在 parent 作用域下的正确输入:「存量行本来就违规吗」问的是
**写入前**那一行的状态,而它挂的是**旧**表头。改挂(repoint)到另一个主表时,若把落地表头也
喂给这个前置判定,就会把「移到 Sent 表头之下」读成既有违规而放行 —— 正是本 issue 要堵的那个
收下动作,只是换了个入口。因此求值器新增 `previousParent`,仅在载荷确实改挂时由引擎解析,
其余情况沿用同一行、不多付一次读。

对象级 `script` / `cross_field` 规则共用这个求值调用点,自 #4649 起对不可求值谓词是
**fail-closed**,本次**没有**给它们绑新根 —— 绑了会把它们今天拒掉的写入翻成接受。这条由 pin
测试钉住(#4972 当初把本改动挡在范围外,就是为了这个爆炸半径)。

仓内暂无 app 声明 parent 作用域的 `requiredWhen`(showcase 的 invoice line 用的是行作用域的
`record.quantity >= 100`),所以这是补潜伏缺口,不改变任何现有 app 的写入行为。
89 changes: 87 additions & 2 deletions packages/lint/src/validate-expressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,16 +539,101 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
})).toHaveLength(0);
});

it('is scoped to `readonlyWhen` — `requiredWhen`/`visibleWhen` verdicts are unchanged', () => {
// #4977 — this pin used to read "scoped to `readonlyWhen`, `requiredWhen`
// verdicts unchanged" over a fixture declaring BOTH. It pinned exactly the
// limb that issue removes, so it is replaced rather than re-spelled: the
// `requiredWhen` half moved to its own cases below, and what survives here
// is the genuinely-unchanged slot, `visibleWhen`, on a fixture that
// declares only that.
it('is still scoped OUT of `visibleWhen` — that verdict is unchanged', () => {
expect(parentScopeIssues({
name: 'orphan_line',
fields: {
qty: { type: 'number', requiredWhen: "parent.status == 'paid'", visibleWhen: "parent.status == 'paid'" },
qty: { type: 'number', visibleWhen: "parent.status == 'paid'" },
},
})).toHaveLength(0);
});
});

// #4977 — the same gate, extended to the slot the same issue gave a server
// `parent` binding. `requiredWhen` stays FAIL-OPEN at runtime, so this build
// gate is the only thing that stops an unbindable declaration from shipping
// and enforcing nothing forever — which is why the message must name that
// consequence and not `readonlyWhen`'s opposite one.
describe('parent-scoped `requiredWhen` needs a resolvable master (#4977)', () => {
const parentScopeIssues = (obj: Record<string, unknown>) =>
validateStackExpressions({ objects: [obj] }).filter((i) => /reads `parent`/.test(i.message));

it('rejects it on an object that declares NO master_detail relationship', () => {
const issues = parentScopeIssues({
name: 'orphan_line',
fields: {
inv: { type: 'lookup', reference: 'inv' }, // a lookup is not a master
description: { type: 'text', requiredWhen: "parent.status == 'sent'" },
},
});
expect(issues).toHaveLength(1);
expect(issues[0]!.severity).toBe('error');
expect(issues[0]!.where).toMatch(/field 'description' requiredWhen/);
expect(issues[0]!.message).toMatch(/declares no `master_detail` relationships/);
// The CONSEQUENCE clause is what separates this from its `readonlyWhen`
// twin: fail-open there, fail-closed here, opposite fixes.
expect(issues[0]!.message).toMatch(/the requirement would never be enforced/);
expect(issues[0]!.message).not.toMatch(/locked on every write/);
});

it('rejects it when TWO masters leave "the parent" unstated', () => {
const issues = parentScopeIssues({
name: 'junction',
fields: {
left: { type: 'master_detail', reference: 'a' },
right: { type: 'master_detail', reference: 'b' },
description: { type: 'text', requiredWhen: "parent.status == 'sent'" },
},
});
expect(issues).toHaveLength(1);
expect(issues[0]!.message).toMatch(/declares 2 `master_detail` relationships/);
});

it('ACCEPTS it on a real detail object — the showcase shape must stay lintable', () => {
expect(parentScopeIssues({
name: 'showcase_invoice_line',
fields: {
invoice: { type: 'master_detail', reference: 'showcase_invoice' },
description: { type: 'text', requiredWhen: "parent.status == 'sent'" },
},
})).toHaveLength(0);
});

it('does not fire on a field named `parent_id` or a `parent` string literal', () => {
expect(parentScopeIssues({
name: 'node',
fields: {
parent_id: { type: 'text' },
kind: { type: 'text' },
a: { type: 'text', requiredWhen: "record.parent_id != ''" },
b: { type: 'text', requiredWhen: "record.kind == 'parent'" },
},
})).toHaveLength(0);
});

it('reports BOTH slots when one field declares two unbindable predicates', () => {
const issues = parentScopeIssues({
name: 'orphan_line',
fields: {
qty: {
type: 'number',
readonlyWhen: "parent.status == 'paid'",
requiredWhen: "parent.status == 'sent'",
},
},
});
expect(issues).toHaveLength(2);
expect(issues.map((i) => i.where.replace(/.* field 'qty' /, '')).sort())
.toEqual(['readonlyWhen', 'requiredWhen']);
});
});

it('flags a bare-field sharing-rule condition', () => {
const issues = validateStackExpressions({
objects: [{ name: 'crm_account', fields: { region: { type: 'text' } } }],
Expand Down
46 changes: 36 additions & 10 deletions packages/lint/src/validate-expressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,22 +583,48 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
// at build time, so it is decided here rather than discovered as an
// unwritable field in production — PD #12, declared rather than guessed.
//
// Scoped to `readonlyWhen` on purpose: it is the one field predicate the
// server enforces as a write-path LOCK, so it is the one whose unbindable
// scope changes what lands in the database. `requiredWhen` /
// `visibleWhen` keep their existing verdicts untouched.
const roWhenSource = celSourceOf(f.readonlyWhen);
if (masters !== 1 && roWhenSource && readsParentRoot(roWhenSource)) {
// [#4977] Extended to `requiredWhen`, which the server enforces from the
// same declaration site through the same evaluator and which gained its
// own `parent` binding in the same issue. The two slots ask ONE question —
// "is `parent` a fact this object's metadata states?" — so they share one
// gate rather than growing a second copy of `masterDetailCount` +
// `readsParentRoot`.
//
// What they do NOT share is the CONSEQUENCE, and the message has to name
// the right one or it prescribes the wrong fix (the same reason #4811's
// null-guard gate passes its outcome in explicitly instead of inferring
// it). The two runtimes fail in OPPOSITE directions on an unbindable
// `parent`: `readonlyWhen` fails CLOSED (#4889 — an unbound scope root
// resolves to LOCKED, so the field becomes unwritable forever), while
// `requiredWhen` stays fail-OPEN (#4977's ruling deliberately did not copy
// the carve-out), so the requirement silently enforces NOTHING. This gate
// is why fail-open is affordable there: the declaration that would rot
// unnoticed at runtime cannot ship in the first place.
//
// `conditionalRequired` (retired alias) and `visibleWhen` (no
// server-enforced `parent` binding of its own) keep their verdicts
// untouched.
// Destructured in the loop head on purpose: both predicate slots stay
// LITERAL member reads (`f.readonlyWhen` / `f.requiredWhen`) rather than a
// computed `f[key]`, so the #5017 meta-test's source scan still sees every
// key this rule reads and can still check it against `FieldSchema`. An
// indexed read here would have disarmed that scan silently.
for (const [slot, raw, consequence] of [
['readonlyWhen', f.readonlyWhen, `the field would be locked on every write`],
['requiredWhen', f.requiredWhen, `the requirement would never be enforced — the predicate faults, the server logs and skips it, and the field stays optional in the database`],
] as const) {
const source = celSourceOf(raw);
if (masters === 1 || !source || !readsParentRoot(source)) continue;
issues.push({
where: `object '${objectName}' · field '${fname}' readonlyWhen`,
where: `object '${objectName}' · field '${fname}' ${slot}`,
message:
`\`readonlyWhen\` reads \`parent\`, but object '${objectName}' declares ` +
`\`${slot}\` reads \`parent\`, but object '${objectName}' declares ` +
`${masters === 0 ? 'no' : `${masters}`} \`master_detail\` relationship${masters === 1 ? '' : 's'} — ` +
`so the server has no header record to bind as \`parent\` and the field would be locked on every write. ` +
`so the server has no header record to bind as \`parent\` and ${consequence}. ` +
(masters === 0
? `Declare the owning relationship as \`Field.masterDetail('<master>')\`, or rewrite the predicate against \`record\`.`
: `\`parent\` needs exactly one master; name the header explicitly through \`record.<fk>\` state instead, or model the extra relationship as a \`lookup\`.`),
source: roWhenSource,
source,
severity: 'error',
});
}
Expand Down
Loading
Loading