Skip to content

Commit 45bec43

Browse files
fix(lint,objectql)!: a formula reading a field its lookup target does not declare is refused at save, and no longer nulls its sibling formulas (#24466)
Fixes #24410 Clause-②: no (narrowing) (the save door newly refuses a formula it accepted; owes one `CONTRACT_REVIEW_TIER` review, on the narrowing face, before the queue) Dispatched by the `domain:engine` seat 1 PM (claim 6258777300). Developer session: `session_01SE5iTKQC8i5LBRywMuQZmt`. ## What changes **Save refusal (lint, `judgeFieldFormula`).** The field-formula pass now judges the field a formula reads one hop through a reference against the target object's field index: its declared fields, the system columns the platform injects on it, and `id`. A read of a field the target does not declare is refused under the existing `EXPRESSION_INVALID` rule (`expression-invalid`). No new code. The message opens in the shared validator's `unknown field` words and names the field and the target: "unknown field `name` on `rhf_book`: this formula reads it through the reference `record.book` ...". The same pass runs for `os validate`, `os build` and the object save door. - At the save door the snapshot carries the live registry's objects in the written package's closure. So a target from a package the written one depends on is judged, and a system object is always judged. - A target outside the snapshot is not judged. That is a missed catch, never a false refusal. - A hop into the stored-metadata-body family stays with `refuseStoredMetadataBodyHop` and is not judged twice. **Sibling isolation (objectql, `resolveFormulaRelated`).** Measured cause: no driver read failed. The formula read merged every formula's hop columns per reference. The shared one-hop loader then answered the whole reference `undeclared-field` when any column was undeclared. So the valid sibling read `null` and reported the bad formula's field: "the formula reads 'name' through `book`". The fix: - `resolveFormulaRelated` takes the undeclared columns out of the shared read. - `formulaHopScope` judges them per formula. - A bad formula reads `null` with its existing `undeclared-field` fault report, which now names only its own columns. - A valid sibling is served from the same single read, exactly as it is alone. - The predicate seam's use of the loader is byte-for-byte unchanged. The declared-field test moved into one helper, `undeclaredRelatedFields`, that both seams share. **The WARN text.** "a re-save of the object, refuses it with a located message" now holds for this fault: a re-save of the object answers 422 with the located refusal (measured below). Left unchanged. ⛔ No change to which records a formula may read: the #24316 / #24358 family guards are untouched. ## Verification (M, booted composition: `bootStack`, real ObjectQL, SQL driver, REST, auth, metadata) Pin: `packages/qa/dogfood/test/formula-hop-undeclared-related-field.dogfood.test.ts`. The fixture is `rhf_book` (title), `rhf_reader` (reader_name) and `rhf_loan`. The loan has a valid `summary` formula (`joinNonEmpty([record.book.title, record.reader.reader_name], ' · ')`) and a code-declared bad `bad_title` (`record.book.name`). | Measurement | Before (`main` `db1f1fd`) | After (head) | |---|---|---| | `PUT /meta/object/rhf_loan_saved` with `record.book.name` | **200** `Saved object 'rhf_loan_saved'` | **422** `INVALID_METADATA`, one issue: rule `expression-invalid`, where `object 'rhf_loan_saved' · field 'summary' expression`, message `unknown field \`name\` on \`rhf_book\` ...` | | Control: the same save with `record.book.title` | 200 | 200 | | Control: `GET /data/rhf_loan?fields=summary` (valid alone) | `RHFBOOK71 · RHFREADER72` | `RHFBOOK71 · RHFREADER72` | | `GET /data/rhf_loan`: `summary` / `bad_title` | `null` / `null`, and WARN on `summary`: "reads 'name' through `book`" | `RHFBOOK71 · RHFREADER72` / `null`, with WARN on `bad_title` only | | `GET /data/rhf_loan/:id`: `summary` / `bad_title` | `null` / `null` | value / `null` | Unit pins: - `engine-formula-one-hop.test.ts`: the sibling is served as alone, ONE book read with fields `id,title`, and only the bad formula reports. - `validate-expressions.formula-one-hop.test.ts`: the refusal names the field and the target. `id` and an injected column (`created_at`) are admitted. A target the stack does not carry is not judged. - `runtime-gate.object-formula-writes.test.ts`: at the door, the target comes from another package the written one depends on. The refusal is LIT and the declared-field read is the CONTROL. **Ablations** (`scripts/ablation-replace.mjs` wrap form, then `scripts/ablation-dist-preflight.mjs`). Each restore was checked against the HEAD blob with an empty `git diff HEAD`. - **Refusal.** In `refuseUndeclaredHopField`, `if (column === 'id' || ...) continue;` became an always-continue carrying the marker `ABLATED_24410`. - lint build exit 0. Marker present in 4 `dist/` files. - Red: 2 lint pins (`refuses a read of a field the target does not declare`, the door `LIT`), and the booted save case. - The first attempt deleted the call instead. The lint build then exited 1 on the unused closure, so it was redone. The pins were red on that attempt too. - **Isolation.** In `formulaHopScope`, `own` became every undeclared column of the reference, not this formula's own. - objectql build exit 0. Marker in 4 `dist/` files. - Red: the objectql sibling pin and the booted sibling case. - The booted save case was also red on this run, because ablation 1's restore had not rebuilt `lint/dist` yet. That redness belongs to ablation 1. - **Restore legs.** lint and objectql rebuilt (exit 0). `--absent` checks: marker absent from all 20 lint and all 14 objectql built files. Booted pin 3/3. **Corpus.** No formula field the repository ships reads through a reference: 28 formula fields across examples (app-crm, app-showcase, app-todo) and platform display titles. So the pass refuses nothing it accepted in-tree. ## Tests and gates The full package suites ran on `2293b1c45`, after merging `origin/main` `9112bcd88`: - `pnpm --filter @objectstack/lint exec vitest run`: 145 files, 6869 tests passed. - `pnpm --filter @objectstack/objectql exec vitest run`: 444/445 files passed. The one red was the federated-reader census, which needed a row for the new `referenceTargetOf()` use. Row added (`caller-predicate`); that file plus the one-hop file then passed 31/31. Then `origin/main` `fa26308bb` was merged in (head `313ee1040`). It brings #24423, which touches `objectql/hook-binder.ts`: the hook body's `ctx.title`, which `serveFormulaField` → `resolveFormulaRelated` serves. It also moves `packages/spec`. On `313ee1040`: - The closure was rebuilt. - typecheck: `@objectstack/lint` 0, `@objectstack/objectql` 0, `@objectstack/dogfood` 0. - `pnpm --filter @objectstack/spec check:generated`: exit 0. - objectql one-hop, hook-binder wrap-handler and census files: 34/34 passed. - Booted pin: 3/3. Derived gates: `node scripts/pm/dispatch-gates.mjs --commands` (no paths) on `313ee1040` gives 75 commands. Each was run with its exit code recorded, and all 75 exited 0. - `check:dual-build-cjs-loads` exited 3 on the earlier head (8 packages outside the dogfood closure had no `dist/`). They were built, and it exits 0. - `--ran` verdict: "75 derived, 75 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero — all 75 recorded an exit code and none of them is 3)". ## Diff (+/−) | File | + | − | |---|---|---| | `.changeset/24410-lint-formula-hop-undeclared-field.md` | 19 | 0 | | `.changeset/24410-objectql-formula-hop-sibling-isolation.md` | 17 | 0 | | `packages/lint/src/validate-expressions.ts` | 56 | 1 | | `packages/lint/src/validate-expressions.formula-one-hop.test.ts` | 28 | 0 | | `packages/lint/src/runtime-gate.object-formula-writes.test.ts` | 40 | 0 | | `packages/objectql/src/engine.ts` | 70 | 8 | | `packages/objectql/src/engine-formula-one-hop.test.ts` | 18 | 0 | | `packages/objectql/src/federated-injected-column-readers.test.ts` | 6 | 0 | | `packages/qa/dogfood/test/formula-hop-undeclared-related-field.dogfood.test.ts` | 139 | 0 | File surface: within the claim, plus two pin files the measurement required. - `runtime-gate.object-formula-writes.test.ts` holds the door pin with a cross-package target. - `federated-injected-column-readers.test.ts` holds the census row. It reds on any new engine `referenceTargetOf()` use. - The booted pin lives in `packages/qa/dogfood`, to be declared to `domain:cli` at review. ## Acceptance notes - **The predicate seam has the same union verdict (read-only inference, not measured at a door).** `resolvePredicateRelated` hands the shared loader every rule's hop columns per reference. So one validation rule naming a field its target does not declare leaves every rule through that reference without a verdict. The lint added here judges formula `expression`s only, not hydrated rule `condition`s. The fence leaves that seam unchanged. Dedupe words: validation rule undeclared related field, predicate hop sibling refusal, rule traversal related field existence. - `content/docs/data-modeling/formulas.mdx`'s "Field must exist" row does not mention the one-hop read. It does not misstate the new behaviour, so it was left alone. --- _Generated by [Claude Code](https://claude.ai/code/session_01SE5iTKQC8i5LBRywMuQZmt)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ce2e08e commit 45bec43

10 files changed

Lines changed: 398 additions & 9 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
fix(lint)!: a formula that reads, through a reference, a field the reference's target does not declare is refused where it is written
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No spec key, stored shape or authorable spelling is added, removed or renamed. The field-formula pass now refuses one expression shape it accepted before: a one-hop read of a field the reference's target does not declare, which the runtime never computed a value for (the formula read null on every row). Nothing stored changes, so there is nothing for `objectstack migrate meta` to rewrite. -->
10+
11+
**BREAKING** (an authoring narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
The field-formula pass judges the field a formula reads one hop through a reference against the target object's fields: the fields it declares, the system columns the platform adds to it, and `id`. `os validate`, `os build` and the object save door (`PUT /api/v1/meta/object/:name`) run the same pass, under the existing `EXPRESSION_INVALID` rule.
14+
15+
- FROM: `record.book.name`, where `book` is a lookup to an object that declares no `name`, saved with a 200 and read `null` on every row. TO: refused (`422 INVALID_METADATA` at the save door). The message names the field and the target: "unknown field `name` on `<target>`".
16+
- `record.book.title`, where the target declares `title`, passes as before.
17+
- A target the stack does not carry is not judged. At the save door the stack carries the live registry's objects in the written package's closure, so a target from a package it depends on is judged.
18+
19+
What to do. Read a field the target declares, or declare the field on the target object. Do not declare it on the object that carries the formula. At `os build` and `os validate` this pass does not see a target field that an `objectExtensions` entry contributes, while the save door judges the merged object.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): a formula reading a field its reference's target does not declare no longer makes the other formulas reading through the same reference read null
6+
7+
Clause-②: no
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author writes, and nothing stored, is added, removed or renamed. A valid formula that read null beside a misspelt sibling now reads its value; no spec key, export, option, object definition or stored shape changes, so there is nothing for `objectstack migrate meta` to rewrite. -->
10+
11+
The formula read loads, once per page, every column the object's formulas read through each reference. When one formula named a field the target does not declare, the whole reference was answered as undeclared. Every formula reading through it then read `null` and reported the other formula's field.
12+
13+
- The undeclared fields are left out of that shared read and judged for each formula on its own.
14+
- A formula that names one reads `null`, with a fault report naming only its own undeclared fields.
15+
- A formula that reads only declared fields through the same reference gets the value it gets when read alone (`?fields=`).
16+
17+
What to do. Nothing. A formula that read `null` this way now reads its value. To find the formula at fault, re-save the object: the save door refuses the misspelt read and names the field.

‎packages/core/src/security/second-object-read-exposure.pin.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,11 @@ const CALLERS: Record<string, Classification> = {
201201
kind: 'not-a-served-read',
202202
reason: 'authoring-time static analysis of metadata; reads no record',
203203
},
204+
// [#24410] A formula's one-hop read judged against its target's declared fields.
205+
'packages/lint/src/validate-expressions.ts': {
206+
kind: 'not-a-served-read',
207+
reason: 'authoring-time static analysis of metadata; reads no record',
208+
},
204209
'packages/metadata-protocol/src/seed-loader.ts': {
205210
kind: 'not-a-served-read',
206211
reason: "resolves a seed's references at install, under the system identity; serves nothing to a caller",

‎packages/lint/src/runtime-gate.object-formula-writes.test.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,46 @@ describe('#22019 — the object door dispatches the build\'s expression rule', (
117117
});
118118
});
119119

