Skip to content

Commit 0166bd5

Browse files
os-zhuangclaude
andauthored
fix(spec,drivers): the view filter vocabulary and the AST vocabulary now agree (#3948) (#4039)
`VIEW_FILTER_OPERATORS` (`ui/view.zod.ts`) is what an author may declare on a `ViewFilterRule`; `VALID_AST_OPERATORS` (`data/filter.zod.ts`) gates `isFilterAST()`, which decides whether a filter is parsed into a query at all. They disagreed on **8 of 19** members — `equals`, `not_equals`, `greater_than`, `less_than`, `greater_than_or_equal`, `less_than_or_equal`, `before`, `after`. An author could declare any of them, `ViewFilterRuleSchema` validated them and `defineStack` accepted them; then `isFilterAST()` refused the filter, the protocol passed the array through unconverted, and the driver could not apply it. Six of the eight were reachable only in theory because ObjectUI's adapter alias table happened to translate them — so the query path's correctness was resting on a hand-written table in another repository being complete, and for `before`/`after` it wasn't. `AST_OPERATOR_MAP` becomes the single source of truth: `VALID_AST_OPERATORS` is derived from its keys instead of restated, so an operator can no longer pass the gate without having a lowering. The two were independent hand-written lists that happened to agree, with nothing enforcing it. The map gained the eight canonical view spellings plus the squashed/short forms stored metadata carries. New export `canonicalAstOperator(op)` folds every accepted spelling of one comparison onto a single infix form; both drivers call it rather than growing private alias lists, which is what let them accept different vocabularies. `like`/`ilike` are deliberately NOT folded onto `contains` — driver-sql passes them to SQL verbatim, so folding would silently wrap the value in `%…%`. Widening only; no spelling was removed, so nothing stops validating. Regenerated api-surface.json (0 breaking, 1 added — the ratchet caught it). Tests: spec 6922, objectql 1171, driver-sql 487, driver-memory 178. The new parity test was confirmed to fail without the fix (5 failures naming `before`/`after`). Refs #3948 item 3 Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2d3e255 commit 0166bd5

6 files changed

Lines changed: 333 additions & 52 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/driver-sql": patch
4+
"@objectstack/driver-memory": patch
5+
---
6+
7+
fix(spec,drivers): the view filter vocabulary and the AST vocabulary now agree (#3948)
8+
9+
`VIEW_FILTER_OPERATORS` (`ui/view.zod.ts`) is what an author may declare on a
10+
`ViewFilterRule`. `VALID_AST_OPERATORS` (`data/filter.zod.ts`) gates
11+
`isFilterAST()`, which decides whether a filter is parsed into a query at all.
12+
They disagreed on **8 of 19** members: `equals`, `not_equals`, `greater_than`,
13+
`less_than`, `greater_than_or_equal`, `less_than_or_equal`, `before`, `after`.
14+
15+
An author could declare any of them, `ViewFilterRuleSchema` validated them,
16+
`defineStack` accepted them — and then `isFilterAST()` refused the filter, the
17+
protocol passed the array through unconverted, and the driver could not apply it.
18+
Six of the eight were reachable only in theory because ObjectUI's adapter alias
19+
table happened to translate them; the safety of the query path was resting on a
20+
hand-written table in another repository being complete, and for `before`/`after`
21+
it wasn't.
22+
23+
**`AST_OPERATOR_MAP` is now the single source of truth.** `VALID_AST_OPERATORS`
24+
is derived from its keys rather than restated, so an operator can no longer be
25+
accepted by the gate without also having a lowering — the two were separate
26+
hand-written lists that happened to agree, with nothing enforcing it. The map
27+
gained the eight canonical view spellings plus the squashed/short forms stored
28+
metadata carries (`notequals`, `greaterthanorequal`, `eq`, `gt`, …).
29+
30+
**New export `canonicalAstOperator(op)`** folds every accepted spelling of one
31+
comparison onto a single infix form. Both drivers now call it instead of growing
32+
private alias lists, which is what let them accept different vocabularies.
33+
`like`/`ilike` are deliberately not folded onto `contains`: driver-sql passes them
34+
to SQL verbatim, so folding would silently wrap the value in `%…%`.
35+
36+
Widening only — no spelling was removed, so no stored filter stops validating.
37+
A filter that previously produced an error (after #4029) or was silently dropped
38+
(before it) now compiles. `filter-view-operator-parity.test.ts` asserts every
39+
`VIEW_FILTER_OPERATORS` member and every `VIEW_FILTER_OPERATOR_ALIASES` key has a
40+
lowering that is a real `$`-operator rather than the `$${op}` fallback, so the
41+
next operator the view layer gains fails a test instead of a query.

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import type { QueryAST, QueryInput, DriverOptions } from '@objectstack/spec/data';
4+
import { canonicalAstOperator } from '@objectstack/spec/data';
45
import type { IDataDriver } from '@objectstack/spec/contracts';
56
import { Logger, createLogger } from '@objectstack/core';
67
import { Query, Aggregator } from 'mingo';
@@ -760,7 +761,12 @@ export class InMemoryDriver implements IDataDriver {
760761
* Convert a single ObjectQL condition to MongoDB operator format.
761762
*/
762763
private convertConditionToMongo(field: string, operator: string, value: any): Record<string, any> | null {
763-
switch (operator) {
764+
// Fold every accepted spelling of one comparison onto a single infix form,
765+
// so this switch has one case per comparison rather than one per spelling —
766+
// `VALID_AST_OPERATORS` accepts `>`, `gt`, `greater_than`, `greaterthan` and
767+
// `after` for the same thing. A private alias list here is what let this
768+
// driver and driver-sql accept different vocabularies. #3948.
769+
switch (canonicalAstOperator(operator)) {
764770
case '=': case '==':
765771
return { [field]: value };
766772
case '!=': case '<>':

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import type { QueryAST, DriverOptions, SchemaMode } from '@objectstack/spec/data';
1111
import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, isGlobalUnique, isUniqueDeclared, type AutonumberToken } from '@objectstack/spec/data';
1212
import { STRUCTURED_JSON_TYPES, FILE_REFERENCE_TYPES, MULTI_OPTION_TYPES, NUMERIC_VALUE_TYPES } from '@objectstack/spec/data';
13+
import { canonicalAstOperator } from '@objectstack/spec/data';
1314
import type { IDataDriver } from '@objectstack/spec/contracts';
1415
import { StorageNameMapping } from '@objectstack/spec/system';
1516
import { ExternalSchemaModeViolationError } from '@objectstack/spec/shared';
@@ -4766,7 +4767,12 @@ export class SqlDriver implements IDataDriver {
47664767
const where = join === 'or' ? 'orWhere' : 'where';
47674768
const whereNull = join === 'or' ? 'orWhereNull' : 'whereNull';
47684769
const whereNotNull = join === 'or' ? 'orWhereNotNull' : 'whereNotNull';
4769-
const opLower = String(op).toLowerCase();
4770+
// Fold every accepted spelling of one comparison onto a single infix form so
4771+
// the switch below has one case per comparison rather than one per spelling.
4772+
// `VALID_AST_OPERATORS` accepts `>`, `gt`, `greater_than`, `greaterthan` and
4773+
// `after` for the same thing; growing a private alias list here is how this
4774+
// driver and driver-memory drifted apart. #3948.
4775+
const opLower = canonicalAstOperator(String(op));
47704776

47714777
// Value comparisons on a mixed-storage column read it through the CASE; every
47724778
// other operator (null predicates, the LIKE family, a malformed `between`)

packages/spec/api-surface.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,7 @@
549549
"WindowFunctionNodeSchema (const)",
550550
"WindowSpec (type)",
551551
"WindowSpecSchema (const)",
552+
"canonicalAstOperator (function)",
552553
"canonicalizeSqlType (function)",
553554
"classifyFilterToken (function)",
554555
"countAuthorableFields (function)",
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The view vocabulary and the AST vocabulary must agree. (#3948)
5+
*
6+
* `VIEW_FILTER_OPERATORS` (`ui/view.zod.ts`) is what an author may declare on a
7+
* `ViewFilterRule`, and what `ViewFilterRuleSchema` validates against.
8+
* `VALID_AST_OPERATORS` (`data/filter.zod.ts`) gates `isFilterAST()`, which
9+
* decides whether a filter is parsed into a query at all.
10+
*
11+
* They disagreed on **8 of 19** members — `equals`, `not_equals`,
12+
* `greater_than`, `less_than`, `greater_than_or_equal`, `less_than_or_equal`,
13+
* `before`, `after`. An author could declare any of them, the schema accepted
14+
* them, `defineStack` accepted them, and then `isFilterAST()` refused the filter,
15+
* the protocol passed the array through unconverted, and the driver dropped it:
16+
* an unfiltered result set with no error anywhere.
17+
*
18+
* Six of the eight were reachable only in theory, because ObjectUI's adapter
19+
* alias table happened to translate them. The safety of the query path was
20+
* resting on a hand-written table in a different repository being complete, and
21+
* it wasn't — `before`/`after` had no entry, which is how this surfaced.
22+
*
23+
* `data/` cannot import `ui/` (that direction is already taken, so it would be
24+
* circular), which is why the AST map is not literally derived from the view
25+
* vocabulary. This test is the enforcement instead: it fails the moment the view
26+
* layer gains an operator the AST layer cannot lower.
27+
*/
28+
29+
import { describe, it, expect } from 'vitest';
30+
import {
31+
VALID_AST_OPERATORS,
32+
isFilterAST,
33+
parseFilterAST,
34+
} from './filter.zod';
35+
import {
36+
VIEW_FILTER_OPERATORS,
37+
VIEW_FILTER_OPERATOR_ALIASES,
38+
} from '../ui/view.zod';
39+
40+
/**
41+
* View operators that resolve to a value-shape before an operator is ever
42+
* emitted, so they legitimately need no AST lowering.
43+
*
44+
* Empty today: `is_empty`/`is_not_empty` DO have lowerings (to `$null`), because
45+
* clients send them as operators rather than resolving them client-side. Kept as
46+
* an explicit, empty exemption list so that adding to it is a visible decision
47+
* rather than a quiet edit to the assertion.
48+
*/
49+
const NO_AST_LOWERING_REQUIRED = new Set<string>([]);
50+
51+
describe('every view filter operator has an AST lowering', () => {
52+
it('reads both vocabularies', () => {
53+
// Guards the assertions below from passing vacuously.
54+
expect(VIEW_FILTER_OPERATORS.length).toBeGreaterThan(0);
55+
expect(VALID_AST_OPERATORS.size).toBeGreaterThan(0);
56+
});
57+
58+
it('VALID_AST_OPERATORS covers every canonical view operator', () => {
59+
const missing = VIEW_FILTER_OPERATORS
60+
.filter((op) => !NO_AST_LOWERING_REQUIRED.has(op))
61+
.filter((op) => !VALID_AST_OPERATORS.has(op.toLowerCase()));
62+
expect(
63+
missing,
64+
'an author can declare these on a ViewFilterRule and the schema validates them, '
65+
+ 'but isFilterAST() refuses the filter — it is passed through unconverted and '
66+
+ 'the driver cannot apply it. Add a lowering to AST_OPERATOR_MAP.',
67+
).toEqual([]);
68+
});
69+
70+
it('VALID_AST_OPERATORS covers every legacy alias spelling too', () => {
71+
// `saveMeta` persists the authored body verbatim, so the schema's own
72+
// `z.preprocess` normalization never reaches the stored row — every alias in
73+
// this table is live in metadata, not merely historical.
74+
const missing = Object.keys(VIEW_FILTER_OPERATOR_ALIASES)
75+
.filter((alias) => !NO_AST_LOWERING_REQUIRED.has(VIEW_FILTER_OPERATOR_ALIASES[alias]))
76+
.filter((alias) => !VALID_AST_OPERATORS.has(alias.toLowerCase()));
77+
expect(
78+
missing,
79+
'these spellings exist in stored view metadata and have no AST lowering',
80+
).toEqual([]);
81+
});
82+
83+
it.each([...VIEW_FILTER_OPERATORS])('%s survives isFilterAST as a bare triple', (op) => {
84+
// The bare triple is the shape that used to vanish: a single AND condition
85+
// is emitted as `[field, op, value]`, and when isFilterAST() rejects it the
86+
// whole filter is silently dropped.
87+
if (NO_AST_LOWERING_REQUIRED.has(op)) return;
88+
const value = op === 'in' || op === 'not_in' ? ['a'] : op === 'between' ? [1, 2] : 'x';
89+
expect(isFilterAST(['some_field', op, value]), `isFilterAST rejects "${op}"`).toBe(true);
90+
});
91+
92+
it('lowers each view operator to a real $-operator, never the $${op} fallback', () => {
93+
// `convertComparison` ends in `{ [field]: { [`$${op}`]: value } }` for an
94+
// unmapped operator, which produces e.g. `$before` — a key no driver knows,
95+
// so the failure moves from "silently unfiltered" to "driver throws". Both
96+
// are wrong; this asserts we produce a real operator.
97+
const KNOWN = new Set([
98+
'$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in', '$nin',
99+
'$between', '$contains', '$notContains', '$startsWith', '$endsWith',
100+
'$null', '$exists',
101+
]);
102+
const bad: string[] = [];
103+
for (const op of VIEW_FILTER_OPERATORS) {
104+
if (NO_AST_LOWERING_REQUIRED.has(op)) continue;
105+
const value = op === 'in' || op === 'not_in' ? ['a'] : op === 'between' ? [1, 2] : 'x';
106+
const parsed = parseFilterAST(['some_field', op, value]) as Record<string, unknown>;
107+
const arm = parsed.some_field;
108+
// The equality shorthand is `{ field: value }` — a bare value, not an
109+
// operator object. That is a real lowering, not a fallback.
110+
if (arm === null || typeof arm !== 'object') continue;
111+
const keys = Object.keys(arm as Record<string, unknown>);
112+
if (!keys.every((k) => KNOWN.has(k))) bad.push(`${op}${keys.join(',')}`);
113+
}
114+
expect(bad, 'these fell through to the $${op} fallback').toEqual([]);
115+
});
116+
117+
it('the date comparisons that regressed now lower correctly', () => {
118+
expect(parseFilterAST(['close_date', 'before', '2024-01-01']))
119+
.toEqual({ close_date: { $lt: '2024-01-01' } });
120+
expect(parseFilterAST(['close_date', 'after', '2024-01-01']))
121+
.toEqual({ close_date: { $gt: '2024-01-01' } });
122+
});
123+
124+
it('spells equality one way regardless of which alias the author used', () => {
125+
const shorthand = { status: 'active' };
126+
for (const op of ['=', '==', 'equals', 'eq']) {
127+
expect(parseFilterAST(['status', op, 'active']), `via "${op}"`).toEqual(shorthand);
128+
}
129+
});
130+
131+
it('keeps null-direction keyed on the operator name, not the filler value', () => {
132+
// Clients send a truthy placeholder for both directions.
133+
expect(parseFilterAST(['note', 'is_empty', true])).toEqual({ note: { $null: true } });
134+
expect(parseFilterAST(['note', 'isempty', true])).toEqual({ note: { $null: true } });
135+
expect(parseFilterAST(['note', 'is_not_empty', true])).toEqual({ note: { $null: false } });
136+
expect(parseFilterAST(['note', 'isnotempty', true])).toEqual({ note: { $null: false } });
137+
});
138+
139+
it('still refuses an operator in neither vocabulary', () => {
140+
// Widening must not turn the gate off.
141+
expect(isFilterAST(['some_field', 'sounds_like', 'x'])).toBe(false);
142+
});
143+
});

0 commit comments

Comments
 (0)