Skip to content

Commit f7bae28

Browse files
committed
wip: rest table pin for the served nested-relation form; words fit the REST bound
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4b46179 commit f7bae28

8 files changed

Lines changed: 205 additions & 120 deletions

‎packages/objectql/src/engine-nested-object-door.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -175,14 +175,14 @@ describe('[#20745] a no-operator object beneath a relation, structured-JSON or p
175175
});
176176

177177
it('refuses {} beneath a relation and a JSON column too, in the engine\'s words rather than each driver\'s', async () => {
178-
for (const [where, words] of [
178+
for (const [where, empty, words] of [
179179
// [#20802] Served at `where` otherwise — `{}` names no field of the related object.
180-
[{ owner: {} }, 'names no field of the related object'],
181-
[{ meta: {} }, 'whole-value match'],
180+
[{ owner: {} }, '(no keys)', 'names no field of the related object'],
181+
[{ meta: {} }, 'an empty object {}', 'whole-value match'],
182182
] as const) {
183183
const err = await refusalOf(engine.find(OBJECT, { where: where as FilterCondition }));
184184
expect(envelopeOf(err), JSON.stringify(where)).toEqual(ENVELOPE);
185-
expect(err!.message, JSON.stringify(where)).toContain('an empty object {}');
185+
expect(err!.message, JSON.stringify(where)).toContain(empty);
186186
expect(err!.message, JSON.stringify(where)).toContain(words);
187187
}
188188
expect(reads).toHaveLength(0);

‎packages/objectql/src/engine-nested-relation-lowering.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ describe('[#20802] the nested-relation form is lowered at the engine\'s where se
285285
const cases: ReadonlyArray<readonly [FilterCondition, string, string]> = [
286286
[{ parent: { owner: { region: 'NA' } } }, 'where.parent', "'owner' is itself a lookup field of the related object 'nested_rel_ledger'"],
287287
[{ owner: { account: { title: 'a' } } }, 'where.owner', "'account' is itself a lookup field of the related object 'nested_rel_owner'"],
288-
[{ owner: { 'account.title': 'a' } }, 'where.owner', "'account.title' is a dotted path — the condition reaches one level only"],
288+
[{ owner: { 'account.title': 'a' } }, 'where.owner', "'account.title' is a dotted path: the condition reaches one level only"],
289289
[{ owner: { regio: 'NA' } }, 'where.owner', "'regio' is not a field of the related object 'nested_rel_owner'"],
290290
[{ owner: {} }, 'where.owner', 'names no field of the related object'],
291291
[{ stray: { region: 'NA' } }, 'where.stray', "no object 'nested_rel_missing' is registered here"],
@@ -295,7 +295,8 @@ describe('[#20802] the nested-relation form is lowered at the engine\'s where se
295295
rec.reads.length = 0;
296296
const err = await refusalOf(engine.find(OBJECT, { where }));
297297
expect(envelopeOf(err), JSON.stringify(where)).toEqual(INVALID_FILTER);
298-
expect(err!.message, JSON.stringify(where)).toContain(`at ${path}, a nested-relation condition`);
298+
expect(err!.message, JSON.stringify(where)).toContain('puts a nested-relation condition (');
299+
expect(err!.message, JSON.stringify(where)).toContain(`at ${path}, beneath the declared`);
299300
expect(err!.message, JSON.stringify(where)).toContain(words);
300301
expect(err!.message, JSON.stringify(where)).toContain('The filter was NOT applied.');
301302
expect(rec.reads, JSON.stringify(where)).toHaveLength(0);

‎packages/objectql/src/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,11 @@ export type { AdmittedValueShapeViolationTally } from './engine.js';
141141
// type of `ObjectQL.listDatasourceDefs()`. Exported so a consumer sweeping for
142142
// `sys_secret` references can name the shape it reads instead of re-declaring it.
143143
export type { DatasourceDef } from './engine.js';
144+
// [#20802] The cap on the related ids one nested-relation condition may feed
145+
// (`{ owner: { region: 'NA' } }`, served at `where`): past it the engine
146+
// refuses the filter rather than truncate it, and its words name this number.
147+
// Exported so a caller running the two-step route itself can page by it.
148+
export { RELATION_FILTER_ID_CAP } from './relation-filter-lowering.js';
144149
// [#16159] `SUMMARY_RECOMPUTE_CODE` joins the class it names. The refusal's own
145150
// docblock tells a caller to identify it by `code` rather than `instanceof`
146151
// (the two-realm split #14936 measured: this package declares BOTH realms in

‎packages/objectql/src/protocol-explicit-filter-field-gate.test.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -393,13 +393,15 @@ describe('#7534 — unknown field on the EXPLICIT filter axes (real ObjectQL eng
393393
// `{owner_id: {region: 'NA'}}` names `owner_id` on THIS object and
394394
// `region` on the related one. Judging `region` against this object's
395395
// field map would refuse a legitimate relation filter.
396-
// [#20745] The engine refuses the nested-relation form itself (no
397-
// driver serves it) with INVALID_FILTER in its own words. This gate
398-
// still never descends: the answer is not its INVALID_FIELD about
399-
// `region`.
396+
// [#20745] The engine refused the nested-relation form itself for a
397+
// while. [#20802] It serves it now, judging the condition's keys
398+
// against the RELATED object's declarations — here `owner_id` points
399+
// at `sys_user`, which this harness does not register, so the engine
400+
// answers INVALID_FILTER in its own words. This gate still never
401+
// descends: the answer is not its INVALID_FIELD about `region`.
400402
const err: any = await find({ where: { owner_id: { region: 'NA' } } }).then(() => null, (e: any) => e);
401403
expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
402-
expect(err.message).toContain('nested-relation form');
404+
expect(err.message).toContain("a nested-relation condition beneath the declared lookup field 'owner_id'");
403405
expect(err.field).toBeUndefined();
404406
});
405407

‎packages/objectql/src/query-expression-conformance.test.ts‎

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1226,17 +1226,37 @@ describe('#4226 — sort / select / expand on the list path (real ObjectQL engin
12261226
// inner keys belong to ANOTHER object — the exact shape the collectors
12271227
// refuse to descend into. If this control answers the dotted verdict's
12281228
// INVALID_FIELD, that verdict has started judging comparand VALUES,
1229-
// which is a different (and wrong) gate. [#20745] The engine refuses
1230-
// the form itself — no driver serves it — so both doors answer the
1231-
// no-operator-object arm's INVALID_FILTER, in its own words.
1232-
for (const run of [
1233-
() => protocol.findData({ object: 'showcase_task', query: { where: { project_id: { name: 'Apollo' } } } }),
1234-
() => engine.find('showcase_task', { where: { project_id: { name: 'Apollo' } } }),
1235-
]) {
1236-
const err: any = await run().then(() => null, (e: any) => e);
1237-
expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
1238-
expect(err.message).toContain('nested-relation form');
1239-
}
1229+
// which is a different (and wrong) gate. [#20745] The engine refused
1230+
// the form itself for a while — no driver served it. [#20802] It is
1231+
// SERVED now: the engine reads the related object with the condition
1232+
// and hands the driver `$in` on its ids, so both doors answer the rows
1233+
// the undotted `{ project_id: 'p1' }` control answers.
1234+
const byFk: any = await protocol.findData({ object: 'showcase_task', query: { where: { project_id: 'p1' } } });
1235+
const wanted = byFk.records.map((r: any) => r.id).sort();
1236+
expect(wanted).toHaveLength(5);
1237+
const viaProtocol: any = await protocol.findData({
1238+
object: 'showcase_task', query: { where: { project_id: { name: 'Apollo' } } },
1239+
});
1240+
expect(viaProtocol.records.map((r: any) => r.id).sort()).toEqual(wanted);
1241+
const viaEngine = await engine.find('showcase_task', { where: { project_id: { name: 'Apollo' } } });
1242+
expect(viaEngine.map((r: any) => r.id).sort()).toEqual(wanted);
1243+
const none = await engine.find('showcase_task', { where: { project_id: { name: 'Gemini' } } });
1244+
expect(none).toEqual([]);
1245+
});
1246+
1247+
it('[#20802] both doors\' dotted relation refusals name the SAME served nested route', async () => {
1248+
const ingressErr: any = await protocol
1249+
.findData({ object: 'showcase_task', query: { where: { 'project_id.name': 'Apollo' } } })
1250+
.then(() => null, (e: unknown) => e);
1251+
const engineErr: any = await engine
1252+
.find('showcase_task', { where: { 'project_id.name': 'Apollo' } })
1253+
.then(() => null, (e: unknown) => e);
1254+
const route = 'nest the condition beneath the relation field: { "project_id": { "name": VALUE } }';
1255+
expect(String(ingressErr?.message)).toContain(route);
1256+
expect(String(engineErr?.message)).toContain(route);
1257+
// The claim this change made false is gone from both doors.
1258+
expect(String(ingressErr?.message)).not.toContain('a filter reaches only columns');
1259+
expect(String(engineErr?.message)).not.toContain('a filter reaches only columns');
12401260
});
12411261

12421262
it('⛔ CONTROL — the structured/JSON head stays UNJUDGED at both doors: the ruled carve-out', async () => {

‎packages/objectql/src/relation-filter-lowering.ts‎

Lines changed: 25 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -218,12 +218,12 @@ export function lowerRelationSite(site: RelationFilterSite, ids: readonly unknow
218218
};
219219
}
220220

221-
/** How the words name the object beneath the field: its keys, never its values. */
221+
/** How the words name the condition: its keys, never its values. */
222222
function describeKeys(keys: readonly string[]): string {
223-
if (keys.length === 0) return 'an empty object {}';
223+
if (keys.length === 0) return 'no keys';
224224
const shown = keys.slice(0, 3).map((key) => JSON.stringify(key)).join(', ');
225225
const more = keys.length > 3 ? `, and ${keys.length - 3} more` : '';
226-
return `an object with no operator key (keys ${shown}${more})`;
226+
return `keys ${shown}${more}`;
227227
}
228228

229229
/** The ids route every driver serves: `$in` on a single-valued relation, `$contains` per id on a multi-valued one. */
@@ -236,54 +236,49 @@ function idsRoute(field: string, multiple: boolean): string {
236236
/**
237237
* The words of a structural refusal ({@link admitRelationCondition}): the
238238
* position, the verdict and the route first — the REST door bounds a 4xx
239-
* message at 500 characters by truncation — and the reasoning after.
239+
* message at 500 characters by truncation — and nothing after the route that a
240+
* caller needs.
240241
*/
241242
export function relationConditionRefusalMessage(refusal: RelationConditionRefusal, context: string): string {
242243
const { field, type, target, multiple, path, key } = refusal;
243244
const related = target === undefined ? 'the related object' : `the related object '${target}'`;
244-
const readTarget = target === undefined ? 'the related object' : `'${target}'`;
245+
const named = target === undefined ? 'the related object' : `'${target}'`;
245246
const head =
246-
`${context}: filter on '${field}' puts ${describeKeys(refusal.keys)} at ${path}, a nested-relation `
247-
+ `condition beneath the declared ${type} field '${field}'`;
248-
const route = `match '${field}' against the ids it returns: ${idsRoute(field, multiple)}`;
247+
`${context}: filter on '${field}' puts a nested-relation condition (${describeKeys(refusal.keys)}) at `
248+
+ `${path}, beneath the declared ${type} field '${field}'`;
249+
const verdict = 'The filter was NOT applied.';
250+
const twoStep = `then match '${field}' against its ids: ${idsRoute(field, multiple)}.`;
249251
switch (refusal.reason) {
250252
case 'unregistered-target':
251253
return (
252254
head
253255
+ (target === undefined
254-
? ' — and the field declares no related object, so there is nothing to read. '
255-
: ` — and no object '${target}' is registered here, so there is nothing to read. `)
256-
+ 'The filter was NOT applied. Match the field against ids you already hold: '
257-
+ `${idsRoute(field, multiple)}.`
256+
? ', and the field declares no related object to read. '
257+
: `, and no object '${target}' is registered here to read. `)
258+
+ `${verdict} Match '${field}' against ids you hold: ${idsRoute(field, multiple)}.`
258259
);
259260
case 'empty':
260261
return (
261-
head
262-
+ ` that names no field of ${related}. The filter was NOT applied. Name a field of ${related} `
263-
+ `({ "${field}": { "FIELD": VALUE } }), or test that '${field}' has a value with `
262+
`${head}, and it names no field of ${related}. ${verdict} Name one `
263+
+ `({ "${field}": { "FIELD": VALUE } }), or test that '${field}' has a value: `
264264
+ `{ "${field}": { "$null": false } }.`
265265
);
266266
case 'undeclared-key':
267267
return (
268-
head
269-
+ `, and '${key}' is not a field of ${related}. The filter was NOT applied. Name a field `
270-
+ `${related} declares: { "${field}": { "FIELD": VALUE } }. No driver matches a key the `
271-
+ 'related object does not declare the same way, and an empty answer would read exactly like a real one.'
268+
`${head}, and '${key}' is not a field of ${related}. ${verdict} Name a field it declares: `
269+
+ `{ "${field}": { "FIELD": VALUE } }. A key it does not declare would read as a real empty answer.`
272270
);
273271
case 'dotted-key':
274272
return (
275-
head
276-
+ `, and '${key}' is a dotted path — the condition reaches one level only, the fields of `
277-
+ `${related} itself. The filter was NOT applied. Read ${readTarget} with that condition `
278-
+ `yourself (one level there), then ${route}.`
273+
`${head}, and '${key}' is a dotted path: the condition reaches one level only. ${verdict} `
274+
+ `Read ${named} with that condition yourself, ${twoStep}`
279275
);
280276
case 'second-level':
281277
default:
282278
return (
283-
head
284-
+ `, and '${key}' is itself a ${refusal.keyType ?? 'relation'} field of ${related} holding a `
285-
+ 'condition of its own — the condition reaches one level only. The filter was NOT applied. '
286-
+ `Read ${readTarget} with { "${key}": { … } } yourself (one level there), then ${route}.`
279+
`${head}, and '${key}' is itself a ${refusal.keyType ?? 'relation'} field of ${related} holding a `
280+
+ `condition of its own: one level only. ${verdict} Read ${named} with { "${key}": { … } } yourself, `
281+
+ twoStep
287282
);
288283
}
289284
}
@@ -300,13 +295,10 @@ export function relationConditionError(refusal: RelationConditionRefusal, contex
300295
* matching rows. The words name the cap, the relation and the two-step route.
301296
*/
302297
export function relationFilterCapError(site: RelationFilterSite, context: string, cap: number): Error {
303-
const keys = Object.keys(site.condition).map((key) => JSON.stringify(key)).join(', ');
304298
return invalidFilterError(
305299
`${context}: the nested-relation condition at ${site.path} matched more than ${cap} records of `
306-
+ `the related object '${site.target}' — past the engine's cap on the ids one condition may feed `
307-
+ `the filter — so the filter was NOT applied: a cut-off id list would silently drop matching rows. `
308-
+ `Narrow the condition, or run the two steps yourself: read '${site.target}' with the same `
309-
+ `condition (keys ${keys}) page by page, then match '${site.field}' against the ids it returns: `
310-
+ `${idsRoute(site.field, site.multiple)}.`,
300+
+ `the related object '${site.target}' (the engine's cap), so the filter was NOT applied: a cut-off `
301+
+ `id list would silently drop matching rows. Narrow the condition, or read '${site.target}' yourself `
302+
+ `page by page and match '${site.field}' against its ids: ${idsRoute(site.field, site.multiple)}.`,
311303
);
312304
}

0 commit comments

Comments
 (0)