120+
describe('#24410 — the object door refuses a formula hop to a field the target does not declare', () => {
121+
/** The lookup's target, delivered by ANOTHER package the written one depends on. */
122+
const book = {
123+
name: 'fx_book', label: 'Book', sharingModel: 'private', _packageId: 'com.example.library',
124+
fields: { title: { type: 'text', label: 'Title' } },
125+
};
126+
const loan = (expression: string) => ({
127+
name: 'fx_loan',
128+
label: 'Loan',
129+
sharingModel: 'private',
130+
fields: {
131+
book: { type: 'lookup', label: 'Book', reference: 'fx_book' },
132+
summary: { type: 'formula', label: 'Summary', expression },
133+
},
134+
});
135+
const gateLoan = (expression: string) => runRuntimeAuthoringRules({
136+
type: 'object',
137+
item: loan(expression),
138+
context: { objects: [book] },
139+
packageScope: { packageId: 'com.example.loans', dependencies: ['com.example.library'] },
140+
});
141+
const LOAN_WHERE = "object 'fx_loan' · field 'summary' expression";
142+
143+
it('⭐ LIT — the read is REFUSED under EXPRESSION_INVALID, naming the field and the target from the other package', () => {
144+
const result = gateLoan('record.book.name');
145+
146+
const errs = expressionFindings(result.errors);
147+
expect(errs, dump(result)).toHaveLength(1);
148+
expect(errs[0]).toMatchObject({ severity: 'error', where: LOAN_WHERE, path: LOAN_WHERE });
149+
expect(errs[0]!.message).toContain('unknown field `name` on `fx_book`');
150+
});
151+
152+
it('⭐ CONTROL — a read of a field the target declares publishes clean', () => {
153+
const result = gateLoan('record.book.title');
154+
155+
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
156+
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
157+
});
158+
});
159+
120160
describe('#22019 / #22032 — every pass over the object\'s body judges on this door, and no pass over another collection', () => {
121161
/**
122162
* One body carrying a fault in EVERY pass over the object's own body — the

‎packages/lint/src/validate-expressions.formula-one-hop.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,34 @@ describe('a formula expression reads one hop through a reference (#24197)', () =
8787
expect(errors[0].message).toContain('`record.reader` is read BOTH through the relationship');
8888
expect(errors[0].message).toContain("`record.reader.?<related field>.orValue('…')`");
8989
});
90+
91+
it('[#24410] refuses a read of a field the target does not declare, naming the field and the target', () => {
92+
const errors = errorsOf("record.book.name + ' · ' + record.reader.name");
93+
expect(errors).toHaveLength(1);
94+
expect(errors[0].message).toContain('unknown field `name` on `hop_book`');
95+
expect(errors[0].message).toContain('through the reference `record.book`');
96+
expect(errors[0].message).toContain("declare 'name' on 'hop_book' — not on 'hop_loan'");
97+
expect(errors[0].source).toBe("record.book.name + ' · ' + record.reader.name");
98+
});
99+
100+
it('[#24410] admits the primary key and a column the platform injects on the target', () => {
101+
expect(errorsOf('record.book.id')).toEqual([]);
102+
expect(errorsOf('string(record.book.created_at) + record.book.title')).toEqual([]);
103+
});
104+
105+
it('[#24410] does not judge a target the stack does not carry', () => {
106+
const stray = {
107+
name: 'hop_loan',
108+
label: 'Loan',
109+
fields: {
110+
book: { type: 'lookup', label: 'Book', reference: 'hop_elsewhere' },
111+
title: { type: 'formula', label: 'Title', expression: 'record.book.name' },
112+
},
113+
};
114+
const errors = validateStackExpressions(specValid({ objects: [book, shelf, reader, stray] }))
115+
.filter((i) => i.where === WHERE && (i.severity ?? 'error') === 'error');
116+
expect(errors).toEqual([]);
117+
});
90118
});
91119

92120
/**

‎packages/lint/src/validate-expressions.ts‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ import type { FlowNodeParsed, FlowEdgeParsed } from '@objectstack/spec/automatio
115115
// hand-written notion of "blank" here — that drift is what #15662 built the
116116
// shared refusal to prevent.
117117
import { EvaluatedExpressionInputSchema, EVALUATED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec/shared';
118-
import { referenceCarrierOf, REFERENCE_VALUE_TYPES } from '@objectstack/spec/data';
118+
import { referenceCarrierOf, referenceTargetOf, REFERENCE_VALUE_TYPES } from '@objectstack/spec/data';
119119
import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel';
120120
// [#22394] The acting user's declared members — the contract half of the option
121121
// verdict's member allowlist for `current_user` and its ADR-0068 aliases. Read
@@ -1768,6 +1768,20 @@ function storedMetadataBodyHopMessage(
17681768
+ 'stored id, if the id is what you need.';
17691769
}
17701770

1771+
/**
1772+
* [#24410] The refusal of a formula's one-hop read of a field the reference's
1773+
* target does not declare. It opens in the shared validator's `unknown field`
1774+
* words, so the two first-segment and second-segment verdicts read as one
1775+
* family, and names the read, the target and where the field would have to be
1776+
* declared — never on the object carrying the formula.
1777+
*/
1778+
function undeclaredHopFieldMessage(objectName: string, field: string, target: string, column: string): string {
1779+
return `unknown field \`${column}\` on \`${target}\`: this formula reads it through the reference `
1780+
+ `${celMemberPath('record', field)}, and '${target}' declares no field '${column}', so the formula reads `
1781+
+ `null on every row. Read a field '${target}' declares, or declare '${column}' on '${target}' — not on `
1782+
+ `'${objectName}', the object carrying this formula.`;
1783+
}
1784+
17711785
/**
17721786
* [#17495] Why a structural condition whose SHAPE is fine is still not
17731787
* authorable: its source is blank. The author-time twin of the gate #17322 put
@@ -1948,6 +1962,45 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
19481962
}
19491963
};
19501964

1965+
/**
1966+
* [#24410] Refuse a formula's one-hop read of a field the reference's target
1967+
* does not declare (`record.book.name` where the book declares no `name`).
1968+
* ObjectQL's one-hop loader reads only declared columns of the target, so as
1969+
* written the formula reads null on every row; this says so where it is
1970+
* written, at the build and at the object save door alike.
1971+
*
1972+
* Judged against the target's field index — its declared fields and the
1973+
* system columns the platform injects on it, the set the registry carries —
1974+
* and the primary key, which no field map carries and the loader reads. A
1975+
* target outside the stack (or the door's snapshot, which carries the live
1976+
* registry's objects, another package's included) has no index here and is
1977+
* not judged; a hop into the stored-metadata-body family is refused by
1978+
* {@link refuseStoredMetadataBodyHop} and not judged twice.
1979+
*/
1980+
const refuseUndeclaredHopField = (where: string, raw: unknown, objectName: string): void => {
1981+
const source = celSourceOf(raw);
1982+
if (!source) return;
1983+
const traversals = analyzeRelationshipTraversals(source, 'record')?.traversals;
1984+
if (!traversals || traversals.size === 0) return;
1985+
for (const obj of objects) {
1986+
if (obj.name !== objectName) continue;
1987+
for (const [field, def] of fieldEntries(obj)) {
1988+
const columns = traversals.get(field);
1989+
const target = columns ? referenceTargetOf(def)?.trim() : undefined;
1990+
if (!columns || !target || isStoredMetadataBodyObject(target) || !fieldIndex.has(target)) continue;
1991+
for (const column of [...columns].sort()) {
1992+
if (column === 'id' || fieldIndex.get(target)?.includes(column)) continue;
1993+
issues.push({
1994+
where,
1995+
message: undeclaredHopFieldMessage(objectName, field, target, column),
1996+
source,
1997+
severity: 'error',
1998+
});
1999+
}
2000+
}
2001+
}
2002+
};
2003+
19512004
// [#8116] object name → the injected anchors registered with NO storage
19522005
// behind them (non-empty only for ADR-0015 `external` objects). The other
19532006
// half of {@link buildFieldIndex}'s #5378 widening: the anchors ARE
@@ -2848,6 +2901,8 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
28482901
for (const e of res.errors) issues.push({ where: fieldWhere, message: e.message, source: e.source, severity: 'error' });
28492902
for (const w of res.warnings) issues.push({ where: fieldWhere, message: w.message, source: w.source, severity: 'warning' });
28502903
if (objectName) refuseStoredMetadataBodyHop(fieldWhere, f.expression, objectName, 'formula');
2904+
// [#24410] And a hop to a field the target does not declare.
2905+
if (objectName) refuseUndeclaredHopField(fieldWhere, f.expression, objectName);
28512906
// [#8116] Formulas read the same record binding the predicates do, so
28522907
// a `record.<anchor>` read inside one degrades identically.
28532908
warnUnprovisionedAnchors(fieldWhere, f.expression, objectName);

‎packages/objectql/src/engine-formula-one-hop.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,24 @@ describe('#24197 — a formula field reads one hop through a reference', () => {
289289
expect(row?.title).toBeNull();
290290
expect(faultsOn('lib_loan', 'title')[0].message).toContain("'lib_book' declares no such field");
291291
});
292+
293+
it('[#24410] an undeclared related field nulls only its own formula: a sibling through the same reference is served as it is alone', async () => {
294+
registerLoan(TITLE, { bad: { type: 'formula', expression: 'record.book.isbn' } });
295+
const alone = await engine.find('lib_loan', { fields: ['title'], limit: 4, context: READER });
296+
d.calls.length = 0;
297+
const rows = await engine.find('lib_loan', { limit: 4, context: READER });
298+
expect(rows.map((r) => r.title)).toEqual(alone.map((r) => r.title));
299+
expect(rows.map((r) => r.title)).toEqual(['Dune · Ann', 'Emma · Ann', 'Ulysses · Bo', 'Dune · Bo']);
300+
expect(rows.map((r) => r.bad)).toEqual([null, null, null, null]);
301+
// The undeclared column never enters the shared read; ONE book read still serves the page.
302+
expect(relatedReads('lib_book')).toBe(1);
303+
expect([...d.calls.find((c) => c.object === 'lib_book')!.ast.fields].sort()).toEqual(['id', 'title']);
304+
// Each formula's own report: the bad one names its column, the sibling reports nothing.
305+
expect(faultsOn('lib_loan', 'title')).toEqual([]);
306+
const fault = faultsOn('lib_loan', 'bad');
307+
expect(fault).toHaveLength(1);
308+
expect(fault[0].message).toContain("the formula reads 'isbn' through `book` (object 'lib_book')");
309+
});
292310
});
293311

