@object-ui/data-objectstack 16.1.0 → 17.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +638 -1
- package/README.md +9 -4
- package/dist/index.cjs +417 -91
- package/dist/index.d.cts +294 -21
- package/dist/index.d.ts +294 -21
- package/dist/index.js +412 -90
- package/package.json +5 -4
- package/src/aggregate-capability.test.ts +177 -0
- package/src/cache/MetadataCache.ts +12 -3
- package/src/errors.test.ts +12 -12
- package/src/errors.ts +23 -5
- package/src/exportDownload.test.ts +42 -0
- package/src/filter-entry-translation.test.ts +360 -0
- package/src/filter-operator-ast-parity.test.ts +71 -0
- package/src/index.ts +736 -120
- package/src/metadata-client.ts +17 -3
- package/src/onMutation.test.ts +35 -35
- package/src/onWriteWarning.test.ts +172 -0
- package/src/orderby-serialization.test.ts +115 -0
- package/src/queryDataset.test.ts +50 -1
- package/src/searchAll.test.ts +120 -0
- package/src/security.ts +18 -5
- package/src/spec-symbol-batch6.test.ts +303 -0
- package/src/userState.test.ts +25 -4
- package/src/userState.ts +10 -6
- package/src/validation-error.test.ts +94 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Object-form filter entries (`[{ field, operator, value }, ...]`) → filter AST.
|
|
11
|
+
*
|
|
12
|
+
* `find()` has TWO routes to the server and both translate the filter: a plain
|
|
13
|
+
* read goes through `convertQueryParams`, while a read with `$expand`/`$search`
|
|
14
|
+
* goes through `rawFindWithPopulate`. They used to carry independent copies of
|
|
15
|
+
* the "is this object form?" test, and the copies had already drifted — so the
|
|
16
|
+
* tests below run every case down BOTH routes and assert they agree.
|
|
17
|
+
*
|
|
18
|
+
* The stakes are the same as objectstack#3948: a filter this layer fails to
|
|
19
|
+
* translate is not a filter it may skip. Skipping one entry of an `and` returns
|
|
20
|
+
* a superset of the rows asked for, and skipping the last one sends no filter
|
|
21
|
+
* at all — every row in the table, reported as success.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
25
|
+
import { isFilterAST } from '@objectstack/spec/data';
|
|
26
|
+
import { ObjectStackAdapter, clearSharedDiscoveryCache, isMalformedFilterError } from './index';
|
|
27
|
+
|
|
28
|
+
function makeAdapter() {
|
|
29
|
+
const calls: string[] = [];
|
|
30
|
+
const fetchImpl = vi.fn(async (url: any) => {
|
|
31
|
+
const u = String(url);
|
|
32
|
+
calls.push(u);
|
|
33
|
+
if (u.includes('/api/v1/discovery')) {
|
|
34
|
+
return {
|
|
35
|
+
ok: true, status: 200, statusText: 'OK',
|
|
36
|
+
json: async () => ({ success: true, data: { version: 'v1', routes: {} } }),
|
|
37
|
+
} as any;
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
ok: true, status: 200, statusText: 'OK',
|
|
41
|
+
json: async () => ({ success: true, data: { object: 'account', records: [], total: 0 } }),
|
|
42
|
+
} as any;
|
|
43
|
+
});
|
|
44
|
+
const adapter = new ObjectStackAdapter({
|
|
45
|
+
baseUrl: 'http://localhost:3000', token: 't', autoReconnect: false, fetch: fetchImpl as any,
|
|
46
|
+
});
|
|
47
|
+
return { adapter, calls };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The `filter=` this filter produced on the wire, or `undefined` if none was sent. */
|
|
51
|
+
async function filterOnWire(
|
|
52
|
+
$filter: unknown,
|
|
53
|
+
route: 'plain' | 'expand',
|
|
54
|
+
): Promise<unknown | undefined> {
|
|
55
|
+
const { adapter, calls } = makeAdapter();
|
|
56
|
+
await adapter.find('account', {
|
|
57
|
+
$filter,
|
|
58
|
+
...(route === 'expand' ? { $expand: ['owner'] } : {}),
|
|
59
|
+
} as any);
|
|
60
|
+
const dataCall = calls.filter((u) => u.includes('/data/account')).pop();
|
|
61
|
+
const raw = dataCall ? new URL(dataCall).searchParams.get('filter') : null;
|
|
62
|
+
return raw === null ? undefined : JSON.parse(raw);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Run one input down both `find()` routes. */
|
|
66
|
+
function bothRoutes(name: string, $filter: unknown, assert: (wire: unknown | undefined) => void) {
|
|
67
|
+
for (const route of ['plain', 'expand'] as const) {
|
|
68
|
+
it(`${name} (${route} route)`, async () => assert(await filterOnWire($filter, route)));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function findRejects($filter: unknown, route: 'plain' | 'expand') {
|
|
73
|
+
const { adapter } = makeAdapter();
|
|
74
|
+
return adapter
|
|
75
|
+
.find('account', { $filter, ...(route === 'expand' ? { $expand: ['owner'] } : {}) } as any)
|
|
76
|
+
.then(() => null, (e) => e);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('object-form filter entries reach the wire as an AST', () => {
|
|
80
|
+
beforeEach(() => clearSharedDiscoveryCache());
|
|
81
|
+
|
|
82
|
+
bothRoutes(
|
|
83
|
+
'translates a single rule, mapping the operator alias',
|
|
84
|
+
[{ field: 'stage', operator: 'greater_than_or_equal', value: 3 }],
|
|
85
|
+
(wire) => expect(wire).toEqual(['stage', '>=', 3]),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
bothRoutes(
|
|
89
|
+
'joins several rules with `and`',
|
|
90
|
+
[
|
|
91
|
+
{ field: 'stage', operator: 'eq', value: 'won' },
|
|
92
|
+
{ field: 'amount', operator: 'gt', value: 100 },
|
|
93
|
+
],
|
|
94
|
+
(wire) => expect(wire).toEqual(['and', ['stage', '=', 'won'], ['amount', '>', 100]]),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
bothRoutes(
|
|
98
|
+
'accepts the `op` shorthand key',
|
|
99
|
+
[{ field: 'owner', op: 'eq', value: 'me' }],
|
|
100
|
+
(wire) => expect(wire).toEqual(['owner', '=', 'me']),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
bothRoutes(
|
|
104
|
+
'passes an AST tuple through untouched',
|
|
105
|
+
['stage', '=', 'won'],
|
|
106
|
+
(wire) => expect(wire).toEqual(['stage', '=', 'won']),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
bothRoutes(
|
|
110
|
+
'passes a logical AST node through untouched',
|
|
111
|
+
['or', ['stage', '=', 'won'], ['stage', '=', 'lost']],
|
|
112
|
+
(wire) => expect(wire).toEqual(['or', ['stage', '=', 'won'], ['stage', '=', 'lost']]),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
bothRoutes('sends no filter at all for an empty array', [], (wire) => expect(wire).toBeUndefined());
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('a `name`-keyed entry is not an accepted shape', () => {
|
|
119
|
+
beforeEach(() => clearSharedDiscoveryCache());
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `objectFilterEntryToAST` read `entry.field ?? entry.name` while the shape
|
|
123
|
+
* sniff keyed on `field` alone, so the `name` half could never be reached —
|
|
124
|
+
* dead from the commit that introduced it. The spec agrees it is not a real
|
|
125
|
+
* shape: `ViewFilterRuleSchema.field` is required, so such a rule cannot be
|
|
126
|
+
* saved as view metadata. It reaches the server unchanged and is refused
|
|
127
|
+
* there (objectstack#4121) rather than being quietly half-understood here.
|
|
128
|
+
*/
|
|
129
|
+
bothRoutes(
|
|
130
|
+
'is passed through rather than translated',
|
|
131
|
+
[{ name: 'stage', operator: 'eq', value: 'won' }],
|
|
132
|
+
(wire) => expect(wire).toEqual([{ name: 'stage', operator: 'eq', value: 'won' }]),
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('an untranslatable entry fails instead of narrowing the filter', () => {
|
|
137
|
+
beforeEach(() => clearSharedDiscoveryCache());
|
|
138
|
+
|
|
139
|
+
for (const route of ['plain', 'expand'] as const) {
|
|
140
|
+
it(`rejects a rule with a blank field rather than dropping it (${route})`, async () => {
|
|
141
|
+
// Survives `ViewFilterRuleSchema` (`field: z.string()` admits ''), so it
|
|
142
|
+
// can be genuine stored metadata. It used to be dropped, and dropping the
|
|
143
|
+
// only rule sent no filter — the whole table came back.
|
|
144
|
+
const err = await findRejects([{ field: '', operator: 'eq', value: 'x' }], route);
|
|
145
|
+
expect(err).toBeInstanceOf(Error);
|
|
146
|
+
expect(isMalformedFilterError(err)).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it(`rejects a bad entry rather than silently widening the AND (${route})`, async () => {
|
|
150
|
+
const err = await findRejects(
|
|
151
|
+
[
|
|
152
|
+
{ field: 'stage', operator: 'eq', value: 'won' },
|
|
153
|
+
{ field: 'amount', operator: 42 as any, value: 1 },
|
|
154
|
+
],
|
|
155
|
+
route,
|
|
156
|
+
);
|
|
157
|
+
expect(isMalformedFilterError(err)).toBe(true);
|
|
158
|
+
// Names which entry, so the author can find it in the view config.
|
|
159
|
+
expect(String((err as Error).message)).toContain('entry 1');
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
it('carries the status and code that classify it as a rejected request', async () => {
|
|
164
|
+
const err: any = await findRejects([{ field: '', operator: 'eq' }], 'plain');
|
|
165
|
+
// plugin-list's `classifyLoadError` reads these to pick the error panel;
|
|
166
|
+
// without them a malformed filter reads as "check your connection" (#3066).
|
|
167
|
+
expect(err.code).toBe('INVALID_FILTER');
|
|
168
|
+
expect(err.httpStatus).toBe(400);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('object-form rules nested inside a logical node are translated too', () => {
|
|
173
|
+
beforeEach(() => clearSharedDiscoveryCache());
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* This is what a list builds the moment a view with a stored filter meets a
|
|
177
|
+
* user filter from the panel — `['and', <ViewFilterRule[]>, <AST tuples>]`.
|
|
178
|
+
* Translating only the top level shipped the rules raw, and the server has no
|
|
179
|
+
* good answer for that: `isFilterAST` rejects the shape (so a server with
|
|
180
|
+
* objectstack#4121 returns 400 and the list fails to load), while
|
|
181
|
+
* `parseFilterAST` on the same array returns `{ amount: { $gt: 1 } }` — the
|
|
182
|
+
* view's own `stage = won` dropped without a word.
|
|
183
|
+
*/
|
|
184
|
+
bothRoutes(
|
|
185
|
+
'translates a rule group nested under `and`',
|
|
186
|
+
['and', [{ field: 'stage', operator: 'eq', value: 'won' }], [['amount', '>', 1]]],
|
|
187
|
+
(wire) => expect(wire).toEqual(['and', ['stage', '=', 'won'], [['amount', '>', 1]]]),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
bothRoutes(
|
|
191
|
+
'translates under `or`, and through more than one level',
|
|
192
|
+
['or', ['and', [{ field: 'stage', operator: 'not_in', value: ['lost'] }]], ['x', '=', 1]],
|
|
193
|
+
(wire) => expect(wire).toEqual(['or', ['and', ['stage', 'nin', ['lost']]], ['x', '=', 1]]),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
bothRoutes(
|
|
197
|
+
'translates a group inside a legacy flat array of children',
|
|
198
|
+
[[{ field: 'stage', operator: 'eq', value: 'won' }], ['amount', '>', 1]],
|
|
199
|
+
(wire) => expect(wire).toEqual([['stage', '=', 'won'], ['amount', '>', 1]]),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
bothRoutes(
|
|
203
|
+
'leaves a field literally named `and` alone',
|
|
204
|
+
['and', '=', true],
|
|
205
|
+
(wire) => expect(wire).toEqual(['and', '=', true]),
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* A mixed array used to lose the tuple: it did not translate as a rule, so it
|
|
210
|
+
* was filtered out and the query ran on the remaining condition alone —
|
|
211
|
+
* broader than asked for. Keep both rather than dropping one or failing the
|
|
212
|
+
* whole query.
|
|
213
|
+
*/
|
|
214
|
+
bothRoutes(
|
|
215
|
+
'keeps both halves of a rule/tuple mixed array',
|
|
216
|
+
[{ field: 'stage', operator: 'eq', value: 'won' }, ['amount', '>', 1]],
|
|
217
|
+
(wire) => expect(wire).toEqual(['and', ['stage', '=', 'won'], ['amount', '>', 1]]),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
it('rejects a malformed rule nested under `and` as well', async () => {
|
|
221
|
+
const err = await findRejects(['and', [{ field: '', operator: 'eq', value: 1 }]], 'plain');
|
|
222
|
+
expect(isMalformedFilterError(err)).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('what this adapter emits passes the server’s own gate', () => {
|
|
227
|
+
beforeEach(() => clearSharedDiscoveryCache());
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The real contract is not a literal shape, it is "the server accepts this".
|
|
231
|
+
* `isFilterAST` is the function the protocol gates on, so assert against it
|
|
232
|
+
* directly rather than restating what it happens to accept today — a filter
|
|
233
|
+
* it rejects is a 400 (objectstack#4121), and was an unfiltered query before
|
|
234
|
+
* that (objectstack#3948).
|
|
235
|
+
*/
|
|
236
|
+
const TRANSLATABLE: Array<[string, unknown]> = [
|
|
237
|
+
['a single rule', [{ field: 'stage', operator: 'eq', value: 'won' }]],
|
|
238
|
+
['several rules', [
|
|
239
|
+
{ field: 'stage', operator: 'contains', value: 'w' },
|
|
240
|
+
{ field: 'amount', operator: 'less_than_or_equal', value: 9 },
|
|
241
|
+
]],
|
|
242
|
+
['rules under `and` beside a tuple', [
|
|
243
|
+
'and', [{ field: 'stage', operator: 'before', value: '2024-01-01' }], ['amount', '>', 1],
|
|
244
|
+
]],
|
|
245
|
+
['rules inside a legacy flat array', [
|
|
246
|
+
[{ field: 'stage', operator: 'is_null', value: true }], ['amount', '>', 1],
|
|
247
|
+
]],
|
|
248
|
+
['a mixed rule/tuple array', [{ field: 'stage', operator: 'eq', value: 'won' }, ['a', '>', 1]]],
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
for (const [name, input] of TRANSLATABLE) {
|
|
252
|
+
for (const route of ['plain', 'expand'] as const) {
|
|
253
|
+
it(`${name} → accepted by isFilterAST (${route})`, async () => {
|
|
254
|
+
expect(isFilterAST(await filterOnWire(input, route))).toBe(true);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('a bare rule object directly under a logical node', () => {
|
|
261
|
+
beforeEach(() => clearSharedDiscoveryCache());
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Produced by any caller that SPREADS a `ViewFilterRule[]` into an `and`
|
|
265
|
+
* rather than wrapping it — `['and', ...rules, ...tuples]`. The rules land as
|
|
266
|
+
* bare objects where the AST expects nodes, `isFilterAST` rejects the whole
|
|
267
|
+
* node (a 400 since objectstack#4121), and `parseFilterAST` reads the rule as
|
|
268
|
+
* a Mongo condition on columns literally named `field`/`operator`/`value`.
|
|
269
|
+
* objectui's own producer was fixed to wrap; this is the chokepoint defence.
|
|
270
|
+
*/
|
|
271
|
+
bothRoutes(
|
|
272
|
+
'is translated in place',
|
|
273
|
+
['and', { field: 'stage', operator: 'eq', value: 'won' }, ['owner', '=', 'me']],
|
|
274
|
+
(wire) => expect(wire).toEqual(['and', ['stage', '=', 'won'], ['owner', '=', 'me']]),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
bothRoutes(
|
|
278
|
+
'leaves a genuine MongoDB condition child alone',
|
|
279
|
+
// No `field` key, so it is a condition on a column named `status` — not a
|
|
280
|
+
// rule. Translating it would invent a filter the caller never wrote.
|
|
281
|
+
['and', { status: 'active' }, ['owner', '=', 'me']],
|
|
282
|
+
(wire) => expect(wire).toEqual(['and', { status: 'active' }, ['owner', '=', 'me']]),
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe('an OBJECT filter reaches the same predicate on both routes', () => {
|
|
287
|
+
beforeEach(() => clearSharedDiscoveryCache());
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The array branch was single-sourced in #3072; the object branch was not.
|
|
291
|
+
* `convertQueryParams` converted a MongoDB-style filter to AST while
|
|
292
|
+
* `translateFilterToAST` returned it verbatim — so the same `$filter` went out
|
|
293
|
+
* in two formats, decided by whether the query expanded a lookup.
|
|
294
|
+
*
|
|
295
|
+
* Measured across 21 operator shapes, four diverged. `$exists` vs `$null` was
|
|
296
|
+
* a cosmetic difference the server treats identically, but the other three
|
|
297
|
+
* were not: see below.
|
|
298
|
+
*/
|
|
299
|
+
bothRoutes(
|
|
300
|
+
'converts a plain equality object',
|
|
301
|
+
{ status: 'active' },
|
|
302
|
+
(wire) => expect(wire).toEqual(['status', '=', 'active']),
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
bothRoutes(
|
|
306
|
+
'converts an operator object',
|
|
307
|
+
{ age: { $gte: 18 } },
|
|
308
|
+
(wire) => expect(wire).toEqual(['age', '>=', 18]),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
bothRoutes(
|
|
312
|
+
'leaves a top-level Mongo logical node alone',
|
|
313
|
+
// `mergeFilters` (dashboard scope filters) produces this. It survives as a
|
|
314
|
+
// `['$and', '=', [...]]` comparison that `parseFilterAST` reads back as a
|
|
315
|
+
// real `$and` — verified, and the reason this is NOT rewritten here.
|
|
316
|
+
{ $and: [{ a: 1 }, { b: 2 }] },
|
|
317
|
+
(wire) => expect(wire).toEqual(['$and', '=', [{ a: 1 }, { b: 2 }]]),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
for (const route of ['plain', 'expand'] as const) {
|
|
321
|
+
it(`refuses an unknown operator on the ${route} route`, async () => {
|
|
322
|
+
// The guard exists "to avoid silent failure" — it used to run on one
|
|
323
|
+
// route only, so a typo shipped silently whenever a lookup was expanded.
|
|
324
|
+
const err = await findRejects({ s: { $bogus: 1 } }, route);
|
|
325
|
+
expect(err).toBeInstanceOf(Error);
|
|
326
|
+
expect(String((err as Error).message)).toContain('$bogus');
|
|
327
|
+
expect(isMalformedFilterError(err)).toBe(true);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it(`refuses $regex rather than answering a different question (${route})`, async () => {
|
|
331
|
+
// Was silently rewritten to `contains`: `$regex: 'a.c'` matches "abc",
|
|
332
|
+
// `contains 'a.c'` does not. The spec has no `$regex` to translate into.
|
|
333
|
+
const err = await findRejects({ s: { $regex: 'a.c' } }, route);
|
|
334
|
+
expect(isMalformedFilterError(err)).toBe(true);
|
|
335
|
+
expect(String((err as Error).message)).toMatch(/regex/i);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe('the two routes agree on shapes that are neither form', () => {
|
|
341
|
+
beforeEach(() => clearSharedDiscoveryCache());
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The inline copy of the sniff omitted the `!== null` guard that this one
|
|
345
|
+
* had, so `[null]` threw a TypeError on the plain route and was handled on
|
|
346
|
+
* the `$expand` route — the same stored filter, decided by whether the view
|
|
347
|
+
* happened to expand a lookup.
|
|
348
|
+
*/
|
|
349
|
+
bothRoutes(
|
|
350
|
+
'does not crash on a null entry',
|
|
351
|
+
[null],
|
|
352
|
+
(wire) => expect(wire).toEqual([null]),
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
bothRoutes(
|
|
356
|
+
'leaves a nested legacy AST alone',
|
|
357
|
+
[['stage', '=', 'won'], ['amount', '>', 1]],
|
|
358
|
+
(wire) => expect(wire).toEqual([['stage', '=', 'won'], ['amount', '>', 1]]),
|
|
359
|
+
);
|
|
360
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Adapter operator table → filter-AST parity (#2901, objectstack#3948).
|
|
11
|
+
*
|
|
12
|
+
* `FILTER_OPERATOR_ALIASES` is the last translation a filter passes through
|
|
13
|
+
* before it goes on the wire, and `normalizeFilterOperator` ends in `?? op` —
|
|
14
|
+
* an unmapped operator is emitted verbatim. The server then rejects the shape
|
|
15
|
+
* at `isFilterAST()`, passes the array through unconverted, and driver-sql
|
|
16
|
+
* skips it entirely: **no WHERE clause, no error, every row returned.**
|
|
17
|
+
*
|
|
18
|
+
* So a missing row in this table is not a validation failure, it is an
|
|
19
|
+
* unfiltered query. `before`/`after` — canonical members of the spec's
|
|
20
|
+
* `VIEW_FILTER_OPERATORS` — were missing, which is exactly how a stored
|
|
21
|
+
* "close_date before X" view came back unfiltered.
|
|
22
|
+
*
|
|
23
|
+
* These tests pin the table against the spec vocabularies in both directions.
|
|
24
|
+
*/
|
|
25
|
+
import { describe, it, expect } from 'vitest';
|
|
26
|
+
import { VALID_AST_OPERATORS } from '@objectstack/spec/data';
|
|
27
|
+
import { VIEW_FILTER_OPERATORS } from '@objectstack/spec/ui';
|
|
28
|
+
import { FILTER_OPERATOR_ALIASES } from './index';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* View operators this adapter is not the bridge for — the value-shape ones the
|
|
32
|
+
* view layer resolves to a null comparison before an operator is ever emitted.
|
|
33
|
+
*/
|
|
34
|
+
const NOT_THIS_ADAPTERS_JOB = new Set(['is_empty', 'is_not_empty']);
|
|
35
|
+
|
|
36
|
+
describe('FILTER_OPERATOR_ALIASES lands inside the spec AST vocabulary', () => {
|
|
37
|
+
it('reads both vocabularies from the spec', () => {
|
|
38
|
+
expect(VIEW_FILTER_OPERATORS.length).toBeGreaterThan(0);
|
|
39
|
+
expect(VALID_AST_OPERATORS.size).toBeGreaterThan(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('every alias target is an operator the AST gate accepts', () => {
|
|
43
|
+
const bad = Object.entries(FILTER_OPERATOR_ALIASES)
|
|
44
|
+
.filter(([, target]) => !VALID_AST_OPERATORS.has(String(target).toLowerCase()))
|
|
45
|
+
.map(([alias, target]) => `${alias} → ${target}`);
|
|
46
|
+
expect(
|
|
47
|
+
bad,
|
|
48
|
+
'these aliases translate to operators VALID_AST_OPERATORS rejects, so the '
|
|
49
|
+
+ 'server drops the filter silently instead of erroring',
|
|
50
|
+
).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('covers every canonical view operator the spec defines', () => {
|
|
54
|
+
const uncovered = VIEW_FILTER_OPERATORS
|
|
55
|
+
.filter((op) => !NOT_THIS_ADAPTERS_JOB.has(op))
|
|
56
|
+
.filter((op) => {
|
|
57
|
+
const target = FILTER_OPERATOR_ALIASES[op] ?? op;
|
|
58
|
+
return !VALID_AST_OPERATORS.has(String(target).toLowerCase());
|
|
59
|
+
});
|
|
60
|
+
expect(
|
|
61
|
+
uncovered,
|
|
62
|
+
'an author can declare these on a ViewFilterRule and the spec validates them, '
|
|
63
|
+
+ 'but they reach the wire unmapped and the filter is silently dropped',
|
|
64
|
+
).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('maps the date comparisons that regressed', () => {
|
|
68
|
+
expect(FILTER_OPERATOR_ALIASES.before).toBe('<');
|
|
69
|
+
expect(FILTER_OPERATOR_ALIASES.after).toBe('>');
|
|
70
|
+
});
|
|
71
|
+
});
|