Skip to content

Commit 8d66cbe

Browse files
fix(objectql): the no-operator-object refusal prints its route inside the client cap (#27350)
domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgKURKuRGF1ewEVVZKx7U1i4)_ Fixes #27337 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:数据查询拒收"没有 `$` 运算符的对象"时,报错文字改为先说"该怎么写",再说"未执行"和原因;四类字段(单值/多值 lookup、json/address、普通标量)都按此顺序。 - 为什么改:客户端只读报错前 500 字符;原先修复建议写在最后,字段名稍长就被截掉,单值 lookup 在聚合 filter / having 上即使 15 字符字段也看不到 `$in` 示例。 - 风险与回滚:只改报错文案顺序与少量措辞,拒收范围、错误码 `INVALID_FILTER`、状态 400、截断上限都不变;回滚即 revert 本 PR。 - 先看:`packages/objectql/src/no-operator-object-door.ts`(四个 words 函数)、`packages/objectql/src/no-operator-object-prescription-cap.test.ts`(按客户端读法逐类钉住)。 - 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run src/no-operator-object-prescription-cap.test.ts`,预期 4/4 通过。 - 需要你做的:无,席位落地。 ## What changes `packages/objectql/src/no-operator-object-door.ts` — every kind of the no-operator-object refusal now reads **verdict, route, then "NOT applied" and the reasoning**, so the route sits inside the 499 characters a client reads (`CLIENT_MESSAGE_MAX`, `@objectstack/types`, unchanged): - **Single-valued lookup** (`aggregations[i].filter`, `having`) — the card's arm. The `$in` route moved ahead of "NOT applied"; it opens "Move the condition to 'where'" and names the related object by its name alone (`filter 'TARGET' first and match the ids it returns: { "FIELD": { "$in": [ID, …] } }.`). - **Multi-valued lookup, json / address, scalar** — measured past the cut too with a long field name (tables below), so fixed in the same PR, as the dispatch asked: each route moved ahead of "NOT applied". - **The verdict names the column by its type** (`a lookup field`, `an aggregated lookup column`, `a platform-provisioned text column`): the head already names the field twice (`filter on 'FIELD' … at PATH.FIELD`). The full name (`the declared lookup field 'FIELD'`, `the aggregated column 'FIELD', which carries a … value`) moved into the reasoning after "NOT applied". - Unchanged: the set of filters refused, `INVALID_FILTER` / 400, the envelope, the cap and the truncation, and every phrase another package's test reads (`which the engine serves in 'where'`, `The filter was NOT applied.`, `is filter structure, not a value`, `Compare 'id' with a value ({ "id": VALUE })`, `{ "meta": { "$null": false } }, or store the part you filter on in a field of its own`). Tests: a new `no-operator-object-prescription-cap.test.ts` reads each kind through `mapDataError`, as a client does, with a long fixture (21-character object, 20- to 24-character fields, a three-key object, a path through an aggregation's filter and a nested `$and` / `$or`), and requires the whole route in the client text, ending at least 40 characters before the cut, ahead of "NOT applied". Measured route ends on that fixture: 368–444 (margin 55–131). Two in-surface tests updated for the new verdict wording (`engine-nested-object-door.test.ts`, `engine-nested-relation-lowering.test.ts`). One `patch` changeset for `@objectstack/objectql`. ## Tests <details><summary>Targeted and package runs (head 885fc60)</summary> - `pnpm --filter @objectstack/objectql test` — Test Files 479 passed (479), Tests 8942 passed (8942); then `check:test-typecheck` green (lock VERDICT command-exit 0). - `pnpm --filter @objectstack/objectql typecheck` — exit 0, after `pnpm --filter @objectstack/objectql build`. - `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, no env here; the SQLite cell ran). - `eslint --no-inline-config packages/objectql` — exit 0. </details> <details><summary>Ablation, once (the fix committed first)</summary> `no-operator-object-door.ts` replaced by its `origin/main` `6d7634a9` blob (hash `4ebc814b` confirmed on disk; the new wording absent, the old present): `no-operator-object-prescription-cap.test.ts` → 4 failed (4), e.g. "the route ends at 488 … expected 488 to be less than or equal to 459". Restored with `git checkout HEAD --`: hash `58f30588` equals the HEAD blob, `git diff HEAD` empty, 4 passed (4). The subject is imported 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` (`aggregations[12].filter`, nested `$and` / `$or`) and `narrowHavingNumberComparands`, read through `mapDataError`. "Field chars" counts the field name; object `customer_support_case` and target `customer_account` for the first table; "2 keys" is `city` / `zip`, "4 keys" is four 9- to 15-character keys. A multi-valued lookup at `having` has no row: an aggregated column has no declaration, so it gets the single-valued words. <details><summary>Long field (24 chars): before, every arm cut; after, every arm inside</summary> | field chars | object | arm | position | before: route start–end | after: route start–end | |--:|:--|:--|:--|:--|:--| | 24 | 2 keys | single-valued lookup | aggregations[i].filter | 388–596 **cut** | 290–437 inside | | 24 | 2 keys | single-valued lookup | having | 365–554 **cut** | 271–418 inside | | 24 | 2 keys | multi-valued lookup | aggregations[i].filter | 388–520 **cut** | 290–415 inside | | 24 | 2 keys | json | aggregations[i].filter | 349–514 **cut** | 276–441 inside | | 24 | 2 keys | json | having | 343–508 **cut** | 257–422 inside | | 24 | 2 keys | address | aggregations[i].filter | 352–517 **cut** | 279–444 inside | | 24 | 2 keys | address | having | 346–511 **cut** | 260–425 inside | | 24 | 2 keys | number (scalar) | aggregations[i].filter | 370–524 **cut** | 239–393 inside | | 24 | 2 keys | number (scalar) | having | 364–518 **cut** | 220–374 inside | | 24 | 4 keys | single-valued lookup | aggregations[i].filter | 438–646 **cut** | 340–487 inside | | 24 | 4 keys | single-valued lookup | having | 415–604 **cut** | 321–468 inside | | 24 | 4 keys | multi-valued lookup | aggregations[i].filter | 438–570 **cut** | 340–465 inside | | 24 | 4 keys | json | aggregations[i].filter | 399–564 **cut** | 326–491 inside | | 24 | 4 keys | json | having | 393–558 **cut** | 307–472 inside | | 24 | 4 keys | address | aggregations[i].filter | 402–567 **cut** | 329–494 inside | | 24 | 4 keys | address | having | 396–561 **cut** | 310–475 inside | | 24 | 4 keys | number (scalar) | aggregations[i].filter | 420–574 **cut** | 289–443 inside | | 24 | 4 keys | number (scalar) | having | 414–568 **cut** | 270–424 inside | </details> <details><summary>Stress: 27-character object and target, 18- and 32-character fields</summary> | field chars | object | arm | position | before: route start–end | after: route start–end | |--:|:--|:--|:--|:--|:--| | 18 | 2 keys | single-valued lookup | aggregations[i].filter | 376–583 **cut** | 284–436 inside | | 18 | 2 keys | single-valued lookup | having | 353–530 **cut** | 265–406 inside | | 18 | 2 keys | multi-valued lookup | aggregations[i].filter | 376–508 **cut** | 284–409 inside | | 18 | 2 keys | json | aggregations[i].filter | 337–496 inside | 270–429 inside | | 18 | 2 keys | json | having | 331–490 inside | 251–410 inside | | 18 | 2 keys | address | aggregations[i].filter | 340–499 inside | 273–432 inside | | 18 | 2 keys | address | having | 334–493 inside | 254–413 inside | | 18 | 2 keys | number (scalar) | aggregations[i].filter | 358–494 inside | 233–369 inside | | 18 | 2 keys | number (scalar) | having | 352–488 inside | 214–350 inside | | 18 | 4 keys | single-valued lookup | aggregations[i].filter | 426–633 **cut** | 334–486 inside | | 18 | 4 keys | single-valued lookup | having | 403–580 **cut** | 315–456 inside | | 18 | 4 keys | multi-valued lookup | aggregations[i].filter | 426–558 **cut** | 334–459 inside | | 18 | 4 keys | json | aggregations[i].filter | 387–546 **cut** | 320–479 inside | | 18 | 4 keys | json | having | 381–540 **cut** | 301–460 inside | | 18 | 4 keys | address | aggregations[i].filter | 390–549 **cut** | 323–482 inside | | 18 | 4 keys | address | having | 384–543 **cut** | 304–463 inside | | 18 | 4 keys | number (scalar) | aggregations[i].filter | 408–544 **cut** | 283–419 inside | | 18 | 4 keys | number (scalar) | having | 402–538 **cut** | 264–400 inside | | 32 | 2 keys | single-valued lookup | aggregations[i].filter | 418–653 **cut** | 312–478 inside | | 32 | 2 keys | single-valued lookup | having | 395–600 **cut** | 293–448 inside | | 32 | 2 keys | multi-valued lookup | aggregations[i].filter | 418–550 **cut** | 312–437 inside | | 32 | 2 keys | json | aggregations[i].filter | 379–552 **cut** | 298–471 inside | | 32 | 2 keys | json | having | 373–546 **cut** | 279–452 inside | | 32 | 2 keys | address | aggregations[i].filter | 382–555 **cut** | 301–474 inside | | 32 | 2 keys | address | having | 376–549 **cut** | 282–455 inside | | 32 | 2 keys | number (scalar) | aggregations[i].filter | 400–578 **cut** | 261–439 inside | | 32 | 2 keys | number (scalar) | having | 394–572 **cut** | 242–420 inside | | 32 | 4 keys | single-valued lookup | aggregations[i].filter | 468–703 **cut** | 362–528 **cut** | | 32 | 4 keys | single-valued lookup | having | 445–650 **cut** | 343–498 inside | | 32 | 4 keys | multi-valued lookup | aggregations[i].filter | 468–600 **cut** | 362–487 inside | | 32 | 4 keys | json | aggregations[i].filter | 429–602 **cut** | 348–521 **cut** | | 32 | 4 keys | json | having | 423–596 **cut** | 329–502 **cut** | | 32 | 4 keys | address | aggregations[i].filter | 432–605 **cut** | 351–524 **cut** | | 32 | 4 keys | address | having | 426–599 **cut** | 332–505 **cut** | | 32 | 4 keys | number (scalar) | aggregations[i].filter | 450–628 **cut** | 311–489 inside | | 32 | 4 keys | number (scalar) | having | 444–622 **cut** | 292–470 inside | </details> ## Acceptance notes - **Residual, stress fixture only:** with a 27-character object, a 32-character field, a 27-character target, four 9- to 15-character keys and a three-deep path, five rows still end past the cut (single-valued lookup and json / address in `aggregations[i].filter`; json / address at `having`, 502–528). There the head alone (context, field, keys, path) takes about 270 characters before any arm's words; closing it means shortening the shared head (`describeObject` shows three keys), which every position and arm prints. Not done here; recorded for the seat. - **Neighbour, other producer (out of this PR's surface):** `relationConditionRefusalMessage` in `packages/objectql/src/relation-filter-lowering.ts` (the `where` refusals of a nested-relation condition) prints its route LAST. Measured with a 21-character field, a 16-character target, three keys and a nested path: `second-level` 608 characters, `dotted-key` 527, `empty` 508 — each route ends past the 499 cut. Reported to the seat in the dev report. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CXc5hequf76uJfzrdK)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent eef8373 commit 8d66cbe

