Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/27337-no-operator-object-route-first.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/objectql': patch
---

fix(objectql): the no-operator-object filter refusal states what to write inside the 500-character client cap

Clause-②: no
Metadata-protocol: none

`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.
5 changes: 4 additions & 1 deletion packages/objectql/src/engine-nested-object-door.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,10 @@ describe('[#20745] a no-operator object beneath a relation, structured-JSON or p
const err = await refusalOf(engine.find(OBJECT, { where: { [field]: { a: 1 } } as FilterCondition }));
expect(envelopeOf(err), field).toEqual(ENVELOPE);
expect(err!.message, field).toContain(`at where.${field},`);
expect(err!.message, field).toContain(`as the value of the declared ${type} field '${field}'`);
// [#27337] The verdict names the column by its type (the head names the
// field); the reasoning after "NOT applied" names both.
expect(err!.message, field).toContain(`as the value of a ${type} field —`);
expect(err!.message, field).toContain(`the declared ${type} field '${field}' is matched whole`);
expect(err!.message, field).toContain('whole-value match');
expect(err!.message, field).toContain(`{ "${field}": { "$null": false } }`);
expect(err!.message, field).not.toContain('$contains');
Expand Down
10 changes: 7 additions & 3 deletions packages/objectql/src/engine-nested-relation-lowering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,18 +337,22 @@ describe('[#20802] the nested-relation form is lowered at the engine\'s where se
aggregations: [{ function: 'count', alias: 'all' }, { function: 'count', alias: 'n', filter: { owner: { region: 'NA' } } }],
} as EngineAggregateOptions));
expect(envelopeOf(perAggregation)).toEqual(INVALID_FILTER);
expect(perAggregation!.message).toContain("the nested-relation form, which the engine serves in 'where' and not in an aggregation's 'filter'");
// [#27337] The verdict says where the form is served; the reasoning after
// "NOT applied" names the position that does not serve it.
expect(perAggregation!.message).toContain("the nested-relation form, which the engine serves in 'where' only.");
expect(perAggregation!.message).toContain("The engine evaluates an aggregation's 'filter' itself");
expect(perAggregation!.message).toContain('{ "owner": { "$in": [ID, …] } }');
const multi = await refusalOf(engine.aggregate(OBJECT, {
aggregations: [{ function: 'count', alias: 'all' }, { function: 'count', alias: 'n', filter: { owners: { region: 'NA' } } }],
} as EngineAggregateOptions));
expect(multi!.message).toContain("Put the condition in 'where' instead: here the stored list of ids is compared as one value");
expect(multi!.message).toContain("Move the condition to 'where': here the stored list of ids is compared as one value");
expect(multi!.message).not.toContain('$contains');
const having = await refusalOf(engine.aggregate(OBJECT, {
groupBy: ['owner'], aggregations: [{ function: 'count', alias: 'n' }], having: { owner: { region: 'NA' } },
} as EngineAggregateOptions));
expect(envelopeOf(having)).toEqual(INVALID_FILTER);
expect(having!.message).toContain("which the engine serves in 'where' and not in 'having'");
expect(having!.message).toContain("which the engine serves in 'where' only.");
expect(having!.message).toContain("The engine evaluates 'having' itself");
expect(rec.reads).toHaveLength(0);
});

Expand Down
72 changes: 48 additions & 24 deletions packages/objectql/src/no-operator-object-door.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +259,19 @@ function describeObject(keys: readonly string[]): string {
return `an object with no operator key (keys ${shown}${more})`;
}

/** The column, as the words name it at its position. */
function describeColumn(refusal: NoOperatorObjectRefusal): string {
/**
* The column, as the words name it at its position. `named: false` is the
* short form the verdict uses: the head has already named the field twice
* (`filter on 'F' … at PATH.F`), and a third copy would cost the prescription
* its room inside the client cap.
*/
function describeColumn(refusal: NoOperatorObjectRefusal, named = true): string {
const { field, column } = refusal;
if (!named) {
if (refusal.aggregated) return `an aggregated ${column.type} column`;
if (column.provisioned) return `a platform-provisioned ${column.type} column`;
return `a ${column.type} field`;
}
if (refusal.aggregated) return `the aggregated column '${field}', which carries a ${column.type} value`;
if (column.provisioned) return `the platform-provisioned ${column.type} column '${field}'`;
return `the declared ${column.type} field '${field}'`;
Expand All @@ -271,18 +281,27 @@ function describeColumn(refusal: NoOperatorObjectRefusal): string {
* The scalar kind's words (#20546), less the context and the object.
*
* [#20745] Every kind's words put the verdict and the route FIRST and the
* reasoning after: the REST door bounds a 4xx message at 500 characters by
* truncation (`CLIENT_MESSAGE_MAX`, `@objectstack/rest`'s
* `error-response.ts`), so what a caller must do next has to land inside it.
* reasoning after: a client reads at most 500 characters of a 4xx message
* (`CLIENT_MESSAGE_MAX`, `@objectstack/types`' `mapDataError`, cut with `…`),
* so what a caller must do next has to land inside it.
*
* [#27337] Every kind is ordered the same way, and the cap stays: the verdict
* (short — the column by its type, since the head names the field), then the
* route, then "NOT applied" and the reasoning, which is the part a cut may
* take. The relation kind printed its `$in` route after the reasoning, past
* the cut on an aggregation's `filter` and `having`; with a long field name
* every kind did. `no-operator-object-prescription-cap.test.ts` pins each
* kind's route inside the cap, with margin, the same rule the temporal and
* text-operator doors follow.
*/
function scalarWords(refusal: NoOperatorObjectRefusal): string {
const { field, column } = refusal;
const { field } = refusal;
return (
`where a value of ${describeColumn(refusal)} belongs. An object with no "$" operator is filter `
+ 'structure, not a value. The filter was NOT applied. Compare '
+ `'${field}' with a value ({ "${field}": VALUE }) or an operator ({ "${field}": { "$eq": VALUE } }). `
+ `A ${column.type} column holds scalar values — one, or a list of them — so no record can match an `
+ 'object there, and an empty answer would read exactly like a real one.'
`where a value of ${describeColumn(refusal, false)} belongs. Compare '${field}' with a value `
+ `({ "${field}": VALUE }) or an operator ({ "${field}": { "$eq": VALUE } }). The filter was NOT `
+ `applied. An object with no "$" operator is filter structure, not a value: ${describeColumn(refusal)} `
+ 'holds scalar values — one, or a list of them — so no record can match an object there, and an '
+ 'empty answer would read exactly like a real one.'
);
}

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

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

Expand Down
108 changes: 108 additions & 0 deletions packages/objectql/src/no-operator-object-prescription-cap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#27337] The no-operator-object refusal states what to write instead INSIDE
* the 500 characters a client reads, for every kind of column it judges.
*
* The cap stays: `mapDataError` (`@objectstack/types`) cuts a 4xx message at
* `CLIENT_MESSAGE_MAX` with `…`. So `no-operator-object-door.ts` orders each
* kind's words verdict, route, then "NOT applied" and the reasoning, and this
* file reads every kind the way a client does — through `mapDataError` — and
* requires the whole route in what survives the cut, ending at least
* {@link MARGIN} characters before it, ahead of "NOT applied". Run against the
* door as it stood on `origin/main` `6d7634a9`, the single-valued lookup route
* is cut at both positions here, and every other kind's route sits after
* "NOT applied".
*
* The fixtures are long on purpose — a 21-character object, 20- to
* 24-character fields, a three-key object, a path through an aggregation's
* filter and a nested `$and` / `$or` — so the pin holds with margin, not only
* on a one-letter field.
*/

import { describe, it, expect } from 'vitest';
import { mapDataError } from '@objectstack/types';
import { narrowHavingNumberComparands, narrowNumberComparands } from './number-comparand-declared-type-door.js';

const OBJECT = 'customer_support_case';
const TARGET = 'customer_account';
/** The characters a client reads before the `…`: `CLIENT_MESSAGE_MAX - 1`. */
const CLIENT_CUT = 499;
/** How far inside the cut every route must end. */
const MARGIN = 40;
const NESTED_OBJECT = { region: 'NA', tier: 'gold', status: 'open' };

const SCHEMA = {
fields: {
primary_account_owner: { name: 'primary_account_owner', type: 'lookup', reference: TARGET },
secondary_account_owners: { name: 'secondary_account_owners', type: 'lookup', reference: TARGET, multiple: true },
integration_metadata: { name: 'integration_metadata', type: 'json' },
shipping_destination: { name: 'shipping_destination', type: 'address' },
contract_value_amount: { name: 'contract_value_amount', type: 'number' },
},
};

/** The opening and the closing words of each kind's route. */
const SINGLE_LOOKUP_ROUTE = [`Move the condition to 'where', or filter`, '"$in": [ID, …] } }.'] as const;
const MULTI_LOOKUP_ROUTE = [`Move the condition to 'where': here`, 'matches one member of it.'] as const;
const JSON_ROUTE = [`Test the whole value's presence with`, 'and filter that field.'] as const;
const SCALAR_ROUTE = ["Compare '", '{ "$eq": VALUE } }).'] as const;