294312
describe('one hop is the depth', () => {

‎packages/objectql/src/engine.ts‎

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2651,8 +2651,36 @@ type FormulaFaultSink = (field: string, error: EvalError) => void;
26512651
* ({@link ObjectQL.resolveFormulaRelated}), or — when this read serves no hop
26522652
* at all — the sentence saying why, which every traversing formula of the read
26532653
* then reports.
2654+
*
2655+
* [#24410] `undeclared` is decided per FORMULA, never per reference: the
2656+
* related fields a reference's target does not declare, which the shared read
2657+
* never asks for. A formula naming one of them reads `null` with the
2658+
* `undeclared-field` reason; a sibling reading only declared fields through the
2659+
* same reference is served from the shared read exactly as it is alone.
2660+
*/
2661+
type FormulaRelated = {
2662+
readonly bindings: (row: Record<string, unknown>) => RelatedRecordBinding | undefined;
2663+
readonly undeclared: ReadonlyMap<string, { readonly object: string; readonly fields: ReadonlySet<string> }>;
2664+
} | string;
2665+
2666+
/**
2667+
* [#24410] The named related fields a reference's target does not DECLARE —
2668+
* the one reading both seams that serve a hop make of it: the shared loader
2669+
* ({@link ObjectQL.resolveOneHopRelated}) and the formula read, which judges
2670+
* them per formula ({@link ObjectQL.resolveFormulaRelated}).
2671+
*
2672+
* [#8215] The PRIMARY KEY counts as declared, though no field map carries it:
2673+
* `record.<fk>.id` is the repair the traversal conflict refusal prescribes. An
2674+
* unknown target (`declared` absent) declares nothing to judge against, so
2675+
* nothing is undeclared.
26542676
*/
2655-
type FormulaRelated = ((row: Record<string, unknown>) => RelatedRecordBinding | undefined) | string;
2677+
function undeclaredRelatedFields(
2678+
declared: Record<string, unknown> | undefined,
2679+
named: Iterable<string>,
2680+
): string[] {
2681+
if (!declared) return [];
2682+
return [...named].filter((n) => n !== SystemFieldName.ID && !(n in declared));
2683+
}
26562684

26572685
/** [#24197] Why a hop was not served where no reader supplied one. */
26582686
const FORMULA_HOP_NOT_SERVED =
@@ -2687,8 +2715,22 @@ function formulaHopScope(
26872715
hops: ReadonlyMap<string, ReadonlySet<string>>,
26882716
related: FormulaRelated | undefined,
26892717
): { ok: true; record: Record<string, unknown>; empty: string[] } | { ok: false; reason: string } {
2690-
if (typeof related !== 'function') return { ok: false, reason: related ?? FORMULA_HOP_NOT_SERVED };
2691-
const binding = related(rec);
2718+
if (related === undefined || typeof related === 'string') {
2719+
return { ok: false, reason: related ?? FORMULA_HOP_NOT_SERVED };
2720+
}
2721+
// [#24410] This formula's own undeclared reads first, before any row: they
2722+
// are a fault of the expression, whatever the record holds.
2723+
for (const [field, named] of hops) {
2724+
const missing = related.undeclared.get(field);
2725+
const own = missing ? [...named].filter((n) => missing.fields.has(n)) : [];
2726+
if (missing && own.length > 0) {
2727+
return {
2728+
ok: false,
2729+
reason: formulaHopRefusal(field, { object: missing.object, unavailable: 'undeclared-field', undeclaredFields: own }, named),
2730+
};
2731+
}
2732+
}
2733+
const binding = related.bindings(rec);
26922734
let copy: Record<string, unknown> | undefined;
26932735
const empty: string[] = [];
26942736
for (const [field, named] of hops) {
@@ -6264,8 +6306,30 @@ export class ObjectQL implements IObjectQLEngine {
62646306
+ 'formula resolves ONE hop: a formula read this way does not read through a reference of its own '
62656307
+ '(a second hop). Store the value on this object as a field of its own to read it from there';
62666308
}
6267-
const lookup = await this.resolveOneHopRelated(schema, wanted, rows, context, 'caller');
6268-
return (row) => lookup(row);
6309+
// [#24410] The read set per reference is the UNION of every formula's
6310+
// columns, and the loader answers a whole reference `undeclared-field` when
6311+
// any column of it is undeclared — so one formula's misspelt column nulled
6312+
// every sibling reading through the same reference. The undeclared columns
6313+
// are taken out of the shared read here and judged per formula
6314+
// ({@link formulaHopScope}); the loader reads the declared rest.
6315+
const fields = (schema?.fields ?? {}) as Record<string, unknown>;
6316+
const undeclared = new Map<string, { object: string; fields: ReadonlySet<string> }>();
6317+
for (const [fk, named] of wanted) {
6318+
const target = referenceTargetOf(fields[fk]);
6319+
// The loader's own verdicts stand for these: no target, or a hop into
6320+
// the stored-metadata-body family, which is never read.
6321+
if (!target || isStoredMetadataBodyObject(target)) continue;
6322+
const declared = (this._registry.getObject(target) as { fields?: Record<string, unknown> } | undefined)?.fields;
6323+
const missing = undeclaredRelatedFields(declared, named);
6324+
if (missing.length === 0) continue;
6325+
undeclared.set(fk, { object: target, fields: new Set(missing) });
6326+
for (const name of missing) named.delete(name);
6327+
if (named.size === 0) wanted.delete(fk);
6328+
}
6329+
const lookup = wanted.size > 0
6330+
? await this.resolveOneHopRelated(schema, wanted, rows, context, 'caller')
6331+
: () => undefined;
6332+
return { bindings: (row) => lookup(row), undeclared };
62696333
}
62706334

62716335
/**
@@ -10279,9 +10343,7 @@ export class ObjectQL implements IObjectQLEngine {
1027910343
//
1028010344
// ⛔ This widens the READ SET by nothing: `id` is already unconditionally
1028110345
// in the projection below, because the by-id map is keyed on it.
10282-
const declaredHere = (n: string): boolean =>
10283-
n === SystemFieldName.ID || !!declared && n in declared;
10284-
const undeclared = declared ? named.filter((n) => !declaredHere(n)) : [];
10346+
const undeclared = undeclaredRelatedFields(declared, named);
1028510347
if (undeclared.length > 0) {
1028610348
resolved.set(fk, { object: target, byId: new Map(), undeclared });
1028710349
continue;

0 commit comments

Comments
 (0)