Skip to content

Commit 6f98c2d

Browse files
os-zhuangclaude
andauthored
fix(driver-sql,driver-memory): an uncompilable filter throws instead of matching everything (#3948) (#4029)
A filter neither driver could compile was **skipped**, not rejected: no predicate was emitted and the query returned every row. The caller asked to filter and silently received the unfiltered set. The reachable shape is a bare comparison triple. `['close_date','before',…]` arrives at a driver only when `isFilterAST()` refused it — the operator is outside `VALID_AST_OPERATORS`, so `parseFilterAST()` never converted it and the raw array became `where`. driver-sql's loop then saw three *strings*, matched neither `and` nor `or`, and `continue`d past all three. driver-memory was worse: it cast every string to a logic keyword, opened three empty groups and returned `{}` — a filter matching every record. Reachable from ordinary authoring, not just malformed input: `before`/`after` are canonical `VIEW_FILTER_OPERATORS` members that `VALID_AST_OPERATORS` does not accept. Eight of the nineteen canonical view operators are in that position, including `equals`; the rest were masked only because ObjectUI's adapter alias table happened to cover them. Both drivers now throw on an element that is neither a logical keyword nor a condition array. The nested and `$`-object paths already threw on the same input, so this makes the three paths agree rather than disagree. driver-memory also gains seven operators it silently ignored — `not_in`, `is_null`, `is_not_null`, `isnull`, `isnotnull`, `is_empty`, `is_not_empty`, all in `VALID_AST_OPERATORS`, all previously hitting `default: return null`. `is_null` narrowed nothing instead of matching null rows. Alias sets and semantics mirror driver-sql's `whereNull`/`whereNotNull` arms so both backends accept one vocabulary. Throwing without implementing these would have traded a silent bug for a hard regression on operators the spec blesses. Tests: driver-sql 487 pass, driver-memory 153 pass, objectql 1171 pass. Closes #3948 item 1 Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8f2cb78 commit 6f98c2d

5 files changed

Lines changed: 326 additions & 10 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/driver-memory": minor
3+
"@objectstack/driver-sql": patch
4+
---
5+
6+
fix(driver-sql,driver-memory): an uncompilable filter now throws instead of matching everything (#3948)
7+
8+
A filter the driver could not compile was **skipped**, not rejected. No predicate
9+
was emitted and the query returned every row — the caller asked to filter and
10+
silently received the unfiltered set.
11+
12+
The reachable shape is a bare comparison triple. `['close_date','before','2024-01-01']`
13+
arrives at a driver only when `isFilterAST()` refused it — its operator is outside
14+
`VALID_AST_OPERATORS`, so `parseFilterAST()` never converted it and the raw array
15+
was assigned to `where`. `driver-sql`'s loop then saw three *strings*, matched
16+
neither `and` nor `or`, and `continue`d past all three. `driver-memory` was worse:
17+
it cast every string to a logic keyword, opening three empty groups and returning
18+
`{}` — a filter matching every record.
19+
20+
This is reachable from ordinary authoring, not just malformed input: `before` and
21+
`after` are canonical `VIEW_FILTER_OPERATORS` members that `VALID_AST_OPERATORS`
22+
does not accept. Eight of the nineteen canonical view operators are in that
23+
position, including `equals`; the others were masked only because ObjectUI's
24+
adapter alias table happened to cover them.
25+
26+
**Behaviour change.** Both drivers now throw on a filter element that is neither a
27+
logical keyword (`and`/`or`) nor a condition array, and `driver-memory` throws on
28+
an operator it cannot express rather than dropping the condition. The nested and
29+
`$`-object paths already threw on the same input, so this makes the three paths
30+
agree. A caller that was relying on the old silence was receiving wrong results;
31+
the error names the operator and the offending filter.
32+
33+
**`driver-memory` also gains seven operators it silently ignored:** `not_in`,
34+
`is_null`, `is_not_null`, `isnull`, `isnotnull`, `is_empty`, `is_not_empty` — all
35+
members of `VALID_AST_OPERATORS`, all previously falling through to
36+
`default: return null`. `is_null` narrowed nothing instead of matching null rows.
37+
Alias sets and semantics mirror `driver-sql`'s `whereNull`/`whereNotNull` arms so
38+
the two backends accept one vocabulary.
39+
40+
Migration: none for well-formed filters. If a query now throws, the filter was
41+
never being applied — fix the operator (the message names it), or lower it to an
42+
AST spelling. `before``<`, `after``>`, `'not in'``nin`.

packages/plugins/driver-memory/src/memory-driver.ts

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -703,15 +703,40 @@ export class InMemoryDriver implements IDataDriver {
703703

704704
for (const item of filters) {
705705
if (typeof item === 'string') {
706-
const newLogic = item.toLowerCase() as 'and' | 'or';
707-
if (newLogic !== currentLogic) {
708-
currentLogic = newLogic;
706+
const lower = item.toLowerCase();
707+
// Previously this cast ANY string to 'and' | 'or'. A bare comparison
708+
// triple — which reaches a driver only when `isFilterAST()` refused its
709+
// operator, leaving the array unparsed — therefore opened three empty
710+
// logic groups, produced no conditions, and returned `{}`: a filter that
711+
// matches EVERY record. An unapplied filter must not look like a
712+
// satisfied one. #3948.
713+
if (lower !== 'and' && lower !== 'or') {
714+
throw new Error(
715+
`[driver-memory] Unrecognized filter operator "${item}" in a comparison triple. ` +
716+
`A filter array is either a logical node (["and"|"or", …]) or nested ` +
717+
`conditions ([[field, op, value], …]); a bare [field, op, value] only ` +
718+
`reaches the driver when its operator is outside @objectstack/spec ` +
719+
`VALID_AST_OPERATORS, which leaves the filter unparsed. ` +
720+
`Filter was: ${JSON.stringify(filters)}`,
721+
);
722+
}
723+
if (lower !== currentLogic) {
724+
currentLogic = lower;
709725
logicGroups.push({ logic: currentLogic, conditions: [] });
710726
}
711727
} else if (Array.isArray(item)) {
712728
const [field, operator, value] = item;
729+
// `convertConditionToMongo` now throws rather than returning null for an
730+
// operator it cannot express, so a dropped condition can no longer
731+
// silently widen the result set.
713732
const cond = this.convertConditionToMongo(field, operator, value);
714733
if (cond) logicGroups[logicGroups.length - 1].conditions.push(cond);
734+
} else {
735+
throw new Error(
736+
`[driver-memory] Unrecognized filter element of type ` +
737+
`"${item === null ? 'null' : typeof item}" — expected a logical keyword ` +
738+
`("and"/"or") or a condition array. Filter was: ${JSON.stringify(filters)}`,
739+
);
715740
}
716741
}
717742

@@ -750,23 +775,46 @@ export class InMemoryDriver implements IDataDriver {
750775
return { [field]: { $lte: value } };
751776
case 'in':
752777
return { [field]: { $in: value } };
753-
case 'nin': case 'not in':
778+
case 'nin': case 'not_in': case 'notin': case 'not in':
754779
return { [field]: { $nin: value } };
755-
case 'contains': case 'like':
780+
case 'contains': case 'like': case 'ilike':
756781
return { [field]: { $regex: new RegExp(this.escapeRegex(value), 'i') } };
757782
case 'notcontains': case 'not_contains':
758783
return { [field]: { $not: { $regex: new RegExp(this.escapeRegex(value), 'i') } } };
759784
case 'startswith': case 'starts_with':
760785
return { [field]: { $regex: new RegExp(`^${this.escapeRegex(value)}`, 'i') } };
761786
case 'endswith': case 'ends_with':
762787
return { [field]: { $regex: new RegExp(`${this.escapeRegex(value)}$`, 'i') } };
788+
// Null / empty predicates. These are in `VALID_AST_OPERATORS` and were
789+
// absent here, so every one of them fell to `default: return null` and was
790+
// dropped — `is_null` narrowed nothing instead of matching null rows.
791+
// Alias sets and semantics mirror driver-sql's `whereNull`/`whereNotNull`
792+
// arms so both backends accept the same vocabulary. In a document store
793+
// `{field: null}` matches null AND missing, and `$ne: null` excludes both,
794+
// which is the right analogue of SQL IS [NOT] NULL. #3948.
795+
case 'is_null': case 'isnull': case 'is_empty': case 'isempty': case 'empty':
796+
return { [field]: null };
797+
case 'is_not_null': case 'isnotnull':
798+
case 'is_not_empty': case 'isnotempty': case 'not_empty': case 'notempty':
799+
case 'is_set': case 'set':
800+
return { [field]: { $ne: null } };
763801
case 'between':
764802
if (Array.isArray(value) && value.length === 2) {
765803
return { [field]: { $gte: value[0], $lte: value[1] } };
766804
}
767-
return null;
805+
throw new Error(
806+
`[driver-memory] "between" on field "${field}" needs a two-element array, got ` +
807+
`${JSON.stringify(value)}. Returning no predicate would silently match every record.`,
808+
);
768809
default:
769-
return null;
810+
// Was `return null`, which the caller dropped — so an operator this
811+
// driver cannot express narrowed nothing instead of erroring. driver-sql
812+
// already threw on the same input; the two backends disagreed. #3948.
813+
throw new Error(
814+
`[driver-memory] Unsupported filter operator "${operator}" on field "${field}". ` +
815+
`Supported operators: =, !=, <, <=, >, >=, in, nin, between, contains, ` +
816+
`not_contains, starts_with, ends_with (see @objectstack/spec VALID_AST_OPERATORS).`,
817+
);
770818
}
771819
}
772820

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* Filter-AST vocabulary parity, and no-silent-drop. (#3948)
3+
*
4+
* Two invariants, both of which this driver used to break in the same direction:
5+
*
6+
* 1. Every operator in `VALID_AST_OPERATORS` must be expressible. That set gates
7+
* `isFilterAST()`, so anything in it is an operator the protocol will happily
8+
* parse and hand to a driver. Seven of them — `not_in`, `is_null`,
9+
* `is_not_null`, `isnull`, `isnotnull`, `is_empty`, `is_not_empty` — fell to
10+
* `default: return null` and the caller dropped the condition, so e.g.
11+
* `is_null` narrowed nothing instead of matching null rows.
12+
*
13+
* 2. An operator this driver cannot express must THROW, never be skipped. A
14+
* dropped condition widens the result set: the caller asked to filter and
15+
* silently received more rows than it asked for. driver-sql already threw on
16+
* the same input, so the two backends disagreed about the same query.
17+
*
18+
* The nastiest case is the bare comparison triple. `['x','before',1]` reaches a
19+
* driver only when `isFilterAST()` refused it, leaving the array unparsed — and
20+
* the old loop cast each string element to a logic keyword, opening empty logic
21+
* groups and returning `{}`: a filter matching EVERY record.
22+
*/
23+
import { describe, it, expect, beforeEach } from 'vitest';
24+
import { VALID_AST_OPERATORS } from '@objectstack/spec/data';
25+
import { InMemoryDriver } from './memory-driver.js';
26+
27+
const TABLE = 'vocab_probe';
28+
29+
describe('InMemoryDriver filter vocabulary ↔ VALID_AST_OPERATORS', () => {
30+
let driver: InMemoryDriver;
31+
32+
beforeEach(async () => {
33+
driver = new InMemoryDriver({ persistence: false });
34+
await driver.connect();
35+
await driver.create(TABLE, { id: '1', name: 'alpha', score: 10, note: null });
36+
await driver.create(TABLE, { id: '2', name: 'beta', score: 20, note: 'set' });
37+
});
38+
39+
/** Operators are exercised through `find`, the path a real query takes. */
40+
const find = (where: unknown) =>
41+
driver.find(TABLE, { object: TABLE, fields: ['id'], where } as any);
42+
43+
it('reads a non-empty operator set from the spec', () => {
44+
// Guards every assertion below from passing vacuously.
45+
expect(VALID_AST_OPERATORS.size).toBeGreaterThan(0);
46+
});
47+
48+
/** A representative value per operator, so each one is actually exercised. */
49+
const valueFor = (op: string): unknown => {
50+
if (op === 'in' || op === 'nin' || op === 'not_in') return ['alpha'];
51+
if (op === 'between') return [0, 100];
52+
if (/null|empty/.test(op)) return true;
53+
if (/contains|like|startswith|starts_with|endswith|ends_with/.test(op)) return 'alp';
54+
return 'alpha';
55+
};
56+
57+
it.each([...VALID_AST_OPERATORS])('expresses %s without dropping it', async (op) => {
58+
const field = /^[<>=!]/.test(op) || op === 'between' ? 'score' : 'name';
59+
const value = field === 'score' && !Array.isArray(valueFor(op)) ? 10 : valueFor(op);
60+
// The assertion is that this does not throw and does not silently degrade to
61+
// "no predicate". An operator the driver cannot express now throws, so any
62+
// rejection here means the spec accepts a name this driver cannot honour.
63+
await expect(
64+
find([[field, op, value]]),
65+
`VALID_AST_OPERATORS accepts "${op}" but InMemoryDriver cannot express it`,
66+
).resolves.toBeDefined();
67+
});
68+
69+
it('matches null rows for is_null instead of dropping the predicate', async () => {
70+
// The regression this pins: `is_null` used to return null from the converter,
71+
// the condition was dropped, and the query returned BOTH rows.
72+
const rows = await find([['note', 'is_null', true]]);
73+
expect(rows.map((r: any) => r.id)).toEqual(['1']);
74+
});
75+
76+
it('matches non-null rows for is_not_null', async () => {
77+
const rows = await find([['note', 'is_not_null', true]]);
78+
expect(rows.map((r: any) => r.id)).toEqual(['2']);
79+
});
80+
81+
it('throws on an operator it cannot express, rather than matching everything', async () => {
82+
await expect(find([['name', 'sounds_like', 'alpha']]))
83+
.rejects.toThrow(/Unsupported filter operator "sounds_like"/);
84+
});
85+
86+
it('throws on a bare comparison triple instead of returning every record', async () => {
87+
// `before` is a canonical VIEW_FILTER_OPERATORS member that VALID_AST_OPERATORS
88+
// does not accept, so this is the exact shape that reached drivers unparsed.
89+
await expect(find(['created_at', 'before', '2024-01-01']))
90+
.rejects.toThrow(/Unrecognized filter operator "created_at"/);
91+
});
92+
93+
it('throws on a malformed between rather than emitting no predicate', async () => {
94+
await expect(find([['score', 'between', 5]]))
95+
.rejects.toThrow(/needs a two-element array/);
96+
});
97+
98+
it('still honours a well-formed logical node', async () => {
99+
const rows = await find(['or', ['name', '=', 'alpha'], ['name', '=', 'beta']]);
100+
expect(rows.map((r: any) => r.id).sort()).toEqual(['1', '2']);
101+
});
102+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A filter that cannot be compiled must THROW, never be skipped. (#3948)
5+
*
6+
* `applyFilters` walked the filter array and `continue`d past anything that was
7+
* not a join keyword or a condition array. For a BARE comparison triple —
8+
* `['close_date', 'before', '2024-01-01']` — the three elements are all strings,
9+
* so every one was skipped and **no WHERE clause was emitted at all**: the caller
10+
* asked to filter and silently received every row.
11+
*
12+
* A bare triple reaches a driver only when `isFilterAST()` refused it, i.e. its
13+
* operator is outside `VALID_AST_OPERATORS`, so `parseFilterAST()` never
14+
* converted it and the raw array arrived as `where`. That is reachable from
15+
* ordinary authoring: `before`/`after` are canonical `VIEW_FILTER_OPERATORS`
16+
* members which `VALID_AST_OPERATORS` does not accept.
17+
*
18+
* The nested and `$`-object paths already threw on the same class of input, so
19+
* the three code paths disagreed about one query. These tests pin the loud
20+
* behaviour, and pin that well-formed filters still compile.
21+
*/
22+
23+
import { describe, it, expect, beforeEach } from 'vitest';
24+
import { SqlDriver } from '../src/index.js';
25+
26+
describe('SqlDriver rejects an uncompilable filter instead of dropping it', () => {
27+
let driver: SqlDriver;
28+
29+
beforeEach(async () => {
30+
driver = new SqlDriver({
31+
client: 'better-sqlite3',
32+
connection: { filename: ':memory:' },
33+
useNullAsDefault: true,
34+
});
35+
36+
await driver.initObjects([
37+
{
38+
name: 'deal',
39+
fields: {
40+
id: { type: 'text', name: 'id' },
41+
stage: { type: 'text', name: 'stage' },
42+
amount: { type: 'number', name: 'amount' },
43+
},
44+
} as any,
45+
]);
46+
47+
await driver.create('deal', { id: '1', stage: 'won', amount: 10 });
48+
await driver.create('deal', { id: '2', stage: 'lost', amount: 20 });
49+
});
50+
51+
const find = (where: unknown) =>
52+
driver.find('deal', { object: 'deal', fields: ['id'], where } as any);
53+
54+
it('throws on a bare triple whose operator the AST gate refused', async () => {
55+
// The exact shape a stored single-condition `before` view produced.
56+
await expect(find(['close_date', 'before', '2024-01-01']))
57+
.rejects.toThrow(/Unrecognized filter operator "close_date"/);
58+
});
59+
60+
it('does not silently return every row for that filter', async () => {
61+
// The regression itself: before the fix this resolved with BOTH rows.
62+
await expect(find(['stage', 'sounds_like', 'won'])).rejects.toThrow();
63+
});
64+
65+
it('throws on a filter element that is neither a keyword nor a condition', async () => {
66+
await expect(find([42 as any])).rejects.toThrow(/Unrecognized filter element of type "number"/);
67+
await expect(find([null as any])).rejects.toThrow(/Unrecognized filter element of type "null"/);
68+
});
69+
70+
it('still throws on an unsupported operator inside a well-formed condition', async () => {
71+
// Pre-existing behaviour, pinned so the two paths cannot diverge again.
72+
await expect(find([['stage', 'sounds_like', 'won']]))
73+
.rejects.toThrow(/Unsupported filter operator "sounds_like"/);
74+
});
75+
76+
it('compiles a nested condition array', async () => {
77+
const rows = await find([['stage', '=', 'won']]);
78+
expect(rows.map((r: any) => r.id)).toEqual(['1']);
79+
});
80+
81+
it('compiles an infix logical join', async () => {
82+
// This path's legacy array form is INFIX — `[condA, 'or', condB]`. The
83+
// prefix spec-AST form `['or', condA, condB]` reaches the driver already
84+
// converted to `{$or: […]}` by `parseFilterAST()`, so it takes the
85+
// object branch instead and never lands here.
86+
const rows = await find([['stage', '=', 'won'], 'or', ['stage', '=', 'lost']]);
87+
expect(rows.map((r: any) => r.id).sort()).toEqual(['1', '2']);
88+
});
89+
90+
it('compiles an object-form filter', async () => {
91+
const rows = await find({ stage: 'lost' });
92+
expect(rows.map((r: any) => r.id)).toEqual(['2']);
93+
});
94+
95+
it('leaves an empty filter alone (no filter is not a failed filter)', async () => {
96+
const rows = await find([]);
97+
expect(rows).toHaveLength(2);
98+
});
99+
});

packages/plugins/driver-sql/src/sql-driver.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4641,9 +4641,23 @@ export class SqlDriver implements IDataDriver {
46414641

46424642
for (const item of filters) {
46434643
if (typeof item === 'string') {
4644-
if (item.toLowerCase() === 'or') nextJoin = 'or';
4645-
else if (item.toLowerCase() === 'and') nextJoin = 'and';
4646-
continue;
4644+
const lower = item.toLowerCase();
4645+
if (lower === 'or') { nextJoin = 'or'; continue; }
4646+
if (lower === 'and') { nextJoin = 'and'; continue; }
4647+
// Anything else is not a join keyword, and the only way a bare string
4648+
// reaches here is a comparison triple that `isFilterAST()` refused —
4649+
// its operator is outside `VALID_AST_OPERATORS`, so `parseFilterAST()`
4650+
// never converted it and the raw array arrived as `where`. Skipping it
4651+
// (the old behaviour) emitted NO predicate at all: the caller asked to
4652+
// filter and silently got every row. Fail loudly instead. #3948.
4653+
throw new Error(
4654+
`[sql-driver] Unrecognized filter operator "${item}" in a comparison triple. ` +
4655+
`A filter array is either a logical node (["and"|"or", …]) or nested ` +
4656+
`conditions ([[field, op, value], …]); a bare [field, op, value] only ` +
4657+
`reaches the driver when its operator is outside @objectstack/spec ` +
4658+
`VALID_AST_OPERATORS, which leaves the filter unparsed. ` +
4659+
`Filter was: ${JSON.stringify(filters)}`,
4660+
);
46474661
}
46484662

46494663
if (Array.isArray(item)) {
@@ -4666,7 +4680,18 @@ export class SqlDriver implements IDataDriver {
46664680
}
46674681

46684682
nextJoin = 'and';
4683+
continue;
46694684
}
4685+
4686+
// Neither a join keyword nor a condition. Previously fell out of both
4687+
// branches and was dropped, so a malformed element silently narrowed
4688+
// nothing. Same reasoning as above: an unapplied filter must not look
4689+
// like a satisfied one. #3948.
4690+
throw new Error(
4691+
`[sql-driver] Unrecognized filter element of type "${item === null ? 'null' : typeof item}" — ` +
4692+
`expected a logical keyword ("and"/"or") or a condition array. ` +
4693+
`Filter was: ${JSON.stringify(filters)}`,
4694+
);
46704695
}
46714696
}
46724697

0 commit comments

Comments
 (0)