/** The refusal a client reads: status, code, the bounded `error` text, and the full message. */
function clientReads(fn: () => unknown): { status: number; code: unknown; text: string; full: string } {
let thrown: unknown;
try { fn(); } catch (e) { thrown = e; }
expect(thrown, 'the door refused').toBeInstanceOf(Error);
const { status, body } = mapDataError(thrown);
return { status, code: body.code, text: String(body.error), full: (thrown as Error).message };
}

/** The route is whole in what the client reads, ends MARGIN inside the cut, and precedes "NOT applied". */
function expectRouteInside(fn: () => unknown, [open, close]: readonly [string, string]): void {
const read = clientReads(fn);
expect(read.status).toBe(400);
expect(read.code).toBe('INVALID_FILTER');
const start = read.full.indexOf(open);
expect(start, read.full).toBeGreaterThan(0);
const closeAt = read.full.indexOf(close, start);
expect(closeAt, read.full).toBeGreaterThan(start);
const end = closeAt + close.length;
expect(read.text, 'the client text holds the whole route').toContain(read.full.slice(start, end));
expect(end, `the route ends at ${end}, within ${MARGIN} of the ${CLIENT_CUT}-character cut`)
.toBeLessThanOrEqual(CLIENT_CUT - MARGIN);
const notApplied = read.full.indexOf(' was NOT applied');
expect(notApplied, read.full).toBeGreaterThan(end - 1);
}

