Skip to content

Commit 99eae83

Browse files
committed
fix(objectql): collection operators answer 400 INVALID_FILTER for a scalar comparand (#5869)
`FieldOperatorsSchema` declares `$in`/`$nin` as arrays and `$between` as a [min, max] tuple, but nothing enforced that on the way in: `isFilterAST` checks only the OPERATOR and `parseFilterAST` lowers whatever comparand it is handed, so `['status', 'not_in', 'done']` became `{ status: { $nin: 'done' } }` and reached the driver, where `driver-sql` handed a scalar to `whereIn()` and answered 500 DATABASE_ERROR -- a server-fault code for a filter the caller can fix, naming neither the operator nor the field. The gate goes at the engine's single filter collection point rather than in each driver: three backends answered three different ways for one declared contract, and both driver families are under an investment freeze (#5499). It runs on the LOWERED condition, which is what makes it cover the door the defect was actually measured through: the protocol face runs its own isFilterAST -> parseFilterAST and hands the engine a FilterCondition OBJECT, so a guard on the array branch alone would have left the reported 500 exactly where it was. `$between` arity is hoisted to the same seam -- driver-sql and driver-memory each already refuse it (wording kept verbatim), while driver-mongodb's arm falls through without emitting a range predicate. Not judged here: empty lists (declared predicates), list MEMBER types (#5234, another face), and non-collection operators. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
1 parent 80f7dc6 commit 99eae83

4 files changed

Lines changed: 501 additions & 1 deletion

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): 集合算子的标量比较值答 400 INVALID_FILTER 并点名期望形状,不再 500 DATABASE_ERROR
6+
7+
`FieldOperatorsSchema` 声明 `$in` / `$nin` 的比较值是数组、`$between` 是 `[min, max]` 二元组,但入口处没有任何一层强制这条声明:`isFilterAST` 只看算子,`parseFilterAST` 照单下降,于是 `['status', 'not_in', 'done']` 变成 `{ status: { $nin: 'done' } }` 一路走到驱动。
8+
9+
**行为变化(用户可见)**:此前 `driver-sql` 把标量交给 `whereIn(field, scalar)`,答 **500 `DATABASE_ERROR`** —— 用服务端故障码报告一个调用方能自己改好的过滤器,且不说明是哪个算子、哪个字段、该写成什么。现在引擎在唯一收口点拒收,答 **400 `INVALID_FILTER`**,信息点名算子(同时给出 `not_in` / `nin` / `notin` 这类作者实际书写的拼法)、字段、收到的值与位置、以及可直接粘贴的正确形状,并声明该过滤器**未被应用**。
10+
11+
覆盖两道门:直接调用引擎(`FilterArray` 下降路径)与 HTTP 面(协议层已自行下降成 `FilterCondition` 对象后再交给引擎)—— 后者正是本问题实测到的那道门。`find` / `findOne` / `count` / `aggregate` / `update` / `delete` 六个入口一致。
12+
13+
`$between` 的非二元组比较值一并收在同一处:`driver-sql` 与 `driver-memory` 各自已经拒收(措辞保持逐字一致),`driver-mongodb` 的分支则直接落空、不发射区间谓词 —— 收在收口点后三家答案一致。
14+
15+
**不变的**:`$in: []` / `$nin: []` 仍是合法谓词(分别表示「不匹配任何行」与「匹配所有行」);列表**成员**的类型不在此处复判(那是 #5234,另一个面);非集合算子的标量比较值不受影响,包括 `$gt` 的 ISO 日期字符串这类 `FieldOperatorsSchema` 声明更严、而各后端一致接受的形状。

‎packages/objectql/src/engine-filter-array-lowering.test.ts‎

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,197 @@ describe('Door 2 lowers FilterArray to FilterCondition before the driver (#5158)
304304
expect(reads).toHaveLength(0);
305305
});
306306

307+
// ── #5869: the list-shaped operators' comparands ──────────────────────
308+
//
309+
// `isFilterAST` vouches for the OPERATOR and nothing else, and
310+
// `parseFilterAST` lowers whatever comparand it is handed. So the shapes
311+
// below passed both, reached the driver, and — on driver-sql, via
312+
// `whereIn(field, scalar)` — came back as `500 DATABASE_ERROR`: a
313+
// server-fault code for a filter the caller can fix, naming neither the
314+
// operator nor the field. Same collection point, same envelope as the
315+
// refusals above.
316+
317+
it.each([
318+
['not_in', [['stage', 'not_in', 'won']]],
319+
['nin', [['stage', 'nin', 'won']]],
320+
['notin', [['stage', 'notin', 'won']]],
321+
['in', [['stage', 'in', 'won']]],
322+
])('refuses a scalar comparand on the collection operator %s', async (_op, where) => {
323+
await expect(engine.find('deal', { where } as any))
324+
.rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
325+
// Nothing ran: a refused filter must not reach the driver at all, or the
326+
// 400 would be describing a query that already returned rows.
327+
expect(reads).toHaveLength(0);
328+
});
329+
330+
it('the refusal NAMES the operator, the field and the expected shape (#5346/#5348 wording)', async () => {
331+
const err = await engine.find('deal', { where: [['stage', 'not_in', 'won']] } as any)
332+
.then(() => null, (e: any) => e);
333+
334+
expect(err).not.toBeNull();
335+
// The entry point that refused, matching the sibling refusals above.
336+
expect(err.message).toMatch(/^find\('deal'\): /);
337+
// The operator, in the lowered spelling…
338+
expect(err.message).toMatch(/Operator "\$nin"/);
339+
// …and in the spellings an author actually types on a ViewFilterRule —
340+
// nobody writes `$nin` into metadata, so a refusal naming only the lowered
341+
// form sends them looking for a key their file does not contain.
342+
expect(err.message).toMatch(/not_in/);
343+
// The member.
344+
expect(err.message).toMatch(/field "stage"/);
345+
// What was received, and where.
346+
expect(err.message).toMatch(/Received string \("won"\)/);
347+
expect(err.message).toMatch(/where\.stage\.\$nin/);
348+
// The expected shape, as a value the caller can paste.
349+
expect(err.message).toMatch(/\["won"\]/);
350+
// The alternative, for the caller who meant a scalar comparison.
351+
expect(err.message).toMatch(/"!=" \(\$ne\)/);
352+
// And the part a status code cannot carry.
353+
expect(err.message).toMatch(/NOT applied/);
354+
expect(err.message).toMatch(/UNFILTERED result set/);
355+
});
356+
357+
it('the whole refusal survives the REST boundary — it fits under CLIENT_MESSAGE_MAX', async () => {
358+
// `rest-server.ts` truncates a declared-4xx message at 500 chars before it
359+
// reaches the client (#5423 made it a truncation rather than a swap). The
360+
// "NOT applied" sentence is the part a caller cannot infer from a status
361+
// code, and it sits at the END — so a message that overflows loses exactly
362+
// the sentence the refusal exists to deliver. Pinned here rather than
363+
// trusted, because the bound lives in another package.
364+
const CLIENT_MESSAGE_MAX = 500;
365+
for (const where of [
366+
[['stage', 'not_in', 'won']],
367+
[['stage', 'in', 'won']],
368+
[['amount', 'between', 5]],
369+
]) {
370+
const err = await engine.find('deal', { where } as any)
371+
.then(() => null, (e: any) => e);
372+
expect(err.message.length, JSON.stringify(where)).toBeLessThan(CLIENT_MESSAGE_MAX);
373+
expect(err.message, JSON.stringify(where)).toMatch(/UNFILTERED result set/);
374+
}
375+
});
376+
377+
it('refuses through the OBJECT door too — the door #5869 was measured through', async () => {
378+
// The protocol/HTTP face runs its own `isFilterAST` → `parseFilterAST` and
379+
// hands the engine an already-lowered FilterCondition, so the array branch
380+
// above never sees a wire query. This is that shape, arriving as an object.
381+
await expect(engine.find('deal', { where: { stage: { $nin: 'won' } } } as any))
382+
.rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
383+
await expect(engine.find('deal', { where: { stage: { $in: 'won' } } } as any))
384+
.rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
385+
});
386+
387+
it.each([
388+
['null', { stage: { $in: null } }],
389+
['a number', { amount: { $in: 10 } }],
390+
['an object', { stage: { $in: { a: 1 } } }],
391+
])('refuses a comparand that is %s — every non-list, not just strings', async (_l, where) => {
392+
await expect(engine.find('deal', { where } as any))
393+
.rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
394+
});
395+
396+
it('walks into $and / $or / $not — a nested scalar is refused with its own path', async () => {
397+
const err = await engine.find('deal', {
398+
where: ['and', ['amount', '>', 5], ['stage', 'not_in', 'won']],
399+
} as any).then(() => null, (e: any) => e);
400+
expect(err?.status).toBe(400);
401+
expect(err.message).toMatch(/where\.\$and\[1\]\.stage\.\$nin/);
402+
403+
await expect(engine.find('deal', { where: { $not: { stage: { $in: 'won' } } } } as any))
404+
.rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
405+
});
406+
407+
it('every engine entry point refuses it, not just find()', async () => {
408+
const where = [['stage', 'not_in', 'won']];
409+
await expect(engine.findOne('deal', { where } as any)).rejects.toMatchObject({ status: 400 });
410+
await expect(engine.count('deal', { where } as any)).rejects.toMatchObject({ status: 400 });
411+
await expect(engine.aggregate('deal', {
412+
where, groupBy: ['stage'], aggregations: [{ function: 'count', field: 'id', alias: 'n' }],
413+
} as any)).rejects.toMatchObject({ status: 400 });
414+
await expect(engine.update('deal', { amount: 1 }, { where, multi: true } as any))
415+
.rejects.toMatchObject({ status: 400 });
416+
await expect(engine.delete('deal', { where, multi: true } as any))
417+
.rejects.toMatchObject({ status: 400 });
418+
// Refused before any of them touched the store.
419+
expect(reads).toHaveLength(0);
420+
expect(writes).toHaveLength(0);
421+
expect(await engine.count('deal')).toBe(3);
422+
});
423+
424+
// `$between`'s arity, hoisted to the same seam. driver-sql and driver-memory
425+
// each already refuse this (#5328); driver-mongodb's arm falls through
426+
// without emitting a range predicate. Checking here is what makes the three
427+
// agree — the same reason the collection point exists.
428+
it.each([
429+
['a scalar', [['amount', 'between', 5]]],
430+
['a 1-tuple', [['amount', 'between', [1]]]],
431+
['a 3-tuple', [['amount', 'between', [1, 2, 3]]]],
432+
])('refuses a $between comparand that is %s', async (_l, where) => {
433+
await expect(engine.find('deal', { where } as any))
434+
.rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
435+
});
436+
437+
it('the $between refusal keeps the platform-wide wording and names the field', async () => {
438+
const err = await engine.find('deal', { where: [['amount', 'between', 5]] } as any)
439+
.then(() => null, (e: any) => e);
440+
// Verbatim leading sentence from driver-sql / driver-memory: one condition,
441+
// one wording, wherever the caller meets it.
442+
expect(err.message).toMatch(
443+
/Operator "\$between" on field "amount" requires a \[min, max\] value array\./,
444+
);
445+
expect(err.message).toMatch(/where\.amount\.\$between/);
446+
});
447+
448+
// ── what must KEEP working: the declared list shapes ───────────────────
449+
450+
it('a proper list comparand still reaches the driver untouched', async () => {
451+
await engine.find('deal', { where: [['stage', 'in', ['won', 'lost']]] } as any);
452+
expect(lastWhere()).toEqual({ stage: { $in: ['won', 'lost'] } });
453+
454+
await engine.find('deal', { where: [['stage', 'not_in', ['lost']]] } as any);
455+
expect(lastWhere()).toEqual({ stage: { $nin: ['lost'] } });
456+
457+
await engine.find('deal', { where: [['amount', 'between', [5, 25]]] } as any);
458+
expect(lastWhere()).toEqual({ amount: { $between: [5, 25] } });
459+
});
460+
461+
it('an EMPTY list is a declared predicate, not a malformed one', async () => {
462+
// `$in: []` matches nothing and `$nin: []` matches everything — both
463+
// drivers say so in as many words. Arity is not this gate's business.
464+
await engine.find('deal', { where: { stage: { $in: [] } } } as any);
465+
expect(lastWhere()).toEqual({ stage: { $in: [] } });
466+
await engine.find('deal', { where: { stage: { $nin: [] } } } as any);
467+
expect(lastWhere()).toEqual({ stage: { $nin: [] } });
468+
});
469+
470+
it('the gate does not re-judge list MEMBERS — that is #5234, on another face', async () => {
471+
// A `$field` reference and a plain object are both legitimate members here;
472+
// this gate asks only whether the comparand is a list at all.
473+
const where = { stage: { $in: [{ $field: 'other' }, 'won'] } };
474+
await engine.find('deal', { where } as any);
475+
expect(lastWhere()).toEqual(where);
476+
});
477+
478+
it('does not descend into a deep-equality comparand that merely LOOKS like an operator map', async () => {
479+
// `{ $eq: {...} }` holds DATA. A gate that walked into it would refuse a
480+
// stored document whose own key happens to be `$in` — a stricter contract
481+
// than any backend applies.
482+
const where = { stage: { $eq: { $in: 'not-an-operator-here' } } };
483+
await engine.find('deal', { where } as any);
484+
expect(lastWhere()).toEqual(where);
485+
});
486+
487+
it('a scalar on a NON-collection operator is untouched', async () => {
488+
await engine.find('deal', { where: [['stage', '!=', 'won']] } as any);
489+
expect(lastWhere()).toEqual({ stage: { $ne: 'won' } });
490+
// String bounds on a range comparison stay legal — `FieldOperatorsSchema`
491+
// declares `$gt` as number|Date|FieldReference, but ISO strings are what the
492+
// showcase apps send and every backend accepts. This gate enforces the
493+
// three list declarations, not the whole schema.
494+
await engine.find('deal', { where: [['stage', '>', '2026-01-01']] } as any);
495+
expect(lastWhere()).toEqual({ stage: { $gt: '2026-01-01' } });
496+
});
497+
307498
// ── the object form is untouched ──────────────────────────────────────
308499

309500
it('a FilterCondition object passes through byte-for-byte', async () => {

‎packages/objectql/src/engine.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyD
2121
// [#5158] Door 2's lowering sink — the SAME pair the protocol face (Door 1)
2222
// runs, so `FilterArray` has exactly one lowering in the product.
2323
import { isFilterAST, parseFilterAST, VALID_AST_OPERATORS } from '@objectstack/spec/data';
24+
import { assertListComparandShapes } from './filter-comparand-shape.js';
2425
import {
2526
DATA_MIGRATION_FLAG_OBJECT,
2627
FILE_REFERENCES_MIGRATION_ID,
@@ -451,7 +452,16 @@ function lowerWhereFilterArray<T extends object | undefined>(
451452
): T {
452453
if (!bag) return bag;
453454
const where = (bag as Record<string, unknown>).where;
454-
if (!Array.isArray(where)) return bag;
455+
if (!Array.isArray(where)) {
456+
// [#5869] Door 1 lands HERE, not below: the protocol face runs its own
457+
// `isFilterAST` → `parseFilterAST` and hands the engine an already-lowered
458+
// `FilterCondition` object, so a gate on the array branch alone would miss
459+
// every query that arrived over the wire. The comparand check is the same
460+
// one either way — it reads the lowered condition, which is what both doors
461+
// produce.
462+
assertListComparandShapes(object, operation, where);
463+
return bag;
464+
}
455465

456466
const lowered: Record<string, unknown> = { ...bag };
457467

@@ -488,6 +498,11 @@ function lowerWhereFilterArray<T extends object | undefined>(
488498
`unfiltered (#5158).`,
489499
);
490500
}
501+
// [#5869] Door 2's half of the same check. `isFilterAST` vouched for the
502+
// OPERATOR and `parseFilterAST` lowered it, but neither looks at the
503+
// comparand — `['status', 'not_in', 'done']` lowers to `{status: {$nin:
504+
// 'done'}}` and a scalar `$nin` is what reached the driver as a 500.
505+
assertListComparandShapes(object, operation, condition);
491506
lowered.where = condition;
492507
return lowered as T;
493508
}

0 commit comments

Comments
 (0)