|
| 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