Skip to content

Commit 7465eeb

Browse files
fix(formula,objectql): name the working guard for an optional lookup in both traversal refusals (#20049)
Fixes #20007 Clause-②: no ## What was wrong An author who wants to refuse an order whose OPTIONAL `line` lookup points at a secret line, and say nothing about an order with no line, was sent in a circle by two refusals: 1. `record.line != null && record.line.kind == 'secret'` reads `line` both through the relationship and as a plain value, so the formula conflict check refuses it (in `@objectstack/lint` at authoring time and in the engine at write time). That refusal prescribed "Compare the id explicitly: write `record.line.id` for the value comparison". 2. `record.line.id != null && record.line.kind == 'secret'` also reads through `line`. So an order with no line is refused before evaluation as "no single related record" (`resolveTraversalScope` gets a `no-reference` binding). That prescription said "Guard the rule on the reference being set, make it required, or …" and named no spelling for the guard. The spellings that work, a `conditional` wrapper or `required: true`, were named in neither refusal. ## What changed Only the prescription text. Every refusal keeps its verdict, its `VALIDATION_FAILED` error, its `rule_violation` field error and its `constraint`, and the same writes are refused. - `packages/formula/src/relationship-traversal.ts`: the `bare-and-traversed` message now says `.id` compares ids and is not a null guard. For a plain value that tests for empty, it names the guard and `required: true`. - `packages/objectql/src/validation/rule-validator.ts`, the `no-reference` arm of `traversalRefusal`: names the guard through the landed `referenceGuardRepair(field)`, says `record.FIELD.id != null` is no guard, and names `required: true`. The multi-value macro clause is kept. Engine texts at this head, printed from the built `@objectstack/objectql` dist. The runtime text spells `RELATED_FIELD` below as the placeholder `related field` in angle brackets. ```text [mixed shape, worded by formula] … To compare the id, write `record.line.id` for the value comparison, and keep `record.line.RELATED_FIELD` for the traversal. `record.line.id` is not a null guard: it reads through `line` too, and a rule that reads through an empty `line` rejects the write instead of being skipped. If the plain value tests for empty, take that test out of this expression. To skip the rule while `line` is empty, guard it on `line` being set: make it the `then` of a `conditional` rule whose `when` is `record.line != null`. To refuse an empty `line`, make `line` required (`required: true`). [no single related record, worded by objectql] … A predicate resolves ONE hop through a single reference. To skip the rule while `line` is empty, guard it on `line` being set: make it the `then` of a `conditional` rule whose `when` is `record.line != null` — `record.line.id != null` inside the rule is no guard, as it reads through `line` too. To refuse an empty `line`, make `line` required (`required: true`). For a multi-value reference, test it with a macro (`exists`, `size`) instead of reading through it. ``` ## Premise check and PM hypotheses (measured at `origin/main` `b3735968ba`) - **H1 holds.** The new `#20007` block in `engine-predicate-relationship.test.ts` was committed first (`ae97576f7`) and run against the unchanged source: `2 failed | 2 passed`. Step 1: the natural spelling is refused as the mixed shape, and the text lacked the guard. Step 2: `record.line.id != null && …` on an empty `line` is refused before evaluation with a fault that ends `no single related record`, and the text named no spelling. Both failures were on text assertions. The code, field code and constraint assertions above them passed. - **H2 holds.** Two tests were green on the unchanged source, so the text change is all this card needed: - A `conditional` rule with `when: 'record.line != null'` wrapping `record.line.kind == 'secret'` accepts an insert with no line, `line: null` and a public line. It refuses a secret line with the rule's own message (field `_record`, `rule_violation`, no `unevaluable` constraint). On update, it refuses repointing an empty order at a secret line and accepts clearing a set one. - With `required: true`, an insert with no line is refused with exactly one field error, `['line', 'required']`, because the rule is never reached. A public line is accepted and a secret line is refused by the rule. - The wrapper parses as a spec `ValidationRule` (`@objectstack/spec/data`, `safeParse` ok). Control: the same wrapper without its `message` fails with `invalid_type` at `message`. - **H3, the consumers of the formula text:** `findTraversalConflicts` has two callers. - `validateExpression` (formula) is used by `@objectstack/lint`'s `validateStackExpressions` at `rule.condition`, the only site that passes `traversalHydration: true`. Its output text changes. A probe against the built lint dist gave: the natural spelling, one error with the new text; the `.id` spelling, 0 issues (lint cannot know a reference will be empty); the wrapped rule, 0 issues. - ObjectQL's `resolveTraversalScope` passes the message through into `detail`, so the engine text changes. - Lint and objectql dists do not bundle the formula text: 0 hits for it in `packages/lint/dist` and `packages/objectql/dist`. Control: 2 hits in `packages/formula/dist`. The text ships from formula alone. - Repo-wide grep for tests asserting the old texts ("Compare the id", "no single related record", "MULTIPLE references", "make it required", "value comparison" and more): hits only in the three suites this PR edits. No lint test asserts the formula text. - **H4, where the one wording lives:** formula may not import objectql, and exporting the sentence from `@objectstack/formula` would add a public export. That would widen the public surface, so the claim's `Clause-②: no` would not hold. So the wording exists twice, and a test holds the copies equal: - formula has a module-local `referenceGuardRepair(root, field)`, not exported; - objectql keeps its landed module-local `referenceGuardRepair(field)`. - Each docblock names the other copy and the pin. `engine-predicate-relationship.test.ts` asserts one literal `GUARD` in the engine refusal worded by formula (step 1) and in the one worded by objectql (step 2). - Ablation below: mutating only the formula copy turns step 1 red and leaves step 2 green. ## Tests (at `0f4c443ac`) - `@objectstack/formula`: `vitest run` 35 files / 978 passed; `typecheck` exit 0. - `@objectstack/objectql`: - `--project local` 311 files / 5266 passed (run at `b8801356b`; since then only one test's assertions changed, and that file re-ran 42/42 at this head); - `test:repo` 5/5; - `typecheck` exit 0 (`check:test-typecheck` OK, ledger unchanged). - `@objectstack/lint` (consumer): `vitest run` 108 files / 4138 passed, with formula rebuilt. - New pins: - formula: the conflict message and the `validateExpression` refusal name the guard and `required: true`, and both halves of the guarded rule validate; - objectql rule level: the `no-reference` case and the mixed case name the guard; - objectql engine end to end: the four `#20007` cases above. - **Reverse verification:** the 2 red / 2 green run on the unchanged source is quoted under H1. - **Ablation** (through `dist`, because the objectql to formula import is an unaliased pair in `KNOWN_UNALIASED_TEST_IMPORTS`). `scripts/ablation-replace.mjs` rewrote `` whose `when` is ` `` to `IS_ABLATED` in the formula copy only: anchor 1 to 0, blob `002192dc9337` to `324bfd327a45`. After a formula build, `ablation-dist-preflight` reported the marker present in 2 built files. Results: - objectql step 1 red, steps 2 to 4 green (`1 failed | 3 passed`); - the two new formula text pins red (`2 failed | 30 passed`). - **Restore:** the blob equals HEAD and `git diff HEAD` is empty. After a rebuild, the marker is absent from all 6 built files and the tree is clean. The pins are green again: objectql 4/4, formula 32/32. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `0f4c443ac` (merge base `b3735968b`) derives 63 commands; 61 exit 0. - **NOT MEASURED** (exit 3, PREREQUISITE NOT MET): `pnpm check:dual-build-cjs-loads` and `pnpm check:type-check-debt`. Both read the whole workspace's built `dist`, which needs a full `pnpm build` that this seat did not run on the shared box. This diff changes string literals and tests only. CI builds and runs both. - `--ran`: `63 derived famil(ies) accounted for — 61 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)`. - `check:error-code-casing` first went red on a new test line (`toMatchObject({ code: 'rule_violation' })`). The assertion now uses the envelope shape the neighbouring pins use, and the gate exits 0. - `node scripts/check-issue-citations.mjs --base b373596`: exit 0, 4 citations resolve. - `pnpm check:nul-bytes`: exit 0. - Narrowed ESLint at `0f4c443ac`: `eslint --no-inline-config --format json` over the 5 changed `.ts` files gives 5 files, 0 errors and 0 warnings. `--print-config` shows each file is in the population with `parserOptions.project` and `projectService` null. `eslint.config.mjs` states that type-aware linting is never enabled, so this diff cannot move a verdict on any untouched file. The full `pnpm lint` run belongs to CI. ## Acceptance notes - The pending `.changeset/18682-predicate-relationship-traversal.md` table still reads "`record.account.id == 'acc_1'` for the value comparison". That is correct for its example, which is an id comparison and not a null test, so it is left alone. - The shape "already holds an expanded record rather than an id" in the `no-reference` arm still names no repair of its own. It is unchanged here and outside this card. - The engine's `ValidationError` carries `code: 'VALIDATION_FAILED'` and `fields`, and no `status`. The REST layer maps the status, and this PR does not change it. - Out of this PR's surface, per the claim: `requiredWhen` / `readonlyWhen` fault arms, `unevaluableRuleError`, `packages/lint/src/validate-null-guards.ts` (draft PR #20028) and `packages/spec`. - Changeset: `@objectstack/formula` patch and `@objectstack/objectql` patch. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0bf85ea commit 7465eeb

6 files changed

Lines changed: 284 additions & 5 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
'@objectstack/formula': patch
3+
'@objectstack/objectql': patch
4+
---
5+
6+
fix(formula,objectql): the two refusals a traversing validation rule on an optional lookup meets now name the repairs that work — a `conditional` wrapper or `required: true` (#20007)
7+
8+
Clause-②: no
9+
10+
An author who wants to refuse a write when an OPTIONAL lookup is set and its related record is secret writes `record.line != null && record.line.kind == 'secret'`. Two refusals then sent them in a circle:
11+
12+
1. That expression reads `line` both through the relationship and as a plain value, which cannot be served, and is refused. The refusal said to "compare the id explicitly" and write `record.line.id` for the value comparison.
13+
2. `record.line.id != null && record.line.kind == 'secret'` reads through `line` too, so an order with no line is refused before the rule is evaluated, as "no single related record". That refusal said to "guard the rule on the reference being set" and named no spelling for the guard.
14+
15+
Which writes are refused is unchanged, and so are the error, the `rule_violation` field error and its `constraint` (`reason: 'unevaluable'` and the fault). `@objectstack/lint` passes the formula refusal through unchanged, so it shows the new text too. Only the prescriptions change. Both now name the two spellings measured to work for an optional reference, and the guard is worded exactly as in the delete-cleanup refusal:
16+
17+
```text
18+
… To compare the id, write `record.line.id` for the value comparison, and keep
19+
`record.line.<related field>` for the traversal. `record.line.id` is not a null guard: it
20+
reads through `line` too, and a rule that reads through an empty `line` rejects the write
21+
instead of being skipped. If the plain value tests for empty, take that test out of this
22+
expression. To skip the rule while `line` is empty, guard it on `line` being set: make it
23+
the `then` of a `conditional` rule whose `when` is `record.line != null`. To refuse an
24+
empty `line`, make `line` required (`required: true`).
25+
```
26+
27+
```text
28+
… A predicate resolves ONE hop through a single reference. To skip the rule while `line`
29+
is empty, guard it on `line` being set: make it the `then` of a `conditional` rule whose
30+
`when` is `record.line != null` — `record.line.id != null` inside the rule is no guard, as
31+
it reads through `line` too. To refuse an empty `line`, make `line` required
32+
(`required: true`). For a multi-value reference, test it with a macro (`exists`, `size`)
33+
instead of reading through it.
34+
```
35+
36+
The repair as an author writes it, measured end to end on insert and update. It accepts an order with no line or a public line, and refuses a secret line with the rule's own message:
37+
38+
```ts
39+
validations: [{
40+
name: 'no_secret_line_when_set', type: 'conditional',
41+
message: 'Only checked while the order names a line.',
42+
when: 'record.line != null',
43+
then: { name: 'no_secret_line', type: 'script', message: 'An order may not carry a secret line.',
44+
condition: "record.line.kind == 'secret'" },
45+
}]
46+
```
47+
48+
With `required: true` on `line` instead, an order with no line is refused at the field (`required`), and the rule still judges one with a line.

‎packages/formula/src/relationship-traversal.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,25 @@ describe('findTraversalConflicts — what the authoring layer refuses', () => {
121121
expect(conflicts[0].message).toContain('record.crm_account.id');
122122
});
123123

124+
// [#20007] The plain value is often a NULL TEST on an optional reference, and
125+
// `.id` is no repair for that: it reads through the reference as well. The
126+
// refusal must name the two spellings that do work — the guard in the ONE
127+
// spelling ObjectQL's refusals use (held equal by objectql's
128+
// `engine-predicate-relationship.test.ts`, which also drives both repairs end
129+
// to end), and `required`.
130+
it('names the guard and `required` for a null test on an optional reference', () => {
131+
const a = analyzeRelationshipTraversals(
132+
"record.crm_account != null && record.crm_account.type == 'partner'",
133+
)!;
134+
const [conflict] = findTraversalConflicts(a, isLookup);
135+
expect(conflict.kind).toBe('bare-and-traversed');
136+
expect(conflict.message).toContain('`record.crm_account.id` is not a null guard');
137+
expect(conflict.message).toContain(
138+
'make it the `then` of a `conditional` rule whose `when` is `record.crm_account != null`',
139+
);
140+
expect(conflict.message).toContain('make `crm_account` required (`required: true`)');
141+
});
142+
124143
// The short-circuit form is the one shape that can evaluate today for SOME
125144
// rows (the traversal is skipped when the left arm decides the verdict) and
126145
// fault for others. It is refused for that reason, not despite it.
@@ -189,6 +208,29 @@ describe('validateExpression — refuses the unserviceable traversal shapes', ()
189208
expect(message).toContain('record.account.id');
190209
});
191210

211+
// [#20007] The author-side refusal carries the same repairs as the engine's.
212+
it('names the guard and `required` when the plain value is a null test', () => {
213+
const r = validateExpression(
214+
'predicate',
215+
"record.account != null && record.account.type == 'partner'",
216+
schema,
217+
);
218+
expect(r.ok).toBe(false);
219+
const message = r.errors.map((e) => e.message).join('\n');
220+
expect(message).toContain(
221+
'make it the `then` of a `conditional` rule whose `when` is `record.account != null`',
222+
);
223+
expect(message).toContain('make `account` required (`required: true`)');
224+
});
225+
226+
// …and the repair it names is accepted where the author writes it: the
227+
// guard's `when` reads the reference only as a value, the wrapped condition
228+
// only through it.
229+
it('accepts both halves of the guarded rule', () => {
230+
expect(validateExpression('predicate', 'record.account != null', schema).ok).toBe(true);
231+
expect(validateExpression('predicate', "record.account.type == 'partner'", schema).ok).toBe(true);
232+
});
233+
192234
it('refuses a read deeper than one hop', () => {
193235
const r = validateExpression('predicate', 'record.account.owner.email != null', schema);
194236
expect(r.ok).toBe(false);

‎packages/formula/src/relationship-traversal.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,25 @@ export type TraversalConflictKind =
221221
/** Read through more than one hop; one hop is the declared depth. */
222222
| 'multi-hop';
223223

224+
/**
225+
* [#20007] The repair that guards a rule on a reference being set.
226+
*
227+
* ⭐ The same words as ObjectQL's `referenceGuardRepair` (`rule-validator.ts`),
228+
* which its "no single related record" and delete-cleanup refusals carry: an
229+
* author meets this sentence at authoring time here and at write time there,
230+
* and two spellings of one repair read as two repairs. This package may not
231+
* import ObjectQL, and exporting the wording from here would publish a sentence
232+
* as API, so the two copies are held equal by a test instead:
233+
* `packages/objectql/src/engine-predicate-relationship.test.ts` drives the
234+
* engine's refusals from both sources and asserts one literal in each.
235+
*
236+
* Measured there end to end: the wrapped rule is skipped while the reference is
237+
* empty and judged as before once it is set.
238+
*/
239+
function referenceGuardRepair(root: string, field: string): string {
240+
return `make it the \`then\` of a \`conditional\` rule whose \`when\` is \`${root}.${field} != null\``;
241+
}
242+
224243
/** One refusal-worthy finding about one field. */
225244
export interface TraversalConflict {
226245
readonly field: string;
@@ -250,6 +269,12 @@ export function findTraversalConflicts(
250269
for (const field of analysis.traversals.keys()) {
251270
if (!isReferenceField(field)) continue;
252271
if (!analysis.bareFields.has(field)) continue;
272+
// [#20007] The plain value is often a NULL TEST on an optional reference
273+
// (`record.line != null && record.line.kind == 'secret'`), and `.id` is no
274+
// repair for that intent: it reads through the reference as well, so an
275+
// empty reference still leaves the rule nothing to read and the write is
276+
// rejected. So the id comparison is named for what it is, and the two
277+
// spellings measured to work for the null test are named beside it.
253278
conflicts.push({
254279
field,
255280
kind: 'bare-and-traversed',
@@ -259,8 +284,14 @@ export function findTraversalConflicts(
259284
+ `(\`${root}.${field}\`) in the same expression. Reading through the `
260285
+ `relationship resolves \`${root}.${field}\` to the related RECORD, so the `
261286
+ `plain-value comparison would stop matching the stored id — silently. `
262-
+ `Compare the id explicitly: write \`${root}.${field}.id\` for the value `
263-
+ `comparison, and keep \`${root}.${field}.<related field>\` for the traversal.`,
287+
+ `To compare the id, write \`${root}.${field}.id\` for the value `
288+
+ `comparison, and keep \`${root}.${field}.<related field>\` for the traversal. `
289+
+ `\`${root}.${field}.id\` is not a null guard: it reads through \`${field}\` `
290+
+ `too, and a rule that reads through an empty \`${field}\` rejects the write `
291+
+ `instead of being skipped. If the plain value tests for empty, take that test `
292+
+ `out of this expression. To skip the rule while \`${field}\` is empty, guard `
293+
+ `it on \`${field}\` being set: ${referenceGuardRepair(root, field)}. To refuse `
294+
+ `an empty \`${field}\`, make \`${field}\` required (\`required: true\`).`,
264295
});
265296
}
266297

‎packages/objectql/src/engine-predicate-relationship.test.ts‎

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -735,3 +735,139 @@ describe('#20006 — a cascade reference clear refused by a traversing rule says
735735
}
736736
});
737737
});
738+
739+
// ---------------------------------------------------------------------------
740+
// [#20007] An OPTIONAL lookup a traversing rule must skip while it is empty.
741+
//
742+
// The intent: refuse an order whose line is secret, and say nothing about an
743+
// order with no line. Both refusals on the way used to send the author in a
744+
// circle. The natural spelling, `record.line != null && record.line.kind ==
745+
// 'secret'`, is refused as a reference read both through the relationship and
746+
// as a value, and that refusal prescribed `record.line.id` — which is no null
747+
// guard: with `line` empty the engine refuses the rule before evaluation as
748+
// "no single related record", whose own prescription named no spelling at all.
749+
// The repairs that work are a `conditional` wrapper whose `when` is
750+
// `record.line != null`, or `required: true` on the lookup. Both refusals now
751+
// name them, in the ONE guard spelling the delete-cleanup refusal above uses,
752+
// and the wrapped rule is driven end to end here.
753+
// ---------------------------------------------------------------------------
754+
755+
describe('#20007 — an optional lookup guarded in a traversing rule', () => {
756+
const SECRET_MESSAGE = 'An order may not carry a secret line.';
757+
/**
758+
* The guard spelling every prescription names, byte for byte. ⭐ Step 1's
759+
* refusal is worded by `@objectstack/formula` and step 2's by this package's
760+
* `referenceGuardRepair`; formula may not import ObjectQL, so the words exist
761+
* twice, and THIS literal asserted in both is what holds the copies equal.
762+
*/
763+
const GUARD = 'make it the `then` of a `conditional` rule whose `when` is `record.line != null`';
764+
const script = (condition: string) => ({
765+
name: 'no_secret_line', type: 'script', severity: 'error', message: SECRET_MESSAGE, condition,
766+
});
767+
/** The natural spelling: a null test and a traversal in one expression. */
768+
const natural = script("record.line != null && record.line.kind == 'secret'");
769+
/** The spelling the mixed-shape refusal used to prescribe. */
770+
const idTest = script("record.line.id != null && record.line.kind == 'secret'");
771+
/** The repair, as an author writes it from the prescription. */
772+
const wrapped = {
773+
name: 'no_secret_line_when_set', type: 'conditional', severity: 'error',
774+
message: 'Only checked while the order names a line.',
775+
when: 'record.line != null', then: script("record.line.kind == 'secret'"),
776+
};
777+
778+
async function boot(validations: unknown[], line: Record<string, unknown> = {}) {
779+
const engine = new ObjectQL();
780+
const d = makeDriver();
781+
engine.registerDriver(d.driver, true);
782+
await engine.init();
783+
engine.registry.registerObject({
784+
name: 'qa_line', fields: { name: { type: 'text' }, kind: { type: 'text' } },
785+
} as any, 'test-package');
786+
engine.registry.registerObject({
787+
name: 'qa_order',
788+
fields: {
789+
name: { type: 'text' },
790+
// OPTIONAL unless a case says otherwise.
791+
line: { type: 'lookup', reference: 'qa_line', ...line },
792+
},
793+
validations,
794+
} as any, 'test-package');
795+
d.storeFor('qa_line').set('line_secret', { id: 'line_secret', name: 'S', kind: 'secret' });
796+
d.storeFor('qa_line').set('line_public', { id: 'line_public', name: 'P', kind: 'public' });
797+
const insert = (data: Record<string, unknown>) => engine
798+
.insert('qa_order', { name: 'O', ...data }, { context: { isSystem: true } } as any)
799+
.then(() => null, (e: unknown) => e as any);
800+
return { engine, d, insert };
801+
}
802+
803+
it('THE CIRCLE, step 1: the natural spelling is refused, and the refusal names the guard and `required`', async () => {
804+
const { insert } = await boot([natural]);
805+
const err = await insert({ line: 'line_public' });
806+
expect(err?.code).toBe('VALIDATION_FAILED');
807+
expect(err.fields).toHaveLength(1);
808+
expect(err.fields[0]).toMatchObject({ field: '_record', code: 'rule_violation' });
809+
expect(err.fields[0].constraint).toEqual({
810+
rule: 'no_secret_line', reason: 'unevaluable',
811+
fault: 'reads a reference field both through the relationship and as a value',
812+
});
813+
const message: string = err.message;
814+
expect(message).toContain('`record.line.id`'); // still the id comparison
815+
expect(message).toContain('not a null guard'); // …which it says is no guard
816+
expect(message).toContain(GUARD); // repair 1
817+
expect(message).toContain('make `line` required'); // repair 2
818+
// ⛔ never the rule's own verdict: the rule was not evaluated.
819+
expect(message).not.toContain(SECRET_MESSAGE);
820+
});
821+
822+
it('THE CIRCLE, step 2: the `.id` spelling is no guard — an empty line is refused before evaluation, and that refusal names the guard too', async () => {
823+
const { insert } = await boot([idTest]);
824+
const err = await insert({});
825+
expect(err?.code).toBe('VALIDATION_FAILED');
826+
expect(err.fields).toHaveLength(1);
827+
expect(err.fields[0]).toMatchObject({ field: '_record', code: 'rule_violation' });
828+
expect(err.fields[0].constraint).toEqual({
829+
rule: 'no_secret_line', reason: 'unevaluable',
830+
fault: "cannot read 'id', 'kind' through `line` (object 'qa_line'): no single related record",
831+
});
832+
const message: string = err.message;
833+
expect(message).toContain(GUARD);
834+
expect(message).toContain('make `line` required');
835+
expect(message).toContain('`record.line.id != null` inside the rule is no guard');
836+
// CONTROL: the same rule is judged normally once the line is set.
837+
expect(await insert({ line: 'line_public' })).toBe(null);
838+
expect((await insert({ line: 'line_secret' }))?.message).toBe(SECRET_MESSAGE);
839+
});
840+
841+
it('THE REPAIR: the wrapped rule ACCEPTS an empty line and a public one, and REFUSES a secret one with its own message', async () => {
842+
const { insert, engine, d } = await boot([wrapped]);
843+
expect(await insert({ id: 'o_empty' })).toBe(null);
844+
expect(await insert({ id: 'o_null', line: null })).toBe(null);
845+
expect(await insert({ id: 'o_public', line: 'line_public' })).toBe(null);
846+
const err = await insert({ id: 'o_secret', line: 'line_secret' });
847+
expect(err?.code).toBe('VALIDATION_FAILED');
848+
expect(err.fields).toHaveLength(1);
849+
// The nested rule's own verdict: a violation, not an unevaluable fault.
850+
expect(err.fields[0]).toMatchObject({ field: '_record', code: 'rule_violation', message: SECRET_MESSAGE });
851+
expect(err.fields[0].constraint?.reason).toBeUndefined();
852+
expect(err.message).toBe(SECRET_MESSAGE);
853+
expect(d.storeFor('qa_order').has('o_secret')).toBe(false);
854+
// The UPDATE door: repointing an empty order at a secret line is refused,
855+
// and emptying a set one is accepted.
856+
const update = (id: string, patch: Record<string, unknown>) => engine
857+
.update('qa_order', { id, ...patch }, { context: { isSystem: true } } as any)
858+
.then(() => null, (e: unknown) => e as any);
859+
expect((await update('o_empty', { line: 'line_secret' }))?.message).toBe(SECRET_MESSAGE);
860+
expect(await update('o_public', { line: null })).toBe(null);
861+
expect(d.storeFor('qa_order').get('o_public')?.line).toBe(null);
862+
});
863+
864+
it('THE OTHER REPAIR: with `required: true` an empty line is refused at the FIELD, and a set one is judged by the rule', async () => {
865+
const { insert } = await boot([script("record.line.kind == 'secret'")], { required: true });
866+
const empty = await insert({});
867+
expect(empty?.code).toBe('VALIDATION_FAILED');
868+
// Only the field's own refusal: the rule is never reached on this write.
869+
expect(empty.fields.map((f: any) => [f.field, f.code])).toEqual([['line', 'required']]);
870+
expect(await insert({ line: 'line_public' })).toBe(null);
871+
expect((await insert({ line: 'line_secret' }))?.message).toBe(SECRET_MESSAGE);
872+
});
873+
});

‎packages/objectql/src/validation/rule-relationship-traversal.test.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,8 @@ describe('#18682 — the engine refuses the unserviceable shape, not only lint',
248248
const detail = JSON.stringify((e as unknown as { errors?: unknown }).errors ?? (e as Error).message);
249249
expect(detail).toContain('could not be evaluated');
250250
expect(detail).toContain('record.account.id');
251+
// [#20007] …and, for a null test, the guard the engine's other refusals name.
252+
expect(detail).toContain('`conditional` rule whose `when` is `record.account != null`');
251253
// ⛔ and never the rule's own message — the rule produced NO verdict.
252254
expect(detail).not.toContain('should never be reached');
253255
}
@@ -272,7 +274,12 @@ describe('#18682 — the refusal names the RELATED object, not the referencing o
272274
const cases: Array<[string, ReturnType<typeof unavailable>, string[]]> = [
273275
['read failed', unavailable('unreadable'), ['could not read', "'crm_account'"]],
274276
['undeclared related field', unavailable('undeclared-field', ['type']), ['declares no', "'type'"]],
275-
['no reference stored', unavailable('no-reference'), ['no single related record', 'MULTIPLE references']],
277+
// [#20007] …and the two repairs measured to work for an EMPTY reference: the
278+
// guard in `referenceGuardRepair`'s spelling, and `required`.
279+
['no reference stored', unavailable('no-reference'), [
280+
'no single related record', 'MULTIPLE references',
281+
'`conditional` rule whose `when` is `record.account != null`', 'make `account` required',
282+
]],
276283
['related record not found', unavailable('unresolved'), ["'crm_account'", 'the related record was not found']],
277284
];
278285

0 commit comments

Comments
 (0)