Skip to content

Commit c28206f

Browse files
committed
test(driver-memory): pin the JSON-stored family refusal on find, the write doors and the analytics face
Flip the one per-element pin the suite carried ($ne beside $empty on a tags field) to a refusal pin, keep its composition through $null: false, and note the declared population on the scalar-column array suite. Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
1 parent a4bcdc0 commit c28206f

3 files changed

Lines changed: 353 additions & 1 deletion

File tree

‎packages/drivers/driver-memory/src/memory-20444-empty-operator.test.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import { beforeAll, describe, expect, it } from 'vitest';
2929
import type { Cube, FilterCondition } from '@objectstack/spec/data';
3030
import { isEmptyFilterValue } from '@objectstack/spec/data';
31+
import { jsonColumnOperatorRefusalText } from '@objectstack/core';
3132
import { InMemoryDriver } from './memory-driver.js';
3233
import { assertFilterConditionShape } from './filter-refusal.js';
3334
import { MemoryAnalyticsService } from './memory-analytics.js';
@@ -65,7 +66,12 @@ const CASES: Array<{ where: FilterCondition; expected: string[] }> = [
6566
{ where: { $or: [{ score: 5 }, { tags: { $empty: true } }] }, expected: ['r1', 'r2', 'r3', 'r5'] },
6667
{ where: { $and: [{ title: { $empty: false } }, { owners: { $empty: false } }] }, expected: ['r1', 'r4'] },
6768
{ where: { title: { $empty: false, $ne: 'x' } }, expected: ['r4'] },
68-
{ where: { tags: { $empty: true, $ne: null } }, expected: ['r2'] },
69+
// [#21066] This cell was `{ tags: { $empty: true, $ne: null } }` → `r2`. A
70+
// `$ne` on a declared multi-value field is now refused, as the SQL family
71+
// refuses it (pinned below), so the composition — `$empty` beside a "has a
72+
// value" sibling on ONE multi-value field — is held through `$null: false`,
73+
// the sibling that still answers there. `driver-sql`/SQLite answers `r2` too.
74+
{ where: { tags: { $empty: true, $null: false } }, expected: ['r2'] },
6975
];
7076

7177
function refusal(run: () => unknown): Promise<{ code?: string; status?: number } | 'answered'> {
@@ -150,6 +156,20 @@ describe('[#20444] InMemoryDriver — $empty on the live path, the by-value read
150156
expect(byValue(rows, { score: { $empty: true } })).toEqual(['blank']);
151157
});
152158

