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