5 files changed

Lines changed: 177 additions & 28 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): the no-operator-object filter 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` / `aggregate`) refuses a plain object with no `$` operator key written where a value belongs with `400 INVALID_FILTER`: beneath a scalar field, as the value of a structured-JSON field (`json`, `address`, …), and beneath a relation field in `aggregations[i].filter` and `having`. A client reads at most 500 characters of that message, and the refusal printed its route after "NOT applied" and the reasoning, so with a long field name the route was cut off — on a single-valued lookup in an aggregation's filter or `having`, the `{ "FIELD": { "$in": [ID, …] } }` example was cut even with a 15-character field. Each refusal now reads: where the object sits and what kind of column it is under, then what to write instead, then "NOT applied" and why. The routes themselves are unchanged in meaning; the relation routes now open "Move the condition to 'where'" and name the related object by its name alone. The cap, the truncation, the code and the status are unchanged, and so is the set of filters refused.

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,10 @@ describe('[#20745] a no-operator object beneath a relation, structured-JSON or p
157157
const err = await refusalOf(engine.find(OBJECT, { where: { [field]: { a: 1 } } as FilterCondition }));
158158
expect(envelopeOf(err), field).toEqual(ENVELOPE);
159159
expect(err!.message, field).toContain(`at where.${field},`);
160-
expect(err!.message, field).toContain(`as the value of the declared ${type} field '${field}'`);
160+
// [#27337] The verdict names the column by its type (the head names the
161+
// field); the reasoning after "NOT applied" names both.
162+
expect(err!.message, field).toContain(`as the value of a ${type} field —`);
163+
expect(err!.message, field).toContain(`the declared ${type} field '${field}' is matched whole`);
161164
expect(err!.message, field).toContain('whole-value match');
162165
expect(err!.message, field).toContain(`{ "${field}": { "$null": false } }`);
163166
expect(err!.message, field).not.toContain('$contains');

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -337,18 +337,22 @@ describe('[#20802] the nested-relation form is lowered at the engine\'s where se
337337
aggregations: [{ function: 'count', alias: 'all' }, { function: 'count', alias: 'n', filter: { owner: { region: 'NA' } } }],
338338
} as EngineAggregateOptions));
339339
expect(envelopeOf(perAggregation)).toEqual(INVALID_FILTER);
340-
expect(perAggregation!.message).toContain("the nested-relation form, which the engine serves in 'where' and not in an aggregation's 'filter'");
340+
// [#27337] The verdict says where the form is served; the reasoning after
341+
// "NOT applied" names the position that does not serve it.
342+
expect(perAggregation!.message).toContain("the nested-relation form, which the engine serves in 'where' only.");
343+
expect(perAggregation!.message).toContain("The engine evaluates an aggregation's 'filter' itself");
341344
expect(perAggregation!.message).toContain('{ "owner": { "$in": [ID, …] } }');
342345
const multi = await refusalOf(engine.aggregate(OBJECT, {
343346
aggregations: [{ function: 'count', alias: 'all' }, { function: 'count', alias: 'n', filter: { owners: { region: 'NA' } } }],
344347
} as EngineAggregateOptions));
345-
expect(multi!.message).toContain("Put the condition in 'where' instead: here the stored list of ids is compared as one value");
348+
expect(multi!.message).toContain("Move the condition to 'where': here the stored list of ids is compared as one value");
346349
expect(multi!.message).not.toContain('$contains');
347350
const having = await refusalOf(engine.aggregate(OBJECT, {
348351
groupBy: ['owner'], aggregations: [{ function: 'count', alias: 'n' }], having: { owner: { region: 'NA' } },
349352
} as EngineAggregateOptions));
350353
expect(envelopeOf(having)).toEqual(INVALID_FILTER);
351-
expect(having!.message).toContain("which the engine serves in 'where' and not in 'having'");
354+
expect(having!.message).toContain("which the engine serves in 'where' only.");
355+
expect(having!.message).toContain("The engine evaluates 'having' itself");
352356
expect(rec.reads).toHaveLength(0);
353357
});
354358

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

Lines changed: 48 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -259,9 +259,19 @@ function describeObject(keys: readonly string[]): string {
259259
return `an object with no operator key (keys ${shown}${more})`;
260260
}
261261

262-
/** The column, as the words name it at its position. */
263-
function describeColumn(refusal: NoOperatorObjectRefusal): string {
262+
/**
263+
* The column, as the words name it at its position. `named: false` is the
264+
* short form the verdict uses: the head has already named the field twice
265+
* (`filter on 'F' … at PATH.F`), and a third copy would cost the prescription
266+
* its room inside the client cap.
267+
*/
268+
function describeColumn(refusal: NoOperatorObjectRefusal, named = true): string {
264269
const { field, column } = refusal;
270+
if (!named) {
271+
if (refusal.aggregated) return `an aggregated ${column.type} column`;
272+
if (column.provisioned) return `a platform-provisioned ${column.type} column`;
273+
return `a ${column.type} field`;
274+
}
265275
if (refusal.aggregated) return `the aggregated column '${field}', which carries a ${column.type} value`;
266276
if (column.provisioned) return `the platform-provisioned ${column.type} column '${field}'`;
267277
return `the declared ${column.type} field '${field}'`;
@@ -271,18 +281,27 @@ function describeColumn(refusal: NoOperatorObjectRefusal): string {
271281
* The scalar kind's words (#20546), less the context and the object.
272282
*
273283
* [#20745] Every kind's words put the verdict and the route FIRST and the
274-
* reasoning after: the REST door bounds a 4xx message at 500 characters by
275-
* truncation (`CLIENT_MESSAGE_MAX`, `@objectstack/rest`'s
276-
* `error-response.ts`), so what a caller must do next has to land inside it.
284+
* reasoning after: a client reads at most 500 characters of a 4xx message
285+
* (`CLIENT_MESSAGE_MAX`, `@objectstack/types`' `mapDataError`, cut with `…`),
286+
* so what a caller must do next has to land inside it.
287+
*
288+
* [#27337] Every kind is ordered the same way, and the cap stays: the verdict
289+
* (short — the column by its type, since the head names the field), then the
290+
* route, then "NOT applied" and the reasoning, which is the part a cut may
291+
* take. The relation kind printed its `$in` route after the reasoning, past
292+
* the cut on an aggregation's `filter` and `having`; with a long field name
293+
* every kind did. `no-operator-object-prescription-cap.test.ts` pins each
294+
* kind's route inside the cap, with margin, the same rule the temporal and
295+
* text-operator doors follow.
277296
*/
278297
function scalarWords(refusal: NoOperatorObjectRefusal): string {
279-
const { field, column } = refusal;
298+
const { field } = refusal;
280299
return (
281-
`where a value of ${describeColumn(refusal)} belongs. An object with no "$" operator is filter `
282-
+ 'structure, not a value. The filter was NOT applied. Compare '
283-
+ `'${field}' with a value ({ "${field}": VALUE }) or an operator ({ "${field}": { "$eq": VALUE } }). `
284-
+ `A ${column.type} column holds scalar values — one, or a list of them — so no record can match an `
285-
+ 'object there, and an empty answer would read exactly like a real one.'
300+
`where a value of ${describeColumn(refusal, false)} belongs. Compare '${field}' with a value `
301+
+ `({ "${field}": VALUE }) or an operator ({ "${field}": { "$eq": VALUE } }). The filter was NOT `
302+
+ `applied. An object with no "$" operator is filter structure, not a value: ${describeColumn(refusal)} `
303+
+ 'holds scalar values — one, or a list of them — so no record can match an object there, and an '
304+
+ 'empty answer would read exactly like a real one.'
286305
);
287306
}
288307

@@ -297,24 +316,26 @@ function scalarWords(refusal: NoOperatorObjectRefusal): string {
297316
* value, so neither `$in` nor `$contains` matches a member of it — so `where`
298317
* is its only route. An aggregated column (`having`) names no declaration to
299318
* read either from, so it is given the single-valued spelling.
319+
*
320+
* [#27337] The route comes right after the verdict, ahead of "NOT applied".
300321
*/
301322
function relationWords(refusal: NoOperatorObjectRefusal): string {
302323
const { field, column } = refusal;
303324
const def = column.def as { type: string; multiple?: boolean } | undefined;
304325
const target = def === undefined ? undefined : referenceTargetOf(def);
305-
const related = target === undefined ? 'the related object' : `the related object '${target}'`;
326+
const related = target === undefined ? 'the related object' : `'${target}'`;
306327
const multiple = def !== undefined && isMultiValueField(def);
307328
const position = refusal.aggregated ? "'having'" : "an aggregation's 'filter'";
308329
const route = multiple
309-
? `Put the condition in 'where' instead: here the stored list of ids is compared as one value, so no `
330+
? `Move the condition to 'where': here the stored list of ids is compared as one value, so no `
310331
+ 'operator matches one member of it.'
311-
: `Put the condition in 'where' instead, or filter ${related} first and match '${field}' against `
312-
+ `the ids it returns: { "${field}": { "$in": [ID, …] } }.`;
332+
: `Move the condition to 'where', or filter ${related} first and match the ids it returns: `
333+
+ `{ "${field}": { "$in": [ID, …] } }.`;
313334
return (
314-
`beneath ${describeColumn(refusal)} — the nested-relation form, which the engine serves in 'where' `
315-
+ `and not in ${position}. The filter was NOT applied. ${route} An object with no "$" operator is `
316-
+ `filter structure, not a value, here: '${field}' holds the related record's id, and an empty answer `
317-
+ 'would read exactly like a real one.'
335+
`beneath ${describeColumn(refusal, false)} — the nested-relation form, which the engine serves in `
336+
+ `'where' only. ${route} The filter was NOT applied. The engine evaluates ${position} itself, and `
337+
+ `there ${describeColumn(refusal)} holds the related record's id, so an object with no "$" operator `
338+
+ 'is filter structure, not a value, and an empty answer would read exactly like a real one.'
318339
);
319340
}
320341