159+
it('[#21066] $ne beside $empty on a declared multi-value field is refused, in the SQL family\'s words', async () => {
160+
let thrown: { code?: string; status?: number; message?: string } | undefined;
161+
try {
162+
await driver.find(TABLE, { where: { tags: { $empty: true, $ne: null } } });
163+
} catch (err) {
164+
thrown = err as typeof thrown;
165+
}
166+
expect({ code: thrown?.code, status: thrown?.status, message: thrown?.message }).toEqual({
167+
code: 'INVALID_FILTER',
168+
status: 400,
169+
message: jsonColumnOperatorRefusalText('tags', '$ne', false).message,
170+
});
171+
});
172+
153173
it('a non-boolean flag is refused on the live path and by the shared shape gate itself', async () => {
154174
expect(await refusal(() => driver.find(TABLE, { where: { title: { $empty: 'yes' as never } } })))
155175
.toEqual({ code: 'INVALID_FILTER', status: 400 });
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21066] On a field DECLARED JSON-stored — a `multiple: true` field, an
5+
* inherently multi-value option type (`tags`) or a structured-JSON type
6+
* (`json`) — this driver REFUSES the scalar-comparison family `driver-sql`'s
7+
* `where` refuses (#7398) and the engine's per-aggregation `filter` refuses
8+
* (#21007): `INVALID_FILTER` / 400, in the same words, read from the one set
9+
* and sentence `@objectstack/core` holds. `$contains` / `$notContains`
10+
* (membership), the null predicates and `$empty` keep answering.
11+
*
12+
* ## The defect, measured through `engine.find` before the change
13+
*
14+
* `origin/main` `670680e93`, a real `ObjectQL` over this driver, the six rows
15+
* below (#21004's fixture). The SQL family refuses every one of these 400
16+
* `INVALID_FILTER`; this driver answered each PER ELEMENT, through mingo's array
17+
* semantics:
18+
*
19+
* | `where` | before | now |
20+
* |:--|:--|:--|
21+
* | `owners` `$eq 'u1'` | `d1`, `d3` | 400 |
22+
* | `owners` `$in ['u1','u9']` | `d1`, `d3` | 400 |
23+
* | `owners` `$nin ['u1','u9']` | `d2`, `d4`, `d5`, `d6` | 400 |
24+
* | `owners` `$gt 'u1'` | `d1`, `d2`, `d3`, `d5` | 400 |
25+
* | `tags` `$gt 'red'` | `d3` | 400 |
26+
* | `owners` `$contains 'u1'` (the prescription, the control) | `d1`, `d3` | `d1`, `d3` |
27+
*
28+
* The analytics (cube) face answered the same rows, and its SQL echo rendered
29+
* `owners = 'u1'` — a statement that, run over the JSON text the SQL family
30+
* stores, returns none. The engine's run was a one-off: this package may not
31+
* import the engine (`check:driver-memory-census`), so what is pinned here is
32+
* the `where` the engine hands the driver, recorded in that run — the card's
33+
* operators as written, and `$ne` / `$nin` inside the spec's null-safe lowering
34+
* (`{ $and: [{ $or: [{ f: { $null: true } }, { f: { $nin: [...] } }] }] }`).
35+
*
36+
* ## Why the words are compared, not just the code
37+
*
38+
* The refusal is ONE refusal across backends, so a caller swapping this driver
39+
* for SQL must see the same body. The message is asserted equal to
40+
* `jsonColumnOperatorRefusalText`'s — imported, so the sentence has no copy
41+
* here — on top of `code` and `status`.
42+
*/
43+
44+
import { describe, it, expect, beforeAll, vi } from 'vitest';
45+
import type { Cube } from '@objectstack/spec/data';
46+
import { JSON_COLUMN_INCOMPATIBLE_OPERATORS, jsonColumnOperatorRefusalText } from '@objectstack/core';
47+
import { InMemoryDriver } from './memory-driver.js';
48+
import { MemoryAnalyticsService } from './memory-analytics.js';
49+
import {
50+
assertFilterConditionShape,
51+
DRIVER_FILTER_CAPABILITIES,
52+
SUPPORTED_FIELD_OPERATORS,
53+
} from './filter-refusal.js';
54+
55+
const OBJECT = 'mem21066_doc';
56+
57+
const FIELDS = {
58+
title: { type: 'text' },
59+
owners: { type: 'lookup', reference: 'mem21066_user', multiple: true },
60+
tags: { type: 'tags' },
61+
meta: { type: 'json' },
62+
} as const;
63+
64+
/** #21004's six rows, plus a `json` column. */
65+
const ROWS = [
66+
{ id: 'd1', title: 'u1 memo', owners: ['u1', 'u2'], tags: ['red', 'blue'], meta: ['a'] },
67+
{ id: 'd2', title: 'none', owners: ['u2'], tags: ['blue'], meta: { k: 'a' } },
68+
{ id: 'd3', title: 'about u10', owners: ['u3', 'u1'], tags: ['redwood'], meta: 'a' },
69+
{ id: 'd4', title: 'x', owners: [], tags: [], meta: [] },
70+
{ id: 'd5', title: 'u1', owners: ['u10'], tags: ['red'], meta: null },
71+
{ id: 'd6', title: null, owners: null, tags: null, meta: null },
72+
] as const;
73+
74+
const byId = (a: string, b: string) => a.localeCompare(b);
75+
76+
interface Refusal {
77+
code?: string;
78+
status?: number;
79+
message?: string;
80+
}
81+
82+
async function refusalOf(run: () => unknown): Promise<Refusal | 'answered'> {
83+
try {
84+
await run();
85+
return 'answered';
86+
} catch (err) {
87+
const e = err as Refusal;
88+
return { code: e.code, status: e.status, message: e.message };
89+
}
90+
}
91+
92+
/** What the SQL family answers for the same filter — the shared text, never a copy. */
93+
function sharedRefusal(field: string, op: string, bare: boolean): Refusal {
94+
return { code: 'INVALID_FILTER', status: 400, message: jsonColumnOperatorRefusalText(field, op, bare).message };
95+
}
96+
97+
async function seed({ declare = true } = {}): Promise<{ driver: InMemoryDriver; warn: ReturnType<typeof vi.spyOn> }> {
98+
const driver = new InMemoryDriver({ persistence: false });
99+
await driver.connect();
100+
if (declare) await driver.syncSchema(OBJECT, { name: OBJECT, fields: FIELDS } as never);
101+
for (const row of ROWS) await driver.create(OBJECT, structuredClone({ ...row }) as never);
102+
const warn = vi.spyOn((driver as unknown as { logger: { warn: (...a: unknown[]) => void } }).logger, 'warn')
103+
.mockImplementation(() => undefined);
104+
return { driver, warn };
105+
}
106+
107+
const findIds = async (driver: InMemoryDriver, where: unknown): Promise<string[]> =>
108+
((await driver.find(OBJECT, { where } as never)) as Array<Record<string, unknown>>).map((r) => String(r.id)).sort(byId);
109+
110+
/** A comparand an author could write for `op` on `field` — a list for the list operators, a pair for `$between`. */
111+
function comparandFor(op: string, field: string): unknown {
112+
const [a, b] = field === 'owners' ? ['u1', 'u9'] : field === 'tags' ? ['red', 'green'] : ['a', 'b'];
113+
if (op === '$in' || op === '$nin') return [a, b];
114+
if (op === '$between') return [a, b];
115+
return a;
116+
}
117+
118+
/**
119+
* The family as THIS driver's FilterCondition vocabulary spells it: every
120+
* member of the shared set the shape gate admits as an operator. The bare infix
121+
* spellings (`=`, `in`, …) are not FilterCondition operators here and are
122+
* refused by the vocabulary gate before this one; they are the AST door's.
123+
*/
124+
const FAMILY = [...JSON_COLUMN_INCOMPATIBLE_OPERATORS].filter((op) => SUPPORTED_FIELD_OPERATORS.has(op));
125+
126+
/** The card's table: [name, where, field, operator the refusal names, bare?]. */
127+
const CARD: ReadonlyArray<readonly [string, Record<string, unknown>, string, string, boolean]> = [
128+
["owners $eq 'u1'", { owners: { $eq: 'u1' } }, 'owners', '$eq', false],
129+
["owners $in ['u1','u9']", { owners: { $in: ['u1', 'u9'] } }, 'owners', '$in', false],
130+
["owners $nin ['u1','u9'] (as the engine lowers it)", { $and: [{ $or: [{ owners: { $null: true } }, { owners: { $nin: ['u1', 'u9'] } }] }] }, 'owners', '$nin', false],
131+
["owners $gt 'u1'", { owners: { $gt: 'u1' } }, 'owners', '$gt', false],
132+
["tags $gt 'red'", { tags: { $gt: 'red' } }, 'tags', '$gt', false],
133+
];
134+
135+
/** Shapes of the family beyond one operator — every one refused, as `driver-sql`'s `where` refuses it. */
136+
function shapes(field: string): ReadonlyArray<readonly [string, Record<string, unknown>, string, boolean]> {
137+
const a = comparandFor('$eq', field);
138+
return [
139+
['implicit equality', { [field]: a }, '=', true],
140+
['implicit equality with null', { [field]: null }, '=', true],
141+
['$eq null', { [field]: { $eq: null } }, '$eq', false],
142+
['$ne null', { [field]: { $ne: null } }, '$ne', false],
143+
['$ne, as the engine lowers it', { $and: [{ $or: [{ [field]: { $null: true } }, { [field]: { $ne: a } }] }] }, '$ne', false],
144+
['$in []', { [field]: { $in: [] } }, '$in', false],
145+
['$nin []', { [field]: { $nin: [] } }, '$nin', false],
146+
['$in under $not', { $not: { [field]: { $in: [a] } } }, '$in', false],
147+
['$nin in an $or branch after one that holds', { $or: [{ title: 'x' }, { [field]: { $nin: [a] } }] }, '$nin', false],
148+
['$eq beside $contains on the same field', { [field]: { $contains: a, $eq: a } }, '$eq', false],
149+
];
150+
}
151+
152+
describe('[#21066] InMemoryDriver refuses the scalar-comparison family on a declared JSON-stored field', () => {
153+
let driver: InMemoryDriver;
154+
let warn: ReturnType<typeof vi.spyOn>;
155+
beforeAll(async () => {
156+
({ driver, warn } = await seed());
157+
});
158+
159+
it('the fixture holds all six rows, and the family it iterates is the shared set, at least the nine $-spellings', async () => {
160+
expect(await findIds(driver, {})).toEqual(['d1', 'd2', 'd3', 'd4', 'd5', 'd6']);
161+
// A floor, not an equality: a member the shared set gains (a later card
162+
// widening it) is pinned here by the loops below without editing this file.
163+
expect(FAMILY).toEqual(expect.arrayContaining(['$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$between', '$in', '$nin']));
164+
});
165+
166+
for (const [name, where, field, op, bare] of CARD) {
167+
it(`the card: ${name} → 400 INVALID_FILTER in the SQL family's words`, async () => {
168+
expect(await refusalOf(() => driver.find(OBJECT, { where } as never))).toEqual(sharedRefusal(field, op, bare));
169+
});
170+
}
171+
172+
for (const field of ['owners', 'tags', 'meta']) {
173+
for (const op of FAMILY) {
174+
it(`${field} ${op} → refused`, async () => {
175+
const where = { [field]: { [op]: comparandFor(op, field) } };
176+
expect(await refusalOf(() => driver.find(OBJECT, { where } as never))).toEqual(sharedRefusal(field, op, false));
177+
});
178+
}
179+
for (const [name, where, op, bare] of shapes(field)) {
180+
it(`${field}: ${name} → refused`, async () => {
181+
expect(await refusalOf(() => driver.find(OBJECT, { where } as never))).toEqual(sharedRefusal(field, op, bare));
182+
});
183+
}
184+
}
185+
186+
it('every filter door of the query path refuses — count, findOne, updateMany and deleteMany — and a write leaves the table untouched', async () => {
187+
const where = { owners: { $nin: ['u1'] } };
188+
const expected = sharedRefusal('owners', '$nin', false);
189+
expect(await refusalOf(() => driver.count(OBJECT, { where } as never))).toEqual(expected);
190+
expect(await refusalOf(() => driver.findOne(OBJECT, { where } as never))).toEqual(expected);
191+
expect(await refusalOf(() => driver.updateMany(OBJECT, { where } as never, { title: 'clobbered' }))).toEqual(expected);
192+
expect(await refusalOf(() => driver.deleteMany(OBJECT, { where } as never))).toEqual(expected);
193+
const rows = (await driver.find(OBJECT, {} as never)) as Array<Record<string, unknown>>;
194+
expect(rows.map((r) => r.id).sort()).toEqual(['d1', 'd2', 'd3', 'd4', 'd5', 'd6']);
195+
expect(rows.some((r) => r.title === 'clobbered')).toBe(false);
196+
});
197+
198+
it('the message withholds the field and the operator; the diagnostic, with its position, goes to the server log', async () => {
199+
warn.mockClear();
200+
const got = await refusalOf(() => driver.find(OBJECT, { where: { $or: [{ title: 'x' }, { owners: { $gte: 'u1' } }] } } as never));
201+
expect(got).toEqual(sharedRefusal('owners', '$gte', false));
202+
const message = (got as Refusal).message!;
203+
expect(message).not.toContain('owners');
204+
expect(message).not.toContain('$gte');
205+
expect(warn).toHaveBeenCalledTimes(1);
206+
const line = String(warn.mock.calls[0]![0]);
207+
expect(line).toContain('At filter.$or[1].owners.$gte: ');
208+
expect(line).toContain(jsonColumnOperatorRefusalText('owners', '$gte', false).diagnostic);
209+
});
210+
211+
it('a comparand the gate already refuses is still told about its COMPARAND first — driver-sql\'s order', async () => {
212+
// An array under a single-value operator, and a one-element `$between`:
213+
// both `INVALID_FILTER` / 400 already, in their own words.
214+
const array = await refusalOf(() => driver.find(OBJECT, { where: { owners: { $eq: ['u1'] } } } as never));
215+
expect(array).toMatchObject({ code: 'INVALID_FILTER', status: 400 });
216+
expect((array as Refusal).message).toContain('requires a single comparable value');
217+
const between = await refusalOf(() => driver.find(OBJECT, { where: { owners: { $between: ['u1'] } } } as never));
218+
expect(between).toMatchObject({ code: 'INVALID_FILTER', status: 400 });
219+
expect((between as Refusal).message).toContain('requires a [min, max] value array');
220+
});
221+
222+
// The other half of the contract: what still answers on the same fields.
223+
const ANSWERED: ReadonlyArray<readonly [string, Record<string, unknown>, readonly string[]]> = [
224+
["owners $contains 'u1' — the prescription", { owners: { $contains: 'u1' } }, ['d1', 'd3']],
225+
['an $or of $contains — the any-of prescription', { $or: [{ owners: { $contains: 'u1' } }, { owners: { $contains: 'u10' } }] }, ['d1', 'd3', 'd5']],
226+
["owners $notContains 'u1', as the engine lowers it", { $and: [{ $or: [{ owners: { $null: true } }, { owners: { $notContains: 'u1' } }] }] }, ['d2', 'd4', 'd5', 'd6']],
227+
["tags $contains 'red'", { tags: { $contains: 'red' } }, ['d1', 'd5']],
228+
['owners $null: true', { owners: { $null: true } }, ['d6']],
229+
['owners $exists: false', { owners: { $exists: false } }, ['d6']],
230+
['owners $empty: true', { owners: { $empty: true } }, ['d4', 'd6']],
231+
['meta $null: false', { meta: { $null: false } }, ['d1', 'd2', 'd3', 'd4']],
232+
['title $in — a scalar column, untouched', { title: { $in: ['u1', 'none'] } }, ['d2', 'd5']],
233+
["title implicit equality — a scalar column, untouched", { title: 'x' }, ['d4']],
234+
["title $gt — a scalar column, untouched", { title: { $gt: 'u1' } }, ['d1', 'd4']],
235+
];
236+
for (const [name, where, expected] of ANSWERED) {
237+
it(`still answered: ${name}`, async () => {
238+
expect(await findIds(driver, where)).toEqual([...expected]);
239+
});
240+
}
241+
});
242+
243+
describe('[#21066] the population is the DECLARATION', () => {
244+
it('an object never passed through syncSchema is not judged — the per-element answer it always had, as driver-sql answers a table it was never told about', async () => {
245+
const { driver } = await seed({ declare: false });
246+
expect(await findIds(driver, { owners: { $eq: 'u1' } })).toEqual(['d1', 'd3']);
247+
expect(await findIds(driver, { tags: { $gt: 'red' } })).toEqual(['d3']);
248+
});
249+
250+
it('a declared SCALAR field is not judged by the row it holds: a text column holding an array still answers', async () => {
251+
const driver = new InMemoryDriver({ persistence: false });
252+
await driver.connect();
253+
await driver.syncSchema('mem21066_scalar', { fields: { label: { type: 'text' } } } as never);
254+
await driver.create('mem21066_scalar', { id: 's1', label: ['a', 'b'] } as never);
255+
const rows = (await driver.find('mem21066_scalar', { where: { label: { $eq: 'a' } } } as never)) as Array<Record<string, unknown>>;
256+
expect(rows.map((r) => r.id)).toEqual(['s1']);
257+
});
258+
259+
it('the shared shape gate refuses only when it is handed the declarations, and reports before it throws', () => {
260+
const reported: string[] = [];
261+
const declarations = {
262+
isJsonStoredField: (field: string) => field === 'owners',
263+
reportWithheld: (diagnostic: string) => { reported.push(diagnostic); },
264+
};
265+
const where = { $not: { owners: { $in: ['u1'] } } };
266+
expect(() => assertFilterConditionShape(where, 'filter')).not.toThrow();
267+
expect(() => assertFilterConditionShape({ title: { $in: ['u1'] } }, 'filter', DRIVER_FILTER_CAPABILITIES, declarations)).not.toThrow();
268+
let thrown: Refusal | undefined;
269+
try {
270+
assertFilterConditionShape(where, 'filter', DRIVER_FILTER_CAPABILITIES, declarations);
271+
} catch (err) {
272+
thrown = err as Refusal;
273+
}
274+
expect({ code: thrown?.code, status: thrown?.status, message: thrown?.message }).toEqual(sharedRefusal('owners', '$in', false));
275+
expect(reported).toEqual([`At filter.$not.owners.$in: ${jsonColumnOperatorRefusalText('owners', '$in', false).diagnostic}`]);
276+
});
277+
});
278+
279+
describe('[#21066] the analytics (cube) face refuses the same filters, and its SQL echo with it', () => {
280+
let service: MemoryAnalyticsService;
281+
let warn: ReturnType<typeof vi.spyOn>;
282+
283+
beforeAll(async () => {
284+
const { driver } = await seed();
285+
const dimensions: Record<string, unknown> = { id: { label: 'Id', type: 'string', sql: 'id' } };
286+
for (const field of Object.keys(FIELDS)) dimensions[field] = { label: field, type: 'string', sql: field };
287+
const cube = {
288+
name: 'docs',
289+
title: 'Docs',
290+
sql: OBJECT,
291+
measures: { count: { label: 'Count', type: 'count', sql: 'id' } },
292+
dimensions,
293+
} as unknown as Cube;
294+
service = new MemoryAnalyticsService({ driver, cubes: [cube] });
295+
warn = vi.spyOn((service as unknown as { logger: { warn: (...a: unknown[]) => void } }).logger, 'warn')
296+
.mockImplementation(() => undefined);
297+
});
298+
299+
const query = (where: unknown) => ({ cube: 'docs', measures: ['docs.count'], dimensions: ['docs.id'], where }) as never;
300+
301+
for (const [name, where, field, op, bare] of [
302+
...CARD.filter(([name]) => !name.includes('as the engine lowers it')),
303+
["owners $nin ['u1','u9'] — the face lowers it itself", { owners: { $nin: ['u1', 'u9'] } }, 'owners', '$nin', false] as const,
304+
["owners implicit equality 'u1'", { owners: 'u1' }, 'owners', '=', true] as const,
305+
["the member spelled cube.member — docs.owners $in", { 'docs.owners': { $in: ['u1'] } }, 'docs.owners', '$in', false] as const,
306+
]) {
307+
it(`${name} → refused by query() and by generateSql()`, async () => {
308+
const expected = sharedRefusal(field, op, bare);
309+
expect(await refusalOf(() => service.query(query(where)))).toEqual(expected);
310+
expect(await refusalOf(() => service.generateSql(query(where)))).toEqual(expected);
311+
});
312+
}
313+
314+
it('the withheld half goes to the face\'s own log', async () => {
315+
warn.mockClear();
316+
await refusalOf(() => service.query(query({ tags: { $gt: 'red' } })));
317+
expect(warn).toHaveBeenCalledTimes(1);
318+
expect(String(warn.mock.calls[0]![0])).toContain(`At where.tags.$gt: ${jsonColumnOperatorRefusalText('tags', '$gt', false).diagnostic}`);
319+
});
320+
321+
it("$contains still answers on the face — find()'s rows", async () => {
322+
const result = await service.query(query({ owners: { $contains: 'u1' } }));
323+
expect(result.rows.map((r: Record<string, unknown>) => String(r['docs.id'])).sort(byId)).toEqual(['d1', 'd3']);
324+
});
325+
});

0 commit comments

Comments
 (0)