const nested = (field: string) => ({ $and: [{ contract_value_amount: 1 }, { $or: [{ [field]: NESTED_OBJECT }] }] });

const aggregationFilter = (field: string) => () =>
narrowNumberComparands(OBJECT, 'aggregate', SCHEMA, nested(field), 'aggregations[1].filter');

const having = (field: string, type: string) => () =>
narrowHavingNumberComparands(OBJECT, { [field]: NESTED_OBJECT }, new Map(), new Map([[field, type]]));

describe('[#27337] the no-operator-object refusal: every route lands inside the client cap', () => {
it('a single-valued lookup — aggregations[i].filter and having', () => {
expectRouteInside(aggregationFilter('primary_account_owner'), SINGLE_LOOKUP_ROUTE);
expectRouteInside(having('primary_account_owner', 'lookup'), SINGLE_LOOKUP_ROUTE);
});

it('a multi-valued lookup — aggregations[i].filter', () => {
expectRouteInside(aggregationFilter('secondary_account_owners'), MULTI_LOOKUP_ROUTE);
});

it('a json and an address field — where, aggregations[i].filter and having', () => {
for (const [field, type] of [['integration_metadata', 'json'], ['shipping_destination', 'address']] as const) {
expectRouteInside(() => narrowNumberComparands(OBJECT, 'find', SCHEMA, nested(field)), JSON_ROUTE);
expectRouteInside(aggregationFilter(field), JSON_ROUTE);
expectRouteInside(having(field, type), JSON_ROUTE);
}
});

it('a scalar field — where, aggregations[i].filter and having', () => {
expectRouteInside(() => narrowNumberComparands(OBJECT, 'find', SCHEMA, nested('contract_value_amount')), SCALAR_ROUTE);
expectRouteInside(aggregationFilter('contract_value_amount'), SCALAR_ROUTE);
expectRouteInside(having('contract_value_amount', 'number'), SCALAR_ROUTE);
});
});
Loading