|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#20897] `$exists` takes a boolean. A non-boolean is refused on EVERY entry |
| 5 | + * of this package, in `driver-sql`'s words — the `$null` twin's disposition |
| 6 | + * (#5347-A), applied to `$exists` by the ruling on #5298 (#5369). |
| 7 | + * |
| 8 | + * # What was measured |
| 9 | + * |
| 10 | + * `FieldOperatorsSchema` declares `$exists: z.boolean()`, and nothing between an |
| 11 | + * authored `where` and this driver validated it: `driver-sql`, `driver-sqlite-wasm` |
| 12 | + * and both Turso transports refused a non-boolean, and this package did not. |
| 13 | + * Measured on `origin/main` `f6ccca4a`, one row with `stage: 'won'` (id 1) and |
| 14 | + * one with `stage: null` (id 2): |
| 15 | + * |
| 16 | + * | entry | `'yes'` | `1` | `'false'` | `0` / `null` | |
| 17 | + * |---|---|---|---|---| |
| 18 | + * | `find` / `findOne` / `count` / `aggregate` / `updateMany` / `deleteMany` | id 2 | id 2 | id 2 | id 2 | |
| 19 | + * | the analytics (cube) face, `query()` | id 1 | id 1 | id 1 | id 2 | |
| 20 | + * |
| 21 | + * Two answers inside one package, and neither is a refusal. The query path's |
| 22 | + * `val === true` test sent every third value to the NO-value side — `'yes'` |
| 23 | + * asked for the rows without one, the author's intent inverted. The cube face's |
| 24 | + * `set` arm read the same flag by truthiness, so it answered the opposite rows |
| 25 | + * for `'yes'` and `1` — and for the string `'false'`, which is truthy. |
| 26 | + * |
| 27 | + * # Why every entry is asserted, and against `find()` |
| 28 | + * |
| 29 | + * The refusal lives in ONE function (`assertFilterConditionShape`), which every |
| 30 | + * entry runs before it lowers anything. What would let a future change re-fork |
| 31 | + * the answers is an entry reaching its lowering WITHOUT that walk — the cube |
| 32 | + * face's truthiness arm is still there behind it. So each entry is asserted to |
| 33 | + * refuse, and, for the two booleans the spec declares, to answer exactly the |
| 34 | + * rows `find()` answers: the same row set as `find()`, or refused with |
| 35 | + * `INVALID_FILTER` — never a third, quieter answer. |
| 36 | + */ |
| 37 | + |
| 38 | +import { describe, it, expect, beforeEach } from 'vitest'; |
| 39 | +import type { FilterCondition } from '@objectstack/spec/data'; |
| 40 | + |
| 41 | +import { InMemoryDriver } from './memory-driver.js'; |
| 42 | +import { MemoryAnalyticsService } from './memory-analytics.js'; |
| 43 | + |
| 44 | +interface WireBearingError extends Error { |
| 45 | + code?: string; |
| 46 | + status?: number; |
| 47 | +} |
| 48 | + |
| 49 | +const ROWS = [ |
| 50 | + { id: '1', stage: 'won', score: 10 }, |
| 51 | + // The null-valued row that separates "has a value" from "has none". |
| 52 | + { id: '2', stage: null, score: 20 }, |
| 53 | +]; |
| 54 | + |
| 55 | +/** |
| 56 | + * The exact leading sentence `driver-sql` produces for this condition, copied |
| 57 | + * from `sql-driver.ts`'s `nonBooleanExistsComparandError`. A literal rather |
| 58 | + * than an import: this package does not depend on driver-sql (and must not), |
| 59 | + * so one condition, one wording (#5240) is held by pinning the other side's |
| 60 | + * text here. |
| 61 | + */ |
| 62 | +const DRIVER_SQL_LEADING_SENTENCE = (field: string) => |
| 63 | + `Operator "$exists" on field "${field}" requires a boolean comparand (true or false).`; |
| 64 | + |
| 65 | +/** |
| 66 | + * The triage pins (`'yes'`, `1`, the string `'false'`), then the rest of the |
| 67 | + * battery `driver-sql`'s own `$exists` pins run — so the two backends are held |
| 68 | + * to the same inputs. |
| 69 | + */ |
| 70 | +const NON_BOOLEAN: Array<[label: string, value: unknown]> = [ |
| 71 | + ["the string 'yes'", 'yes'], |
| 72 | + ['the number 1', 1], |
| 73 | + ["the STRING 'false'", 'false'], |
| 74 | + ['the number 0', 0], |
| 75 | + ['null', null], |
| 76 | + ['undefined', undefined], |
| 77 | + ['an object', {}], |
| 78 | +]; |
| 79 | + |
| 80 | +const CUBE = { |
| 81 | + name: 'deals', |
| 82 | + title: 'Deals', |
| 83 | + sql: 'deal', |
| 84 | + measures: { total: { label: 'Total', type: 'count', sql: 'id' } }, |
| 85 | + dimensions: { |
| 86 | + id: { label: 'Id', type: 'string', sql: 'id' }, |
| 87 | + stage: { label: 'Stage', type: 'string', sql: 'stage' }, |
| 88 | + }, |
| 89 | + public: true, |
| 90 | +} as never; |
| 91 | + |
| 92 | +describe('[#20897] a non-boolean $exists is refused on every entry of driver-memory', () => { |
| 93 | + let driver: InMemoryDriver; |
| 94 | + let analytics: MemoryAnalyticsService; |
| 95 | + |
| 96 | + beforeEach(async () => { |
| 97 | + driver = new InMemoryDriver({ persistence: false }); |
| 98 | + await driver.syncSchema('deal', { |
| 99 | + fields: { |
| 100 | + id: { type: 'text', name: 'id' }, |
| 101 | + stage: { type: 'text', name: 'stage' }, |
| 102 | + score: { type: 'number', name: 'score' }, |
| 103 | + }, |
| 104 | + } as any); |
| 105 | + for (const row of ROWS) await driver.create('deal', { ...row }); |
| 106 | + analytics = new MemoryAnalyticsService({ driver, cubes: [CUBE] } as never); |
| 107 | + }); |
| 108 | + |
| 109 | + const sorted = (rows: unknown): string[] => (rows as Array<Record<string, unknown>>).map((r) => String(r.id)).sort(); |
| 110 | + const q = (where: unknown) => ({ where: where as FilterCondition }); |
| 111 | + const cubeQuery = (where: unknown) => |
| 112 | + ({ cube: 'deals', measures: ['total'], dimensions: ['id'], where }) as never; |
| 113 | + |
| 114 | + /** |
| 115 | + * Every entry of this package that takes a `where`, by name, with the root its |
| 116 | + * refusal names and whether the shared comparand-TYPE face runs ahead of the |
| 117 | + * gate there. The analytics face runs it first (its door, ADR-0053 D-D1), so |
| 118 | + * a flag that face refuses on TYPE — `undefined`, a plain object — keeps that |
| 119 | + * face's own sentence, the precedence the analytics `where` door and the |
| 120 | + * engine seam give it too; the envelope and the position are the same. |
| 121 | + */ |
| 122 | + const ENTRIES: Array<[name: string, run: (where: unknown) => Promise<unknown>, path: string, typeFaceFirst: boolean]> = [ |
| 123 | + ['find', (w) => driver.find('deal', q(w)), 'filter', false], |
| 124 | + ['findOne', (w) => driver.findOne('deal', q(w)), 'filter', false], |
| 125 | + ['count', (w) => driver.count('deal', q(w)), 'filter', false], |
| 126 | + ['aggregate', (w) => driver.aggregate('deal', { ...q(w), aggregations: [{ function: 'count', alias: 'n' }] } as never), 'filter', false], |
| 127 | + ['updateMany', (w) => driver.updateMany('deal', q(w), { score: 99 }), 'filter', false], |
| 128 | + ['deleteMany', (w) => driver.deleteMany('deal', q(w)), 'filter', false], |
| 129 | + ['the analytics face, query()', (w) => analytics.query(cubeQuery(w)), 'where', true], |
| 130 | + ['the analytics face, generateSql()', (w) => analytics.generateSql(cubeQuery(w)), 'where', true], |
| 131 | + ]; |
| 132 | + |
| 133 | + /** The comparands the comparand-TYPE face refuses before any flag rule is asked. */ |
| 134 | + const TYPE_FACE_REFUSED: ReadonlySet<unknown> = new Set<unknown>([undefined]); |
| 135 | + const isTypeFaceRefused = (value: unknown): boolean => |
| 136 | + TYPE_FACE_REFUSED.has(value) || (typeof value === 'object' && value !== null); |
| 137 | + |
| 138 | + const refusalOf = async (run: () => Promise<unknown>): Promise<WireBearingError> => { |
| 139 | + try { |
| 140 | + await run(); |
| 141 | + } catch (e) { |
| 142 | + return e as WireBearingError; |
| 143 | + } |
| 144 | + throw new Error('expected this entry to refuse the filter, but it resolved'); |
| 145 | + }; |
| 146 | + |
| 147 | + for (const [entry, run, root, typeFaceFirst] of ENTRIES) { |
| 148 | + for (const [label, value] of NON_BOOLEAN) { |
| 149 | + const words = typeFaceFirst && isTypeFaceRefused(value) ? 'the type face\'s words' : 'driver-sql\'s words'; |
| 150 | + it(`${entry} refuses ${label} with INVALID_FILTER / 400, in ${words}`, async () => { |
| 151 | + const err = await refusalOf(() => run({ stage: { $exists: value } })); |
| 152 | + expect(err.code).toBe('INVALID_FILTER'); |
| 153 | + expect(err.status).toBe(400); |
| 154 | + expect(err.message).toContain(`${root}.stage.$exists`); |
| 155 | + // The type face's sentence is its own contract, pinned in its own |
| 156 | + // suite; here only the flag rule's first sentence is load-bearing. |
| 157 | + if (!(typeFaceFirst && isTypeFaceRefused(value))) { |
| 158 | + expect(err.message).toContain(DRIVER_SQL_LEADING_SENTENCE('stage')); |
| 159 | + } |
| 160 | + }); |
| 161 | + } |
| 162 | + } |
| 163 | + |
| 164 | + it('refuses it at every depth, and a satisfiable sibling does not let it through', async () => { |
| 165 | + // The gate is a WALK, not an evaluation: `{ stage: 'won' }` matches and |
| 166 | + // `{}` is the TRUE identity, yet neither short-circuits the refusal. |
| 167 | + for (const [where, at] of [ |
| 168 | + [{ $and: [{ stage: { $exists: 'yes' } }] }, 'filter.$and[0].stage.$exists'], |
| 169 | + [{ $or: [{ stage: 'won' }, { stage: { $exists: 1 } }] }, 'filter.$or[1].stage.$exists'], |
| 170 | + [{ $or: [{}, { stage: { $exists: 'false' } }] }, 'filter.$or[1].stage.$exists'], |
| 171 | + [{ $not: { stage: { $exists: 'yes' } } }, 'filter.$not.stage.$exists'], |
| 172 | + ] as Array<[unknown, string]>) { |
| 173 | + const err = await refusalOf(() => driver.find('deal', q(where))); |
| 174 | + expect(err.code).toBe('INVALID_FILTER'); |
| 175 | + expect(err.status).toBe(400); |
| 176 | + expect(err.message).toContain(at); |
| 177 | + } |
| 178 | + }); |
| 179 | + |
| 180 | + it('a refused write leaves the store untouched', async () => { |
| 181 | + await refusalOf(() => driver.updateMany('deal', q({ stage: { $exists: 'yes' } }), { score: 99 })); |
| 182 | + await refusalOf(() => driver.deleteMany('deal', q({ stage: { $exists: 1 } }))); |
| 183 | + const rows = (await driver.find('deal', {} as never)) as Array<Record<string, unknown>>; |
| 184 | + expect(rows.map((r) => [String(r.id), r.score]).sort()).toEqual([['1', 10], ['2', 20]]); |
| 185 | + }); |
| 186 | + |
| 187 | + describe('the control: true and false answer find()\'s rows on every read entry', () => { |
| 188 | + for (const [flag, expected] of [[true, ['1']], [false, ['2']]] as Array<[boolean, string[]]>) { |
| 189 | + it(`$exists: ${flag} selects ${JSON.stringify(expected)}, and every read entry agrees with find()`, async () => { |
| 190 | + const where = { stage: { $exists: flag } }; |
| 191 | + const found = sorted(await driver.find('deal', q(where))); |
| 192 | + expect(found).toEqual(expected); |
| 193 | + expect(await driver.count('deal', q(where))).toBe(expected.length); |
| 194 | + expect(String((await driver.findOne('deal', q(where)) as Record<string, unknown>).id)).toBe(expected[0]); |
| 195 | + const cube = (await analytics.query(cubeQuery(where))).rows as Array<Record<string, unknown>>; |
| 196 | + expect(cube.map((r) => String(r.id)).sort()).toEqual(found); |
| 197 | + }); |
| 198 | + } |
| 199 | + }); |
| 200 | +}); |
0 commit comments