Skip to content

Commit 4475c59

Browse files
os-zhuangclaude
andauthored
fix(metadata)!: a $filter array that is not a filter AST is rejected (#4121) (#4200)
isFilterAST was read as a CONVERSION gate: an array it refused was assigned to options.where unconverted, leaving each backend to make sense of a value the protocol had already decided it could not parse. Filed as item 2 of #3948 — error locality. The investigation found more. It closes the last silently-unfiltered shape. #3948 made the drivers throw on a bare triple with an unknown operator and on any element that is neither a join keyword nor a condition array. What it could not reach is a lone ['and'] / ['or']: the driver sets its join mode, matches no element, emits NO predicate, and returns every row. isFilterAST refuses it (a logical node needs length >= 2), so it arrived as an opaque `where` and no driver-side check applied. For every other shape this is not a narrowing: driver-sql throws, driver-memory throws, driver-mongodb fails at the server. This changes WHICH error the caller sees, not whether there is one — and puts the request's own vocabulary in the message instead of a driver's builder state. Scoped narrowly, because the regression to fear is rejecting something valid: - only arrays are in scope; a `where` OBJECT is untouched ($and/$or/$gte); - an empty [] is left alone — it means "no filter"; - isFilterAST accepts nested arrays, so [[a,'=',1],[b,'=',2]] and ['and', […], ['or', …]] keep converting. A naive "arrays are suspect" rule would have broken those, so the accepted shapes are pinned by more tests than the rejected ones. status 400 + code INVALID_FILTER, matching UNSUPPORTED_QUERY_PARAM alongside. 12 new tests driving the real findData normalisation rather than a re-implementation of its rule; reverting the change fails six. Package suite 122 tests / 19 files green. Closes #4121. Refs #3948, objectstack-ai/objectui#2945 Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent d82f8c0 commit 4475c59

3 files changed

Lines changed: 264 additions & 1 deletion

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
---
4+
5+
fix(metadata)!: a `$filter` array that is not a filter AST is rejected, not passed through (#4121)
6+
7+
`isFilterAST` was being read as a *conversion* gate: an array it refused was
8+
assigned to `options.where` unconverted, leaving each backend to make sense of a
9+
value the protocol had already decided it could not parse.
10+
11+
Item 2 of #3948, filed as error-locality work. The investigation found it is
12+
more than that.
13+
14+
**It closes the last silently-unfiltered shape.** #3948 made the drivers throw
15+
on a bare triple with an unknown operator and on any element that is neither a
16+
join keyword nor a condition array. What it could not reach is a lone `['and']`
17+
or `['or']`: the driver sets its join mode, matches no element, emits **no
18+
predicate**, and returns every row. `isFilterAST` refuses it (a logical node
19+
needs `length >= 2`), so it arrived as an opaque `where` and no driver-side
20+
check applied. That is now a 400.
21+
22+
**For every other shape this is not a narrowing.** driver-sql throws on all of
23+
them, driver-memory throws, driver-mongodb reaches its own parser and fails at
24+
the server. Rejecting at the protocol changes *which* error the caller sees, not
25+
*whether* there is one — and the message is in the request's own vocabulary
26+
(`unrecognised operator "not in"`, `element 1 is number`, plus the recognised
27+
operator list) rather than a driver's internal builder state.
28+
29+
Scoped narrowly, because the regression to fear is rejecting something valid:
30+
31+
- only `Array.isArray(filter)` values are in scope — a `where` **object** is
32+
untouched, including `$and`/`$or`/`$gte` shapes;
33+
- an empty `[]` is left alone: it means "no filter", and every path already
34+
treats it that way;
35+
- `isFilterAST` accepts nested arrays, so `[[a,'=',1],[b,'=',2]]` and
36+
`['and', […], ['or', …]]` keep converting. A naive "arrays are suspect" rule
37+
would have broken exactly those, which is why the accepted shapes are pinned
38+
by more tests than the rejected ones.
39+
40+
Errors carry `status: 400` and `code: 'INVALID_FILTER'`, matching the
41+
`UNSUPPORTED_QUERY_PARAM` convention alongside.
42+
43+
Verified: 12 new tests driving the real `findData` normalisation, not a
44+
re-implementation of its rule — six for shapes that must keep converting, six
45+
for shapes that must be rejected, including the exact message text. Reverting the
46+
change fails six of them. Full `@objectstack/metadata-protocol` suite: **122
47+
tests across 19 files**, green.
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A `$filter` that looks like a filter AST but is not one is rejected at the
5+
* protocol, not handed to the driver as an opaque `where` (#4121).
6+
*
7+
* `isFilterAST` was being read as a *conversion* gate: an array it refused was
8+
* assigned to `options.where` unconverted, leaving each backend to make sense of
9+
* it. #3948 made the drivers throw rather than skip, so rejecting here buys
10+
* (a) one error instead of a different one per backend, (b) a message in the
11+
* *request's* vocabulary rather than a builder's internal state, and (c) the one
12+
* shape the driver-side fix could not reach.
13+
*
14+
* That shape is a lone `['and']`: the driver sets its join mode, matches no
15+
* element, emits no predicate, and returns **every row** — silently. It is the
16+
* last survivor of the class #3948 closed, which makes this a correctness fix
17+
* and not only an ergonomic one.
18+
*
19+
* The regression this change could plausibly cause is rejecting a filter that
20+
* IS valid, so the accepted shapes are pinned at least as hard as the rejected
21+
* ones. `isFilterAST` accepts nested arrays, and a naive "arrays are suspect"
22+
* rule would have broken `[[a,'=',1],[b,'=',2]]`.
23+
*/
24+
import { describe, it, expect, vi } from 'vitest';
25+
import { ObjectStackProtocolImplementation } from './protocol.js';
26+
27+
const SCHEMA = {
28+
name: 'invoice',
29+
fields: {
30+
status: { name: 'status', type: 'text' },
31+
close_date: { name: 'close_date', type: 'date' },
32+
amount: { name: 'amount', type: 'number' },
33+
stage: { name: 'stage', type: 'text' },
34+
a: { name: 'a', type: 'number' },
35+
b: { name: 'b', type: 'number' },
36+
c: { name: 'c', type: 'number' },
37+
},
38+
};
39+
40+
function makeProtocol() {
41+
const find = vi.fn(async () => []);
42+
const engine = {
43+
registry: { getObject: (n: string) => (n === 'invoice' ? SCHEMA : undefined) },
44+
find,
45+
count: vi.fn(async () => 0),
46+
};
47+
return { p: new ObjectStackProtocolImplementation(engine as any), find };
48+
}
49+
50+
/** Run a `$filter` through the real `findData` normalisation. */
51+
async function whereFor(filter: unknown): Promise<unknown> {
52+
const { p, find } = makeProtocol();
53+
await p.findData({ object: 'invoice', query: { $filter: filter } } as never);
54+
return (find.mock.calls[0] as unknown[])[1] as unknown;
55+
}
56+
57+
async function errorFor(filter: unknown): Promise<{ message: string; status?: number; code?: string }> {
58+
const { p } = makeProtocol();
59+
try {
60+
await p.findData({ object: 'invoice', query: { $filter: filter } } as never);
61+
} catch (e) {
62+
const err = e as Error & { status?: number; code?: string };
63+
return { message: err.message, status: err.status, code: err.code };
64+
}
65+
throw new Error(`expected ${JSON.stringify(filter)} to be rejected`);
66+
}
67+
68+
describe('filters that must keep working', () => {
69+
it('converts a bare comparison triple', async () => {
70+
expect(await whereFor(['status', '=', 'active'])).toMatchObject({ where: { status: 'active' } });
71+
});
72+
73+
it('converts a flat list of conditions — the shape a naive rule would break', async () => {
74+
const opts = await whereFor([['a', '=', 1], ['b', '=', 2]]) as { where: unknown };
75+
expect(opts.where).toBeDefined();
76+
expect(Array.isArray(opts.where)).toBe(false);
77+
});
78+
79+
it('converts logical nodes, including nested ones', async () => {
80+
for (const f of [
81+
['and', ['a', '=', 1], ['b', '=', 2]],
82+
['or', ['a', '=', 1], ['b', '=', 2]],
83+
['and', ['a', '=', 1], ['or', ['b', '=', 2], ['c', '=', 3]]],
84+
]) {
85+
const opts = await whereFor(f) as { where: unknown };
86+
expect(Array.isArray(opts.where), JSON.stringify(f)).toBe(false);
87+
}
88+
});
89+
90+
it('converts the date operators #3948 added to the AST vocabulary', async () => {
91+
for (const op of ['before', 'after']) {
92+
const opts = await whereFor(['close_date', op, '2024-01-01']) as { where: unknown };
93+
expect(Array.isArray(opts.where), op).toBe(false);
94+
}
95+
});
96+
97+
it('leaves an empty filter alone — it means "no filter"', async () => {
98+
const opts = await whereFor([]) as { where: unknown };
99+
expect(opts.where).toEqual([]);
100+
});
101+
102+
it('never touches a `where` object; only arrays are in scope', async () => {
103+
for (const f of [{ status: 'active' }, { $and: [{ a: 1 }, { b: 2 }] }, { amount: { $gte: 100 } }]) {
104+
const opts = await whereFor(f) as { where: unknown };
105+
expect(opts.where).toEqual(f);
106+
}
107+
});
108+
});
109+
110+
describe('filters that must be rejected', () => {
111+
it('rejects a lone logical keyword — the remaining silent-unfiltered shape', async () => {
112+
// The driver sets its join mode, matches nothing, emits no predicate,
113+
// and returns every row. Nothing downstream can catch this.
114+
for (const f of [['and'], ['or']]) {
115+
const err = await errorFor(f);
116+
expect(err.message).toContain('has no conditions to join');
117+
expect(err.status).toBe(400);
118+
expect(err.code).toBe('INVALID_FILTER');
119+
}
120+
});
121+
122+
it('rejects a triple whose operator is outside the AST vocabulary, and names it', async () => {
123+
const err = await errorFor(['status', 'bogusop', 'x']);
124+
expect(err.message).toContain('unrecognised operator "bogusop"');
125+
expect(err.status).toBe(400);
126+
});
127+
128+
it('rejects the space-spelled `not in`, which no vocabulary defines', async () => {
129+
const err = await errorFor(['stage', 'not in', ['won', 'lost']]);
130+
expect(err.message).toContain('unrecognised operator "not in"');
131+
});
132+
133+
it('rejects an array of condition OBJECTS — a different filter dialect', async () => {
134+
const err = await errorFor([{ field: 'a', operator: '=', value: 1 }]);
135+
expect(err.status).toBe(400);
136+
});
137+
138+
it('rejects a mixed array with a non-condition element, naming its position', async () => {
139+
expect((await errorFor([['a', '=', 1], 42])).message).toContain('element 1 is number');
140+
expect((await errorFor([['a', '=', 1], null])).message).toContain('element 1 is null');
141+
});
142+
143+
it('quotes the recognised vocabulary, so the fix is in the error', async () => {
144+
const err = await errorFor(['status', 'bogusop', 'x']);
145+
expect(err.message).toContain('Recognised operators:');
146+
expect(err.message).toContain('not_in');
147+
expect(err.message).toContain('starts_with');
148+
});
149+
});

packages/metadata-protocol/src/protocol.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import type {
1818
} from '@objectstack/spec/api';
1919
import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities } from '@objectstack/spec/api';
2020
import { readServiceSelfInfo } from '@objectstack/spec/api';
21-
import { parseFilterAST, isFilterAST, type DroppedFieldsEvent, type QueryAST } from '@objectstack/spec/data';
21+
import { parseFilterAST, isFilterAST, VALID_AST_OPERATORS, type DroppedFieldsEvent, type QueryAST } from '@objectstack/spec/data';
2222
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
2323
import { type FormView, isAggregatedViewContainer } from '@objectstack/spec/ui';
2424
import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system';
@@ -300,6 +300,59 @@ function resolveOverlaySchema(type: string, _item: unknown): z.ZodTypeAny | null
300300
return getMetadataTypeSchema(singular) ?? null;
301301
}
302302

303+
/**
304+
* A 400 for a `$filter` that looks like a filter AST but is not one.
305+
*
306+
* The message has to be *actionable from the request*, which is the whole point
307+
* of rejecting here rather than letting a driver fail later: the caller sent a
308+
* query parameter, so the error names the offending element and the vocabulary
309+
* it was checked against — not a driver-internal builder state.
310+
*
311+
* Diagnoses the three shapes `isFilterAST` refuses, in the order they occur in
312+
* practice. #4121.
313+
*/
314+
function malformedFilterError(filter: unknown[]): Error {
315+
const detail = describeMalformedFilter(filter);
316+
const err: any = new Error(
317+
`Malformed $filter: ${detail} A filter array is a comparison ` +
318+
`[field, operator, value], a logical node ["and"|"or", ...conditions], or a ` +
319+
`list of those. Recognised operators: ${[...VALID_AST_OPERATORS].sort().join(', ')}.`,
320+
);
321+
err.status = 400;
322+
err.code = 'INVALID_FILTER';
323+
return err;
324+
}
325+
326+
/** The specific reason a filter array failed `isFilterAST`, for the message above. */
327+
function describeMalformedFilter(filter: unknown[]): string {
328+
const [first, second] = filter;
329+
const isKeyword = typeof first === 'string' && ['and', 'or'].includes(first.toLowerCase());
330+
331+
// `["and"]` / `["or"]` with nothing to join. The one shape that still
332+
// returned every row silently after #3948: the driver sets its join mode,
333+
// matches no element, and emits no predicate.
334+
if (isKeyword && filter.length < 2) {
335+
return `logical node ["${String(first)}"] has no conditions to join.`;
336+
}
337+
// A bare triple whose operator is outside the AST vocabulary — the original
338+
// `before` / `after` / `'not in'` case.
339+
if (typeof first === 'string' && !isKeyword && typeof second === 'string'
340+
&& !VALID_AST_OPERATORS.has(second.toLowerCase())) {
341+
return `unrecognised operator "${second}" in [${JSON.stringify(first)}, ...].`;
342+
}
343+
// An element that is neither a join keyword nor a nested condition.
344+
const badIndex = filter.findIndex(
345+
(item) => !Array.isArray(item)
346+
&& !(typeof item === 'string' && ['and', 'or'].includes(item.toLowerCase())),
347+
);
348+
if (badIndex >= 0 && filter.some((item) => Array.isArray(item))) {
349+
const bad = filter[badIndex];
350+
return `element ${badIndex} is ${bad === null ? 'null' : typeof bad}, ` +
351+
`expected a condition array or a logical keyword.`;
352+
}
353+
return `${JSON.stringify(filter)} is not a recognised filter shape.`;
354+
}
355+
303356
/**
304357
* Guarantee a `view` body carries a top-level `name`.
305358
*
@@ -3102,6 +3155,20 @@ export class ObjectStackProtocolImplementation implements
31023155
// Filter AST array → FilterCondition object
31033156
if (isFilterAST(parsedFilter)) {
31043157
parsedFilter = parseFilterAST(parsedFilter);
3158+
} else if (Array.isArray(parsedFilter) && parsedFilter.length > 0) {
3159+
// `isFilterAST` was being read as a *conversion* gate, so an array
3160+
// it refused was assigned to `where` unconverted — an opaque value
3161+
// the driver then had to make sense of. Every driver now fails on
3162+
// it, so this is not a narrowing; it moves the failure to where the
3163+
// malformed filter actually arrived, with the request's own
3164+
// vocabulary in the message instead of a driver-internal one.
3165+
//
3166+
// It also closes what the driver-side fix could not: a lone
3167+
// `['and']` / `['or']` sets the join mode, matches no element, and
3168+
// emits NO predicate — the last shape that still returned every row
3169+
// silently after #3948. An empty `[]` is left alone: it means "no
3170+
// filter", and every path already treats it that way. #4121.
3171+
throw malformedFilterError(parsedFilter);
31053172
}
31063173
options.where = parsedFilter;
31073174
}

0 commit comments

Comments
 (0)