@@ -323,15 +344,18 @@ function relationWords(refusal: NoOperatorObjectRefusal): string {
323344
* answers alike: presence of the whole value, or a stored field of its own for
324345
* the part the filter is about (the dotted path into a JSON value is live on
325346
* some backends and silently empty on others, so it is not offered).
347+
*
348+
* [#27337] The route comes right after the verdict, ahead of "NOT applied".
326349
*/
327350
function jsonWords(refusal: NoOperatorObjectRefusal): string {
328351
const { field } = refusal;
329352
return (
330-
`as the value of ${describeColumn(refusal)} — a whole-value match, which the engine does not `
331-
+ `serve. The filter was NOT applied. Test the whole value's presence with { "${field}": { "$null": `
332-
+ 'false } }, or store the part you filter on in a field of its own and filter that field. An object '
333-
+ 'with no "$" operator is filter structure, not a value, and the drivers share no meaning for a '
334-
+ 'whole-value match: one compares the documents, another refuses the bind.'
353+
`as the value of ${describeColumn(refusal, false)} — a whole-value match the engine does not serve. `
354+
+ `Test the whole value's presence with { "${field}": { "$null": false } }, or store the part you `
355+
+ 'filter on in a field of its own and filter that field. The filter was NOT applied. Here '
356+
+ `${describeColumn(refusal)} is matched whole: an object with no "$" operator is filter structure, `
357+
+ 'not a value, and the drivers share no meaning for a whole-value match — one compares the '
358+
+ 'documents, another refuses the bind.'
335359
);
336360
}
337361

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27337] The no-operator-object refusal states what to write instead INSIDE
5+
* the 500 characters a client reads, for every kind of column it judges.
6+
*
7+
* The cap stays: `mapDataError` (`@objectstack/types`) cuts a 4xx message at
8+
* `CLIENT_MESSAGE_MAX` with `…`. So `no-operator-object-door.ts` orders each
9+
* kind's words verdict, route, then "NOT applied" and the reasoning, and this
10+
* file reads every kind the way a client does — through `mapDataError` — and
11+
* requires the whole route in what survives the cut, ending at least
12+
* {@link MARGIN} characters before it, ahead of "NOT applied". Run against the
13+
* door as it stood on `origin/main` `6d7634a9`, the single-valued lookup route
14+
* is cut at both positions here, and every other kind's route sits after
15+
* "NOT applied".
16+
*
17+
* The fixtures are long on purpose — a 21-character object, 20- to
18+
* 24-character fields, a three-key object, a path through an aggregation's
19+
* filter and a nested `$and` / `$or` — so the pin holds with margin, not only
20+
* on a one-letter field.
21+
*/
22+
23+
import { describe, it, expect } from 'vitest';
24+
import { mapDataError } from '@objectstack/types';
25+
import { narrowHavingNumberComparands, narrowNumberComparands } from './number-comparand-declared-type-door.js';
26+
27+
const OBJECT = 'customer_support_case';
28+
const TARGET = 'customer_account';
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 must end. */
32+
const MARGIN = 40;
33+
const NESTED_OBJECT = { region: 'NA', tier: 'gold', status: 'open' };
34+
35+
const SCHEMA = {
36+
fields: {
37+
primary_account_owner: { name: 'primary_account_owner', type: 'lookup', reference: TARGET },
38+
secondary_account_owners: { name: 'secondary_account_owners', type: 'lookup', reference: TARGET, multiple: true },
39+
integration_metadata: { name: 'integration_metadata', type: 'json' },
40+
shipping_destination: { name: 'shipping_destination', type: 'address' },
41+
contract_value_amount: { name: 'contract_value_amount', type: 'number' },
42+
},
43+
};
44+
45+
/** The opening and the closing words of each kind's route. */
46+
const SINGLE_LOOKUP_ROUTE = [`Move the condition to 'where', or filter`, '"$in": [ID, …] } }.'] as const;
47+
const MULTI_LOOKUP_ROUTE = [`Move the condition to 'where': here`, 'matches one member of it.'] as const;
48+
const JSON_ROUTE = [`Test the whole value's presence with`, 'and filter that field.'] as const;
49+
const SCALAR_ROUTE = ["Compare '", '{ "$eq": VALUE } }).'] as const;
50+
51+
/** The refusal a client reads: status, code, the bounded `error` text, and the full message. */
52+
function clientReads(fn: () => unknown): { status: number; code: unknown; text: string; full: string } {
53+
let thrown: unknown;
54+
try { fn(); } catch (e) { thrown = e; }
55+
expect(thrown, 'the door refused').toBeInstanceOf(Error);
56+
const { status, body } = mapDataError(thrown);
57+
return { status, code: body.code, text: String(body.error), full: (thrown as Error).message };
58+
}
59+
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 {
62+
const read = clientReads(fn);
63+
expect(read.status).toBe(400);
64+
expect(read.code).toBe('INVALID_FILTER');
65+
const start = read.full.indexOf(open);
66+
expect(start, read.full).toBeGreaterThan(0);
67+
const closeAt = read.full.indexOf(close, start);
68+
expect(closeAt, read.full).toBeGreaterThan(start);
69+
const end = closeAt + close.length;
70+
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);
73+
const notApplied = read.full.indexOf(' was NOT applied');
74+
expect(notApplied, read.full).toBeGreaterThan(end - 1);
75+
}
76+
77+
const nested = (field: string) => ({ $and: [{ contract_value_amount: 1 }, { $or: [{ [field]: NESTED_OBJECT }] }] });
78+
79+
const aggregationFilter = (field: string) => () =>
80+
narrowNumberComparands(OBJECT, 'aggregate', SCHEMA, nested(field), 'aggregations[1].filter');
81+
82+
const having = (field: string, type: string) => () =>
83+
narrowHavingNumberComparands(OBJECT, { [field]: NESTED_OBJECT }, new Map(), new Map([[field, type]]));
84+
85+
describe('[#27337] the no-operator-object refusal: every route lands inside the client cap', () => {
86+
it('a single-valued lookup — aggregations[i].filter and having', () => {
87+
expectRouteInside(aggregationFilter('primary_account_owner'), SINGLE_LOOKUP_ROUTE);
88+
expectRouteInside(having('primary_account_owner', 'lookup'), SINGLE_LOOKUP_ROUTE);
89+
});
90+
91+
it('a multi-valued lookup — aggregations[i].filter', () => {
92+
expectRouteInside(aggregationFilter('secondary_account_owners'), MULTI_LOOKUP_ROUTE);
93+
});
94+
95+
it('a json and an address field — where, aggregations[i].filter and having', () => {
96+
for (const [field, type] of [['integration_metadata', 'json'], ['shipping_destination', 'address']] as const) {
97+
expectRouteInside(() => narrowNumberComparands(OBJECT, 'find', SCHEMA, nested(field)), JSON_ROUTE);
98+
expectRouteInside(aggregationFilter(field), JSON_ROUTE);
99+
expectRouteInside(having(field, type), JSON_ROUTE);
100+
}
101+
});
102+
103+
it('a scalar field — where, aggregations[i].filter and having', () => {
104+
expectRouteInside(() => narrowNumberComparands(OBJECT, 'find', SCHEMA, nested('contract_value_amount')), SCALAR_ROUTE);
105+
expectRouteInside(aggregationFilter('contract_value_amount'), SCALAR_ROUTE);
106+
expectRouteInside(having('contract_value_amount', 'number'), SCALAR_ROUTE);
107+
});
108+
});

0 commit comments

Comments
 (0)