Skip to content

Commit a1d8bf6

Browse files
fix(objectql): the nested-relation condition refusal prints its route inside the client cap (#27531)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuVpEmthvkRbuHGjC6oSQZvd)_ Fixes #27351 Clause-②: no Metadata-protocol: none Skills-docs: none (kind `none`, no narrowing arm; no `skills/**` file touched) ## 维护者速读 - 改了什么:数据查询在 `where` 里拒收嵌套关联条件(第二层关联、带点的键、空对象、未声明的键、未注册的关联对象)时,报错文字改为"位置 + 简短原因 + 该怎么写 + 未执行 + 补充原因";两类拒收的开头只列条件里的第一个键并计数其余键。 - 为什么改:客户端只读报错前 500 字符;原先"该怎么写"排在"未执行"之后,字段名稍长就被截掉(第二层关联、带点的键在普通字段名下就看不到 `$in` 写法);长名字下 #27350 的无运算符对象拒收也仍有 6 行被截。 - 风险与回滚:只改报错文案的顺序和措辞,拒收范围、错误码 `INVALID_FILTER`、状态 400、500 字符截断都不变;回滚即 revert 本 PR。 - 先看:`packages/objectql/src/relation-filter-lowering.ts`(`relationConditionRefusalMessage` 五个分支与 `describeKeys`)、`packages/objectql/src/no-operator-object-door.ts`(`describeObject`)、`packages/objectql/src/relation-condition-prescription-cap.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run src/relation-condition-prescription-cap.test.ts src/no-operator-object-prescription-cap.test.ts`,预期 2 个文件 20/20 通过。 - 需要你做的:无,席位落地。 ## What changes `packages/objectql/src/relation-filter-lowering.ts`, `relationConditionRefusalMessage`: every reason now reads **head, short reason, route, then "The filter was NOT applied." and any reasoning**, so the route sits inside the 499 characters a client reads (`CLIENT_MESSAGE_MAX`, `@objectstack/types`, unchanged). - **second-level**: `…, and 'KEY' holds a condition of its own: one level only. Read 'TARGET' with { "KEY": { … } }, then match its ids: ROUTE. The filter was NOT applied. 'KEY' is itself a TYPE field of the related object 'TARGET'.` - **dotted-key**, **empty**, **undeclared-key**, **unregistered-target**: the route moved ahead of "NOT applied"; the reason sentence is unchanged. - **The head names the column after the path by its type** (`at PATH, beneath the lookup field`): the head already names the field twice (`filter on 'FIELD' … at PATH.FIELD`). The route names the field only in its spelling (`then match its ids:`, `Match ids you hold:`, `or test for a value:`). - **The multi-valued ids route** reads `an $or of { "FIELD": { "$contains": ID } }, one per id` (was `{ "FIELD": { "$contains": ID } } for one id, an $or of those for several`). `idsRoute` is shared, so the cap refusal (`relationFilterCapError`) prints the same spelling. - **The shared heads name the first key and count the rest**: `describeKeys` here and `describeObject` in `no-operator-object-door.ts` (`keys "region", and 2 more`, was three keys listed). This closes the residue PR #27350 recorded on the no-operator-object refusal's stress fixture, which the triage ruling asked to be measured in this PR. - Unchanged: the set of filters refused, `INVALID_FILTER` / 400, the envelope, the cap and the truncation, and the phrases other packages' tests read (`names no field of the related object`, `'KEY' is itself a lookup field of the related object 'X'`, `is a dotted path: the condition reaches one level only`, `{ "FIELD": { "$null": false } }`, `{ "FIELD": { "$in": [ID, …] } }`, `The filter was NOT applied.`). ## Tests - New `relation-condition-prescription-cap.test.ts`: for each of the five reasons, on three fixtures, the refusal is read through `mapDataError` as a client reads it. The test requires `400` + `INVALID_FILTER`, the whole route in the client text, the route ahead of "NOT applied", and the route ending by the fixture's limit. The card's fixture (21-character object and field, 16-character target, three keys, nested `$and` / `$or`) has a limit of 459, which is 40 inside the cut. The stress fixture (27-character object and target, 32-character field, four 11- to 15-character keys, three-deep path), single- and multi-valued, has a limit of 499. That makes 15 tests. - `no-operator-object-prescription-cap.test.ts` gains a stress block: every kind at every position it is refused, on a 27-character object and target, 32-character fields, a four-key object, and `aggregations[12].filter` with a three-deep path. The route must end by 499. - Three in-surface pins were updated for the head wording. `engine-nested-relation-lowering.test.ts` now asserts `at PATH, beneath the (lookup|tree) field, and `. `protocol-explicit-filter-field-gate.test.ts` now asserts `… at where.owner_id, beneath the lookup field,`. <details><summary>Package runs (head 2eb99b8, after merging origin/main f57eddc)</summary> - Under the verify lock: `pnpm --filter '@objectstack/objectql...' build`, then `pnpm --filter @objectstack/objectql typecheck` (tsc + `check:test-typecheck` OK), then `pnpm --filter @objectstack/objectql test`: Test Files 484 passed (484), Tests 9104 passed (9104). Then `pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/data-nested-object-door.test.ts src/data-no-operator-object-door.test.ts`: 10 passed, 20 skipped (the live PostgreSQL / MySQL cells, with no env here; the SQLite cell ran, and it asserts the second-level, empty and undeclared words and routes on the REST body). The lock printed `VERDICT command-exit 0`. - `npx eslint --no-inline-config packages/objectql`: exit 0. </details> <details><summary>Ablation (fix committed first; each leg through scripts/ablation-replace.mjs)</summary> 1. `relationConditionRefusalMessage`'s head-and-switch span was replaced by its `cf218eed1` text, which restores the old order. The anchor went from 1 to 0 and the blob from `2f1d2a495ff1` to `b8031a3df929`. Result: `relation-condition-prescription-cap.test.ts` had 15 failed (15). Examples: "the route ends at 496; the limit is 459" (the card's second-level reason); `expected 303 to be greater than 420` (the card's unregistered reason, where "NOT applied" comes before the route); "the client text holds the whole route" on the stress rows. After restore the blob equals HEAD and `git diff HEAD` is empty. 2. `describeObject` was set back to listing three keys. The anchor went from 1 to 0 and the blob from `c06bf275a4ef` to `4584cc487e38`. Result: `no-operator-object-prescription-cap.test.ts` had 1 failed | 4 passed (5): the stress block, where the client text no longer holds `Move the condition to 'where', or f…`. After restore the blob equals HEAD and `git diff HEAD` is empty. The tests import the subject by a relative `src` path, so no `dist` build sits between the test and the edit. </details> ## Verification — route offsets before and after Measured with `tsx` through `narrowNumberComparands` / `narrowHavingNumberComparands` and read the way a client reads it. "Before" is at `cf218eed1`'s two producer files and "after" is this head. **cut** means the route ends past 499. <details><summary>Nested-relation condition refusal (where)</summary> | fixture | reason | before: route start–end | after: route start–end | |:--|:--|:--|:--| | card | second-level | 413–579 **cut** | 266–391 | | card | dotted-key | 360–513 **cut** | 281–393 | | card | empty | 306–463 (after "NOT applied") | 245–373 | | card | undeclared-key | 347–420 (after "NOT applied") | 268–341 | | card | unregistered | 327–426 (after "NOT applied") | 260–327 | | stress | second-level | 492–691 **cut** | 307–454 | | stress | dotted-key | 428–614 **cut** | 322–456 | | stress | empty | 364–554 **cut** | 292–442 | | stress | undeclared-key | 426–510 **cut** | 320–404 | | stress | unregistered | 418–539 **cut** | 312–390 | | stress, multi-valued | second-level | message 733, route after 479 **cut** | 307–477 | | stress, multi-valued | dotted-key | message 656, route after 415 **cut** | 322–479 | | stress, multi-valued | unregistered | message 581 **cut** | 312–413 | </details> <details><summary>No-operator-object refusal, stress fixture (the #27350 residue)</summary> | kind | position | before | after | |:--|:--|:--|:--| | single-valued lookup | aggregations[12].filter | 366–532 **cut** | 332–498 | | single-valued lookup | having | 339–494 | 305–460 | | multi-valued lookup | aggregations[12].filter | 366–491 | 332–457 | | json | aggregations[12].filter | 352–525 **cut** | 318–491 | | json | having | 325–498 | 291–464 | | json | where | 329–502 **cut** | 295–468 | | address | aggregations[12].filter | 355–528 **cut** | 321–494 | | address | having | 328–501 **cut** | 294–467 | | address | where | 332–505 **cut** | 298–471 | | number | aggregations[12].filter | 315–493 | 281–459 | | number | having | 288–466 | 254–432 | | number | where | 292–470 | 258–436 | </details> ## Acceptance notes - **Thin margin on the extreme rows.** On the no-operator stress fixture the single-valued lookup at `aggregations[12].filter` ends at 498, 1 inside the cut. On the relation stress fixture the multi-valued rows end at 477–479. The card-sized fixtures keep 40+ characters of margin, and so does #27350's own fixture. A longer fixture still could be cut. The next lever would be the per-kind route words, not the head, which now names one key. - **The cap refusal's multi-valued route spelling changed** because `idsRoute` is shared. The meaning is the same, and the cap test's `{ "owners": { "$contains": ID } }` pin still holds. --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgXcN9HaZYSEUdJWEjw)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgXcN9HaZYSEUdJWEjw --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7735d1d commit a1d8bf6

7 files changed

Lines changed: 276 additions & 31 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): the nested-relation condition refusal states what to write inside the 500-character client cap
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`POST /api/v1/data/OBJECT/query` (and `find` / `count` / `aggregate` / `update` / `delete`) refuses a nested-relation condition in `where` with `400 INVALID_FILTER` when it reaches a second relation, uses a dotted key, names no key, names a key the related object does not declare, or sits beneath a relation whose related object is not registered. A client reads at most 500 characters of that message, and for a second-level relation, a dotted key and an empty condition the refusal printed its route after "NOT applied", so with an ordinary field name the route was cut off. Each refusal now reads: where the condition sits, the short reason, what to write instead, then "NOT applied" and any reasoning. The head names the column after the path by its type (`beneath the lookup field`), and the two-step route reads `then match its ids: …`; on a multi-valued relation the ids route reads `an $or of { "FIELD": { "$contains": ID } }, one per id` (the cap refusal uses the same spelling).
11+
12+
Both refusal heads — this one and the no-operator-object refusal's — now name the condition's or object's first key and count the rest (`keys "region", and 2 more`) instead of listing three, so with long object, field and key names every route still lands inside the cut.
13+
14+
The cap, the truncation, the code and the status are unchanged, and so is the set of filters refused.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ describe('[#20802] the nested-relation form is lowered at the engine\'s where se
302302
const err = await refusalOf(engine.find(OBJECT, { where }));
303303
expect(envelopeOf(err), JSON.stringify(where)).toEqual(INVALID_FILTER);
304304
expect(err!.message, JSON.stringify(where)).toContain('puts a nested-relation condition (');
305-
expect(err!.message, JSON.stringify(where)).toContain(`at ${path}, beneath the declared`);
305+
expect(err!.message, JSON.stringify(where)).toMatch(new RegExp(`at ${path.replace(/[$[\]().]/g, '\\$&')}, beneath the (lookup|tree) field, and `));
306306
expect(err!.message, JSON.stringify(where)).toContain(words);
307307
expect(err!.message, JSON.stringify(where)).toContain('The filter was NOT applied.');
308308
expect(rec.reads, JSON.stringify(where)).toHaveLength(0);

‎packages/objectql/src/no-operator-object-door.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -251,12 +251,16 @@ export interface NoOperatorObjectRefusal {
251251
readonly aggregated: boolean;
252252
}
253253

254-
/** How the object is named in the words: its keys, never its values. */
254+
/**
255+
* How the object is named in the words: its keys, never its values — the
256+
* first, and a count of the rest. [#27351] Every position and kind prints this
257+
* head before its route, and the path already places the object, so listing
258+
* more keys would cost the route its room inside the client cap.
259+
*/
255260
function describeObject(keys: readonly string[]): string {
256261
if (keys.length === 0) return 'an empty object {}';
257-
const shown = keys.slice(0, 3).map((key) => JSON.stringify(key)).join(', ');
258-
const more = keys.length > 3 ? `, and ${keys.length - 3} more` : '';
259-
return `an object with no operator key (keys ${shown}${more})`;
262+
const more = keys.length > 1 ? `, and ${keys.length - 1} more` : '';
263+
return `an object with no operator key (keys ${JSON.stringify(keys[0])}${more})`;
260264
}
261265

262266
/**

‎packages/objectql/src/no-operator-object-prescription-cap.test.ts‎

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,12 @@ function clientReads(fn: () => unknown): { status: number; code: unknown; text:
5757
return { status, code: body.code, text: String(body.error), full: (thrown as Error).message };
5858
}
5959

60-
/** The route is whole in what the client reads, ends MARGIN inside the cut, and precedes "NOT applied". */
61-
function expectRouteInside(fn: () => unknown, [open, close]: readonly [string, string]): void {
60+
/** The route is whole in what the client reads, ends by `limit` (MARGIN inside the cut), and precedes "NOT applied". */
61+
function expectRouteInside(
62+
fn: () => unknown,
63+
[open, close]: readonly [string, string],
64+
limit = CLIENT_CUT - MARGIN,
65+
): void {
6266
const read = clientReads(fn);
6367
expect(read.status).toBe(400);
6468
expect(read.code).toBe('INVALID_FILTER');
@@ -68,8 +72,8 @@ function expectRouteInside(fn: () => unknown, [open, close]: readonly [string, s
6872
expect(closeAt, read.full).toBeGreaterThan(start);
6973
const end = closeAt + close.length;
7074
expect(read.text, 'the client text holds the whole route').toContain(read.full.slice(start, end));
71-
expect(end, `the route ends at ${end}, within ${MARGIN} of the ${CLIENT_CUT}-character cut`)
72-
.toBeLessThanOrEqual(CLIENT_CUT - MARGIN);
75+
expect(end, `the route ends at ${end}; the limit is ${limit} (the cut is ${CLIENT_CUT})`)
76+
.toBeLessThanOrEqual(limit);
7377
const notApplied = read.full.indexOf(' was NOT applied');
7478
expect(notApplied, read.full).toBeGreaterThan(end - 1);
7579
}
@@ -106,3 +110,59 @@ describe('[#27337] the no-operator-object refusal: every route lands inside the
106110
expectRouteInside(having('contract_value_amount', 'number'), SCALAR_ROUTE);
107111
});
108112
});
113+
114+
/**
115+
* [#27351] The stress fixture: a 27-character object and target, 32-character
116+
* fields, a four-key object and a path through `aggregations[12].filter` and a
117+
* three-deep `$and` / `$or` / `$and`. The shared head (context, field, keys,
118+
* path) is long enough here that every route must still end inside the cut —
119+
* the head names the object's first key and counts the rest. Against the head
120+
* as it stood on `origin/main` `cf218eed1` (three keys shown), six rows end
121+
* past the cut (501–532).
122+
*/
123+
describe('[#27351] the no-operator-object refusal: the stress fixture keeps every route inside the cut', () => {
124+
const STRESS_OBJECT = 'customer_support_case_entry';
125+
const STRESS_TARGET = 'enterprise_customer_account';
126+
const STRESS_VALUE = { region_code: 'x', service_tier: 'x', account_status: 'x', billing_country: 'x' };
127+
const F = {
128+
lookup: 'primary_customer_account_manager',
129+
multi: 'secondary_account_manager_owners',
130+
json: 'integration_metadata_payload_raw',
131+
address: 'primary_shipping_destination_adr',
132+
number: 'contract_value_amount_in_dollars',
133+
} as const;
134+
const STRESS_SCHEMA = {
135+
fields: {
136+
[F.lookup]: { name: F.lookup, type: 'lookup', reference: STRESS_TARGET },
137+
[F.multi]: { name: F.multi, type: 'lookup', reference: STRESS_TARGET, multiple: true },
138+
[F.json]: { name: F.json, type: 'json' },
139+
[F.address]: { name: F.address, type: 'address' },
140+
[F.number]: { name: F.number, type: 'number' },
141+
amount: { name: 'amount', type: 'number' },
142+
},
143+
};
144+
const deep = (field: string) => ({
145+
$and: [{ amount: 1 }, { $or: [{ $and: [{ amount: 2 }, { [field]: STRESS_VALUE }] }] }],
146+
});
147+
const at = {
148+
where: (field: string) => () => narrowNumberComparands(STRESS_OBJECT, 'find', STRESS_SCHEMA, deep(field)),
149+
filter: (field: string) => () =>
150+
narrowNumberComparands(STRESS_OBJECT, 'aggregate', STRESS_SCHEMA, deep(field), 'aggregations[12].filter'),
151+
having: (field: string, type: string) => () =>
152+
narrowHavingNumberComparands(STRESS_OBJECT, { [field]: STRESS_VALUE }, new Map(), new Map([[field, type]])),
153+
};
154+
155+
it('every kind, at every position it is refused', () => {
156+
expectRouteInside(at.filter(F.lookup), SINGLE_LOOKUP_ROUTE, CLIENT_CUT);
157+
expectRouteInside(at.having(F.lookup, 'lookup'), SINGLE_LOOKUP_ROUTE, CLIENT_CUT);
158+
expectRouteInside(at.filter(F.multi), MULTI_LOOKUP_ROUTE, CLIENT_CUT);
159+
for (const kind of ['json', 'address'] as const) {
160+
expectRouteInside(at.where(F[kind]), JSON_ROUTE, CLIENT_CUT);
161+
expectRouteInside(at.filter(F[kind]), JSON_ROUTE, CLIENT_CUT);
162+
expectRouteInside(at.having(F[kind], kind), JSON_ROUTE, CLIENT_CUT);
163+
}
164+
expectRouteInside(at.where(F.number), SCALAR_ROUTE, CLIENT_CUT);
165+
expectRouteInside(at.filter(F.number), SCALAR_ROUTE, CLIENT_CUT);
166+
expectRouteInside(at.having(F.number, 'number'), SCALAR_ROUTE, CLIENT_CUT);
167+
});
168+
});

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ describe('#7534 — unknown field on the EXPLICIT filter axes (real ObjectQL eng
401401
// descends: the answer is not its INVALID_FIELD about `region`.
402402
const err: any = await find({ where: { owner_id: { region: 'NA' } } }).then(() => null, (e: any) => e);
403403
expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
404-
expect(err.message).toContain("puts a nested-relation condition (keys \"region\") at where.owner_id, beneath the declared lookup field 'owner_id'");
404+
expect(err.message).toContain("puts a nested-relation condition (keys \"region\") at where.owner_id, beneath the lookup field,");
405405
expect(err.field).toBeUndefined();
406406
});
407407

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27351] The nested-relation condition refusal at `where` states what to
5+
* write instead INSIDE the 500 characters a client reads, for every reason it
6+
* refuses a condition.
7+
*
8+
* The cap stays: `mapDataError` (`@objectstack/types`) cuts a 4xx message at
9+
* `CLIENT_MESSAGE_MAX` with `…`. So `relation-filter-lowering.ts` orders each
10+
* reason's words head, the short reason, the route, then "NOT applied" and any
11+
* reasoning, and this file reads every reason the way a client does — through
12+
* `mapDataError` — and requires the whole route in what survives the cut,
13+
* ahead of "NOT applied". Run against the words as they stood on `origin/main`
14+
* `cf218eed1`, the second-level and dotted-key routes are cut on the card's
15+
* fixture, and every reason's route is cut on the stress fixture.
16+
*
17+
* Two fixtures. The card's — a 21-character object, a 21-character field, a
18+
* 16-character target, three keys, a path through a nested `$and` / `$or` —
19+
* must end each route {@link MARGIN} characters inside the cut. The stress
20+
* one — a 27-character object and target, a 32-character field, four 11- to
21+
* 15-character keys, a three-deep path, single- and multi-valued — must keep
22+
* each route whole inside the cut.
23+
*/
24+
25+
import { describe, it, expect } from 'vitest';
26+
import { mapDataError } from '@objectstack/types';
27+
import { narrowNumberComparands } from './number-comparand-declared-type-door.js';
28+
29+
/** The characters a client reads before the `…`: `CLIENT_MESSAGE_MAX - 1`. */
30+
const CLIENT_CUT = 499;
31+
/** How far inside the cut every route on the card's fixture must end. */
32+
const MARGIN = 40;
33+
34+
interface Fixture {
35+
readonly object: string;
36+
readonly target: string;
37+
readonly field: string;
38+
readonly keys: readonly string[];
39+
readonly multiple: boolean;
40+
/** Wraps the condition in the fixture's path. */
41+
readonly wrap: (field: string, condition: unknown) => unknown;
42+
/** The latest a route may end. */
43+
readonly limit: number;
44+
}
45+
46+
const CARD: Fixture = {
47+
object: 'customer_support_case',
48+
target: 'customer_account',
49+
field: 'primary_account_owner',
50+
keys: ['region', 'tier', 'status'],
51+
multiple: false,
52+
wrap: (field, condition) => ({ $and: [{ amount: 1 }, { $or: [{ [field]: condition }] }] }),
53+
limit: CLIENT_CUT - MARGIN,
54+
};
55+
56+
const STRESS: Fixture = {
57+
object: 'customer_support_case_entry',
58+
target: 'enterprise_customer_account',
59+
field: 'primary_customer_account_manager',
60+
keys: ['region_code', 'service_tier', 'account_status', 'billing_country'],
61+
multiple: false,
62+
wrap: (field, condition) => ({
63+
$and: [{ amount: 1 }, { $or: [{ $and: [{ amount: 2 }, { [field]: condition }] }] }],
64+
}),
65+
limit: CLIENT_CUT,
66+
};
67+
68+
const STRESS_MULTI: Fixture = { ...STRESS, multiple: true };
69+
70+
/** The opening and the closing words of each reason's route. */
71+
function routes(f: Fixture): Record<string, readonly [string, string]> {
72+
const ids = f.multiple ? '"$contains": ID } }, one per id.' : '"$in": [ID, …] } }.';
73+
return {
74+
secondLevel: [`Read '${f.target}' with { "parent_account": { … } }`, ids],
75+
dottedKey: [`Read '${f.target}' with that condition`, ids],
76+
empty: ['Name one (', '"$null": false } }.'],
77+
undeclaredKey: ['Name a field it declares', '"FIELD": VALUE } }.'],
78+
unregistered: ['Match ', ids],
79+
};
80+
}
81+
82+
/** The refusal a client reads: status, code, the bounded `error` text, and the full message. */
83+
function clientReads(fn: () => unknown): { status: number; code: unknown; text: string; full: string } {
84+
let thrown: unknown;
85+
try { fn(); } catch (e) { thrown = e; }
86+
expect(thrown, 'the door refused').toBeInstanceOf(Error);
87+
const { status, body } = mapDataError(thrown);
88+
return { status, code: body.code, text: String(body.error), full: (thrown as Error).message };
89+
}
90+
91+
/** The route is whole in what the client reads, ends by the fixture's limit, and precedes "NOT applied". */
92+
function expectRouteInside(f: Fixture, fn: () => unknown, [open, close]: readonly [string, string]): void {
93+
const read = clientReads(fn);
94+
expect(read.status).toBe(400);
95+
expect(read.code).toBe('INVALID_FILTER');
96+
const start = read.full.indexOf(open);
97+
expect(start, read.full).toBeGreaterThan(0);
98+
const closeAt = read.full.indexOf(close, start);
99+
expect(closeAt, read.full).toBeGreaterThan(start);
100+
const end = closeAt + close.length;
101+
expect(read.text, 'the client text holds the whole route').toContain(read.full.slice(start, end));
102+
expect(end, `the route ends at ${end}; the limit is ${f.limit}`).toBeLessThanOrEqual(f.limit);
103+
const notApplied = read.full.indexOf(' was NOT applied');
104+
expect(notApplied, read.full).toBeGreaterThan(end - 1);
105+
}
106+
107+
/** Refuse `condition` beneath the fixture's relation field, at `where`. */
108+
function refusal(f: Fixture, condition: Record<string, unknown>, registered = true) {
109+
const related = {
110+
fields: {
111+
...Object.fromEntries(f.keys.map((key) => [key, { name: key, type: 'text' }])),
112+
parent_account: { name: 'parent_account', type: 'lookup', reference: f.target },
113+
},
114+
};
115+
const schema = {
116+
fields: {
117+
[f.field]: { name: f.field, type: 'lookup', reference: f.target, ...(f.multiple ? { multiple: true } : {}) },
118+
amount: { name: 'amount', type: 'number' },
119+
},
120+
};
121+
const schemaOf = (name: string) => (registered && name === f.target ? related : undefined);
122+
return () => narrowNumberComparands(f.object, 'find', schema, f.wrap(f.field, condition), 'where', { schemaOf });
123+
}
124+
125+
const conditionOf = (f: Fixture) => Object.fromEntries(f.keys.map((key) => [key, 'x']));
126+
127+
describe('[#27351] the nested-relation condition refusal: every route lands inside the client cap', () => {
128+
for (const [label, f] of [['the card', CARD], ['stress, single-valued', STRESS], ['stress, multi-valued', STRESS_MULTI]] as const) {
129+
describe(label, () => {
130+
const route = routes(f);
131+
const condition = conditionOf(f);
132+
133+
it('a second-level relation', () => {
134+
expectRouteInside(f, refusal(f, { ...condition, parent_account: { [f.keys[0]]: 'x' } }), route.secondLevel);
135+
});
136+
137+
it('a dotted key', () => {
138+
expectRouteInside(f, refusal(f, { ...condition, 'parent_account.name': 'x' }), route.dottedKey);
139+
});
140+
141+
it('an empty condition', () => {
142+
expectRouteInside(f, refusal(f, {}), route.empty);
143+
});
144+
145+
it('a key the related object does not declare', () => {
146+
expectRouteInside(f, refusal(f, { ...condition, regio: 'x' }), route.undeclaredKey);
147+
});
148+
149+
it('a related object not registered here', () => {
150+
expectRouteInside(f, refusal(f, condition, false), route.unregistered);
151+
});
152+
});
153+
}
154+
});

0 commit comments

Comments
 (0)