|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#29394] The `$search` expansion emits no text clause over a column whose |
| 5 | + * DECLARED type can never hold a string — the #15661 door's own verdict |
| 6 | + * (`textOperatorDoorVerdict`, `@objectstack/spec/data`), read, never re-listed. |
| 7 | + * |
| 8 | + * The door judges the caller's `where` before `expandSearchOnAst` runs, so the |
| 9 | + * expansion's `$icontains` never met it: a declared `searchableFields` naming a |
| 10 | + * `number` / `boolean` / `date` / `json` column (and, once `searchable: true` |
| 11 | + * joins the auto set, a flagged one) sent the driver exactly the predicate the |
| 12 | + * door refuses when a caller writes it. Beneath the door each backend answered |
| 13 | + * that predicate its own way: the SQL family refuses it on a JSON column |
| 14 | + * (`INVALID_FILTER`, so the whole search fails) and type-gates it to no-match |
| 15 | + * on a numeric / boolean / temporal one, while a value-keyed evaluator matches |
| 16 | + * the value's text rendering (`42` hits `'4'`, a date hits `'2026'`). |
| 17 | + * |
| 18 | + * The answer chosen is SKIP: such a column contributes no clause. A search |
| 19 | + * term is text, and a column that can never hold text has nothing for it to |
| 20 | + * match, on any backend — the same declared no-match the SQL faces compile. |
| 21 | + * The resolved search set is unchanged, so every refusal judged on it (#24284 |
| 22 | + * empty set, #28120 override) answers as before, and a set holding ONLY such |
| 23 | + * columns answers no rows: each term's `$or` has no arm, which is FALSE |
| 24 | + * (`FILTER_LOGIC_CASES`, "empty $or is FALSE"). Not an error, not every row. |
| 25 | + * |
| 26 | + * Dialect-neutral: the pin is on the AST the driver receives. The real-driver |
| 27 | + * half is `packages/rest/src/data-search-non-text-column-door.test.ts`. |
| 28 | + */ |
| 29 | + |
| 30 | +import { describe, it, expect, beforeEach } from 'vitest'; |
| 31 | +import { |
| 32 | + TEXT_OPERATOR_DOOR_FIXTURE, |
| 33 | + TEXT_OPERATOR_DOOR_FIXTURE_OBJECT, |
| 34 | + textOperatorDoorVerdict, |
| 35 | +} from '@objectstack/spec/data'; |
| 36 | +import { ObjectQL } from './engine.js'; |
| 37 | +import { findTextOperatorOverNonTextField } from './text-operator-declared-type-door.js'; |
| 38 | + |
| 39 | +type Row = Record<string, unknown>; |
| 40 | + |
| 41 | +const OBJECT = 'search_non_text_29394'; |
| 42 | +const ONLY_NON_TEXT = 'search_only_non_text_29394'; |
| 43 | + |
| 44 | +const FIELDS = { |
| 45 | + name: { type: 'text' }, |
| 46 | + qty: { type: 'number' }, |
| 47 | + born: { type: 'date' }, |
| 48 | + active: { type: 'boolean' }, |
| 49 | + meta: { type: 'json' }, |
| 50 | +} as const; |
| 51 | + |
| 52 | +const ROWS: Row[] = [ |
| 53 | + { id: 'r1', name: 'qq9-alpha', qty: 42, born: '2026-01-05', active: true, meta: { k: 'qq9' } }, |
| 54 | + { id: 'r2', name: 'qq9-beta', qty: 7, born: '2025-03-01', active: false, meta: { k: 'x' } }, |
| 55 | + { id: 'r3', name: 'other', qty: 420, born: '2026-02-02', active: true, meta: { k: 'qq9' } }, |
| 56 | +]; |
| 57 | + |
| 58 | +/** The keys every clause of an emitted search `$or` names — the fields the search scanned. */ |
| 59 | +function scannedFields(where: unknown): string[] { |
| 60 | + const out = new Set<string>(); |
| 61 | + const walk = (node: unknown): void => { |
| 62 | + if (!node || typeof node !== 'object' || Array.isArray(node)) return; |
| 63 | + for (const [k, v] of Object.entries(node as Row)) { |
| 64 | + if (k === '$and' || k === '$or') { for (const arm of v as unknown[]) walk(arm); continue; } |
| 65 | + if (!k.startsWith('$')) out.add(k); |
| 66 | + } |
| 67 | + }; |
| 68 | + walk(where); |
| 69 | + return [...out].sort(); |
| 70 | +} |
| 71 | + |
| 72 | +/** |
| 73 | + * A value-keyed evaluator, the shape of the in-memory faces: a text operator |
| 74 | + * reads the stored value's text rendering. That is exactly how a search term |
| 75 | + * matched `42` or a date on such a face before the skip. |
| 76 | + */ |
| 77 | +const MATCHERS: Record<string, (value: unknown, operand: unknown) => boolean> = { |
| 78 | + $icontains: (value, operand) => value != null && String(value).toLowerCase().includes(String(operand).toLowerCase()), |
| 79 | +}; |
| 80 | + |
| 81 | +function matches(row: Row, where: unknown): boolean { |
| 82 | + if (!where || typeof where !== 'object') return true; |
| 83 | + for (const [k, v] of Object.entries(where as Row)) { |
| 84 | + if (k === '$and') { if (!(v as unknown[]).every((w) => matches(row, w))) return false; continue; } |
| 85 | + if (k === '$or') { if (!(v as unknown[]).some((w) => matches(row, w))) return false; continue; } |
| 86 | + if (k.startsWith('$')) throw new Error(`matcher: unimplemented key ${k}`); |
| 87 | + if (v && typeof v === 'object' && !Array.isArray(v)) { |
| 88 | + for (const [op, operand] of Object.entries(v as Row)) { |
| 89 | + const m = MATCHERS[op]; |
| 90 | + if (!m) throw new Error(`matcher: unimplemented operator ${op}`); |
| 91 | + if (!m(row[k], operand)) return false; |
| 92 | + } |
| 93 | + continue; |
| 94 | + } |
| 95 | + if (row[k] !== v) return false; |
| 96 | + } |
| 97 | + return true; |
| 98 | +} |
| 99 | + |
| 100 | +function makeDriver() { |
| 101 | + const reads: Array<{ object: string; where: unknown }> = []; |
| 102 | + const read = (object: string, ast: any) => { |
| 103 | + reads.push({ object, where: ast?.where }); |
| 104 | + const rows = (object === OBJECT || object === ONLY_NON_TEXT ? ROWS : []).filter((r) => matches(r, ast?.where)); |
| 105 | + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; |
| 106 | + }; |
| 107 | + const driver: any = { |
| 108 | + name: 'mem', version: '0.0.0', supports: {}, |
| 109 | + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, |
| 110 | + async find(object: string, ast: any) { return read(object, ast).map((r) => ({ ...r })); }, |
| 111 | + async findOne(object: string, ast: any) { return read(object, ast)[0] ?? null; }, |
| 112 | + async count(object: string, ast: any) { return read(object, ast).length; }, |
| 113 | + async create(_o: string, d: any) { return d; }, |
| 114 | + async update(_o: string, _id: string, d: any) { return d; }, |
| 115 | + async delete() { return true; }, |
| 116 | + async deleteMany() { return 0; }, |
| 117 | + async bulkCreate(_o: string, r: any[]) { return r; }, |
| 118 | + async bulkUpdate() { return []; }, async bulkDelete() {}, |
| 119 | + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, |
| 120 | + async commit() {}, async rollback() {}, |
| 121 | + }; |
| 122 | + return { driver, reads }; |
| 123 | +} |
| 124 | + |
| 125 | +describe('[#29394] $search emits no text clause over a never-string-capable column', () => { |
| 126 | + let engine: ObjectQL; |
| 127 | + let reads: ReturnType<typeof makeDriver>['reads']; |
| 128 | + const lastWhere = (object = OBJECT) => [...reads].reverse().find((r) => r.object === object)?.where; |
| 129 | + |
| 130 | + beforeEach(async () => { |
| 131 | + const made = makeDriver(); |
| 132 | + reads = made.reads; |
| 133 | + engine = new ObjectQL(); |
| 134 | + engine.registerDriver(made.driver, true); |
| 135 | + await engine.init(); |
| 136 | + const registry = engine.registry as any; |
| 137 | + registry.registerObject({ |
| 138 | + name: OBJECT, |
| 139 | + searchableFields: ['name', 'qty', 'born', 'active', 'meta'], |
| 140 | + fields: FIELDS, |
| 141 | + }, 'test'); |
| 142 | + registry.registerObject({ |
| 143 | + name: ONLY_NON_TEXT, |
| 144 | + searchableFields: ['qty', 'born', 'active', 'meta'], |
| 145 | + fields: FIELDS, |
| 146 | + }, 'test'); |
| 147 | + registry.registerObject({ |
| 148 | + ...TEXT_OPERATOR_DOOR_FIXTURE, |
| 149 | + // Every fixture field the declared branch keeps: one per FieldType member. |
| 150 | + searchableFields: Object.keys(TEXT_OPERATOR_DOOR_FIXTURE.fields).filter((f) => f !== 'id'), |
| 151 | + }, 'test'); |
| 152 | + }); |
| 153 | + |
| 154 | + it('the declared set over every FieldType: the emitted search is one the #15661 door passes', async () => { |
| 155 | + await engine.find(TEXT_OPERATOR_DOOR_FIXTURE_OBJECT, { search: 'zz', context: { isSystem: true } }); |
| 156 | + const where = lastWhere(TEXT_OPERATOR_DOOR_FIXTURE_OBJECT); |
| 157 | + // The door's own walk, over the expansion's output: no text operator aimed |
| 158 | + // at a column the door would refuse it on. |
| 159 | + expect(findTextOperatorOverNonTextField(TEXT_OPERATOR_DOOR_FIXTURE, where)).toBeNull(); |
| 160 | + const scanned = scannedFields(where); |
| 161 | + const fields = TEXT_OPERATOR_DOOR_FIXTURE.fields as Record<string, { type: string; returnType?: string }>; |
| 162 | + for (const f of scanned) expect(textOperatorDoorVerdict(fields[f]!), f).not.toBe('door-refusal'); |
| 163 | + // Control: the string-capable columns are still scanned (text, an option |
| 164 | + // code, a reference id, a multi-valued option field). |
| 165 | + for (const f of ['f_text', 'f_select', 'f_lookup', 'f_tags']) expect(scanned, f).toContain(f); |
| 166 | + }); |
| 167 | + |
| 168 | + it('a mixed declared set scans only the text column: number, date, boolean and json contribute no clause', async () => { |
| 169 | + const rows = await engine.find(OBJECT, { search: 'qq9' }); |
| 170 | + expect(scannedFields(lastWhere())).toEqual(['name']); |
| 171 | + expect(rows.map((r) => r.id)).toEqual(['r1', 'r2']); |
| 172 | + }); |
| 173 | + |
| 174 | + it('a term matching only a non-text value\'s text rendering matches nothing — 42, 2026, true', async () => { |
| 175 | + for (const term of ['42', '2026', 'true']) { |
| 176 | + expect(await engine.find(OBJECT, { search: term }), term).toEqual([]); |
| 177 | + expect(scannedFields(lastWhere()), term).toEqual(['name']); |
| 178 | + } |
| 179 | + }); |
| 180 | + |
| 181 | + it('multi-term: every term\'s $or carries only the text column', async () => { |
| 182 | + const rows = await engine.find(OBJECT, { search: 'qq9 alpha' }); |
| 183 | + expect(rows.map((r) => r.id)).toEqual(['r1']); |
| 184 | + expect(lastWhere()).toEqual({ $and: [ |
| 185 | + { $or: [{ name: { $icontains: 'qq9' } }] }, |
| 186 | + { $or: [{ name: { $icontains: 'alpha' } }] }, |
| 187 | + ] }); |
| 188 | + }); |
| 189 | + |
| 190 | + it('a set of only non-text columns answers NO rows — the driver is asked, nothing is refused, not every row', async () => { |
| 191 | + for (const term of ['qq9', '42', '2026']) { |
| 192 | + const before = reads.length; |
| 193 | + expect(await engine.find(ONLY_NON_TEXT, { search: term }), term).toEqual([]); |
| 194 | + expect(await engine.count(ONLY_NON_TEXT, { search: term }), term).toBe(0); |
| 195 | + expect(reads.length - before, 'the driver answered the read').toBe(2); |
| 196 | + expect(lastWhere(ONLY_NON_TEXT), term).toEqual({ $or: [] }); |
| 197 | + } |
| 198 | + // A blank term is still not a search: every row. |
| 199 | + expect(await engine.find(ONLY_NON_TEXT, { search: ' ' })).toHaveLength(3); |
| 200 | + }); |
| 201 | + |
| 202 | + it('an override naming a declared non-text column is honoured as that column: no rows, no refusal', async () => { |
| 203 | + expect(await engine.find(OBJECT, { search: '42', searchFields: ['qty'] })).toEqual([]); |
| 204 | + expect(lastWhere()).toEqual({ $or: [] }); |
| 205 | + // Control: naming the text column beside it still matches through it. |
| 206 | + const rows = await engine.find(OBJECT, { search: 'qq9', searchFields: ['qty', 'name'] }); |
| 207 | + expect(rows.map((r) => r.id)).toEqual(['r1', 'r2']); |
| 208 | + expect(scannedFields(lastWhere())).toEqual(['name']); |
| 209 | + }); |
| 210 | + |
| 211 | + it('control: an override outside the declared set is still refused, judged on the unchanged set', async () => { |
| 212 | + await expect(engine.find(OBJECT, { search: 'qq9', searchFields: ['nope'] })) |
| 213 | + .rejects.toMatchObject({ status: 400, code: 'INVALID_FIELD', param: 'searchFields', field: 'nope' }); |
| 214 | + }); |
| 215 | +}); |
0 commit comments