Skip to content

Commit c82375c

Browse files
committed
test(objectql,rest): pin per-aggregation $contains membership on a multi-valued field beside its where twin
Engine-level over the read shape find() presents on memory, SQLite and PostgreSQL; REST door on a real SqlDriver, SQLite always and the live PostgreSQL / MySQL cells where their URL is set, each row beside its where twin. having keeps the substring reading on a text projection. Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
1 parent a818817 commit c82375c

2 files changed

Lines changed: 406 additions & 0 deletions

File tree

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#20873] A per-aggregation `filter` answers `$contains` / `$notContains` on a
4+
// DECLARED multi-valued field by MEMBERSHIP — the reading `FILTER_OPERATORS`'
5+
// `$contains` docblock (`@objectstack/spec`) declares for a JSON-stored column,
6+
// and the one `where` gives on every SQL dialect — while a scalar string column
7+
// keeps the substring test.
8+
//
9+
// Measured on the base (`origin/main` `212d613c`) through `engine.aggregate`,
10+
// on a real InMemoryDriver, a real SqlDriver over SQLite and the same over a
11+
// live PostgreSQL 16.14, the six rows below (`owners` a `multiple: true` lookup,
12+
// `tags` a `tags` field, `title` text):
13+
//
14+
// | per-aggregation `filter` | base `m`, all three | SQLite / PostgreSQL `where` | `m` now |
15+
// |:--|:--|:--|:--|
16+
// | `owners $contains 'u1'` (the card) | 0 | 2 (d1, d3) | 2 |
17+
// | `owners $contains 'u10'` | 0 | 1 (d5) | 1 |
18+
// | `owners $notContains 'u1'` | 6 | 4 (d2, d4, d5, d6) | 4 |
19+
// | `tags $contains 'red'` | 0 | 2 (d1, d5) | 2 |
20+
// | `$or` of `$contains` (the any-of spelling) | 0 | 2 | 2 |
21+
// | `$not` of `$contains` | 6 | 4 | 4 |
22+
// | `title $contains 'u1'` (the control) | 3 | 3 | 3 |
23+
//
24+
// `d5` holds `['u10']` and `d3` `['redwood']`: the rows on which membership and
25+
// a substring disagree. `driver-memory`'s own `where` answered 3 for the card
26+
// on that base, by a per-element substring; that face is not this file's (it
27+
// is moving to membership on its own card), and the `where` numbers this file
28+
// holds the evaluator to are the SQL family's. The rows are in the read shape
29+
// `find()` presents on all three backends — measured identical on each: an
30+
// array, or `null`.
31+
//
32+
// The SQLite and PostgreSQL cells run through the public door, beside a live
33+
// `where` twin, in `packages/rest`'s `aggregation-filter-array-membership.test.ts`.
34+
35+
import { describe, it, expect } from 'vitest';
36+
import { ObjectQL } from './engine.js';
37+
import { declaredJsonStoredFields, matchesAggregationFilter } from './having-filter.js';
38+
39+
const OBJECT = 'os20873_doc';
40+
41+
const FIELDS = {
42+
title: { type: 'text' },
43+
owners: { type: 'lookup', reference: 'os20873_user', multiple: true },
44+
tags: { type: 'tags' },
45+
};
46+
47+
const ROWS = [
48+
{ id: 'd1', title: 'u1 memo', owners: ['u1', 'u2'], tags: ['red', 'blue'] },
49+
{ id: 'd2', title: 'none', owners: ['u2'], tags: ['blue'] },
50+
{ id: 'd3', title: 'about u10', owners: ['u3', 'u1'], tags: ['redwood'] },
51+
{ id: 'd4', title: 'x', owners: [], tags: [] },
52+
{ id: 'd5', title: 'u1', owners: ['u10'], tags: ['red'] },
53+
{ id: 'd6', title: null, owners: null, tags: null },
54+
];
55+
56+
/** A driver WITHOUT `aggregate()`: the engine reads rows and lowers in memory, where the filter is evaluated. */
57+
function makeRowsDriver(rows: readonly Record<string, unknown>[]) {
58+
return {
59+
name: 'rows-mock',
60+
version: '0.0.0',
61+
supports: {},
62+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
63+
async find() { return rows.map((row) => ({ ...row })); },
64+
async findOne() { return null; },
65+
async create(_o: string, d: any) { return d; },
66+
async update(_o: string, _id: string, d: any) { return d; },
67+
async delete() { return true; },
68+
async count() { return 0; },
69+
async bulkCreate(_o: string, r: any[]) { return r; },
70+
async bulkUpdate() { return []; }, async bulkDelete() {},
71+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
72+
async commit() {}, async rollback() {},
73+
};
74+
}
75+
76+
async function makeEngine(rows: readonly Record<string, unknown>[]) {
77+
const engine = new ObjectQL();
78+
engine.registerDriver(makeRowsDriver(rows) as any, true);
79+
await engine.init();
80+
(engine.registry as any).registerObject({ name: OBJECT, fields: FIELDS });
81+
return engine;
82+
}
83+
84+
/** filter · the rows the SQL family's `where` answers for it, on this fixture. */
85+
const CASES: ReadonlyArray<readonly [string, Record<string, unknown>, readonly string[]]> = [
86+
['the card — $contains a member id', { owners: { $contains: 'u1' } }, ['d1', 'd3']],
87+
['$contains an id another stored id has as a prefix', { owners: { $contains: 'u10' } }, ['d5']],
88+
['$notContains — the exact complement, the null row included (#5298)', { owners: { $notContains: 'u1' } }, ['d2', 'd4', 'd5', 'd6']],
89+
['$contains on a tags field — a member, not a substring of one', { tags: { $contains: 'red' } }, ['d1', 'd5']],
90+
['$notContains on a tags field', { tags: { $notContains: 'red' } }, ['d2', 'd3', 'd4', 'd6']],
91+
['an $or of $contains — the any-of spelling', { $or: [{ owners: { $contains: 'u1' } }, { owners: { $contains: 'u3' } }] }, ['d1', 'd3']],
92+
['$not over $contains', { $not: { owners: { $contains: 'u1' } } }, ['d2', 'd4', 'd5', 'd6']],
93+
['control — $contains on a text field stays a substring', { title: { $contains: 'u1' } }, ['d1', 'd3', 'd5']],
94+
['control — $notContains on a text field stays a substring', { title: { $notContains: 'u1' } }, ['d2', 'd4', 'd6']],
95+
];
96+
97+
describe('[#20873] engine.aggregate — a per-aggregation filter counts a declared multi-valued field by membership', () => {
98+
for (const [name, filter, rows] of CASES) {
99+
it(`${name}: m = ${rows.length}, the rows the SQL where answers`, async () => {
100+
const engine = await makeEngine(ROWS);
101+
const out = await engine.aggregate(OBJECT, {
102+
aggregations: [{ function: 'count', alias: 'n' }, { function: 'count', alias: 'm', filter: filter as never }],
103+
});
104+
expect(out).toEqual([{ n: 6, m: rows.length }]);
105+
// The same rows, not only the same count.
106+
const fields = FIELDS as Record<string, unknown>;
107+
const kept = ROWS.filter((row) => matchesAggregationFilter(row, filter as never, 1, undefined, declaredJsonStoredFields(fields)));
108+
expect(kept.map((row) => row.id)).toEqual(rows);
109+
});
110+
}
111+
112+
it('an empty table counts 0 for each, and n is 0 beside it', async () => {
113+
for (const [, filter] of CASES) {
114+
const engine = await makeEngine([]);
115+
const out = await engine.aggregate(OBJECT, {
116+
aggregations: [{ function: 'count', alias: 'n' }, { function: 'count', alias: 'm', filter: filter as never }],
117+
});
118+
expect(out).toEqual([{ n: 0, m: 0 }]);
119+
}
120+
});
121+
122+
it('per group: each group counts the rows whose array holds the member', async () => {
123+
const engine = await makeEngine([
124+
{ id: 'g1', title: 'a', owners: ['u1'] },
125+
{ id: 'g2', title: 'a', owners: ['u10'] },
126+
{ id: 'g3', title: 'b', owners: ['u2', 'u1'] },
127+
{ id: 'g4', title: 'b', owners: null },
128+
]);
129+
const out = await engine.aggregate(OBJECT, {
130+
groupBy: ['title'],
131+
aggregations: [{ function: 'count', alias: 'n' }, { function: 'count', alias: 'm', filter: { owners: { $contains: 'u1' } } as never }],
132+
});
133+
expect([...out].sort((a, b) => String(a.title).localeCompare(String(b.title)))).toEqual([
134+
{ title: 'a', n: 2, m: 1 },
135+
{ title: 'b', n: 2, m: 1 },
136+
]);
137+
});
138+
});
139+
140+
describe('[#20873] having — the shared walker keeps the substring reading on an aggregated text column', () => {
141+
it('a $contains over a groupBy text projection keeps the groups whose label holds the text', async () => {
142+
const engine = await makeEngine(ROWS);
143+
const out = await engine.aggregate(OBJECT, {
144+
groupBy: ['title'],
145+
aggregations: [{ function: 'count', alias: 'n' }],
146+
having: { title: { $contains: 'u1' } } as never,
147+
});
148+
expect(out.map((row: any) => row.title).sort()).toEqual(['about u10', 'u1', 'u1 memo']);
149+
});
150+
});
151+
152+
describe('[#20873] the member reading — the comparand names a member by its text, as on every SQL dialect', () => {
153+
const NUMS = { nums: { type: 'select', multiple: true } };
154+
const holds = (stored: unknown, comparand: string) =>
155+
matchesAggregationFilter({ nums: stored }, { nums: { $contains: comparand } } as never, 0, undefined, declaredJsonStoredFields(NUMS));
156+
157+
it.each([
158+
['a string member', ['a', 'b'], 'a', true],
159+
['a number member, named by its text', [1, 2], '1', true],
160+
['a number member, named by a non-canonical JSON number', [1.5, 0], '1.50', true],
161+
['a number member, named by an exponent', [100], '1e2', true],
162+
['a boolean member', [true, false], 'true', true],
163+
['a null member', [null], 'null', true],
164+
['a string member that reads as a number', ['1'], '1', true],
165+
['no member: a leading zero is not a JSON number', [1], '01', false],
166+
['no member: a padded number is not a JSON number', [1], ' 1', false],
167+
['no member: a hex spelling is not a JSON number', [16], '0x10', false],
168+
['no member: a substring of a member', ['redwood'], 'red', false],
169+
['no member: a nested array', [['u1']], 'u1', false],
170+
['no member: an object element', [{ k: 'u1' }], 'u1', false],
171+
['no member: a stored scalar string', 'u1 memo', 'u1', false],
172+
['no member: an empty array', [], 'u1', false],
173+
] as const)('%s', (_name, stored, comparand, expected) => {
174+
expect(holds(stored, comparand)).toBe(expected);
175+
});
176+
177+
it('$notContains is the exact complement on every one of those, and holds for a null or absent value', () => {
178+
const notHolds = (row: Record<string, unknown>, comparand: string) =>
179+
matchesAggregationFilter(row, { nums: { $notContains: comparand } } as never, 0, undefined, declaredJsonStoredFields(NUMS));
180+
expect(notHolds({ nums: [1, 2] }, '1')).toBe(false);
181+
expect(notHolds({ nums: ['redwood'] }, 'red')).toBe(true);
182+
expect(notHolds({ nums: null }, '1')).toBe(true);
183+
expect(notHolds({}, '1')).toBe(true);
184+
});
185+
186+
it('the fork is the DECLARATION: an undeclared column holding an array keeps the substring reading', () => {
187+
const row = { owners: ['u1'] };
188+
expect(matchesAggregationFilter(row, { owners: { $contains: 'u1' } } as never, 0)).toBe(false);
189+
expect(matchesAggregationFilter(row, { owners: { $contains: 'u1' } } as never, 0, undefined, declaredJsonStoredFields(FIELDS))).toBe(true);
190+
});
191+
192+
it('the declared population is the spec\'s JSON-stored classes: multi-valued fields and structured-JSON types', () => {
193+
expect([...declaredJsonStoredFields({
194+
title: { type: 'text' },
195+
one_owner: { type: 'lookup' },
196+
owners: { type: 'lookup', multiple: true },
197+
picks: { type: 'multiselect' },
198+
tags: { type: 'tags' },
199+
choice: { type: 'select' },
200+
choices: { type: 'select', multiple: true },
201+
meta: { type: 'json' },
202+
ship_to: { type: 'address' },
203+
broken: null,
204+
untyped: {},
205+
})].sort()).toEqual(['choices', 'meta', 'owners', 'picks', 'ship_to', 'tags']);
206+
expect(declaredJsonStoredFields(undefined).size).toBe(0);
207+
});
208+
});

0 commit comments

Comments
 (0)