|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#24284] A non-blank `search` over an object whose RESOLVED search set is |
| 5 | + * empty is refused — `400 INVALID_FIELD`, on every verb that reaches |
| 6 | + * `expandSearchOnAst` — instead of answering every row. |
| 7 | + * |
| 8 | + * The shape is the junction object: two lookups and a composed formula title. |
| 9 | + * The auto-default scans textual and enum types only, so it resolves no field, |
| 10 | + * `expandSearchToFilter` answers `null`, and before this refusal the term was |
| 11 | + * dropped with `where` untouched: `search: 'zzzz-nothing'` and `search: 'Dune'` |
| 12 | + * returned the same five rows, read by a user as "everything matched". |
| 13 | + * |
| 14 | + * What stays as it was, pinned beside the refusal so a fix that over-reaches |
| 15 | + * goes red here too: a blank term is not a search; a control object with a |
| 16 | + * stored text field still filters; an explicit override intersected to nothing |
| 17 | + * on an object WITH a searchable set falls back to that set (filtered, never |
| 18 | + * every row). |
| 19 | + */ |
| 20 | + |
| 21 | +import { describe, it, expect, beforeEach } from 'vitest'; |
| 22 | +import type { EngineQueryOptions } from '@objectstack/spec/data'; |
| 23 | +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; |
| 24 | +import { ObjectQL } from './engine.js'; |
| 25 | + |
| 26 | +type Search = EngineQueryOptions['search']; |
| 27 | + |
| 28 | +type Row = Record<string, unknown>; |
| 29 | + |
| 30 | +const LOANS: Row[] = [ |
| 31 | + { id: 'l1', book: 'b1', reader: 'r1' }, |
| 32 | + { id: 'l2', book: 'b2', reader: 'r1' }, |
| 33 | + { id: 'l3', book: 'b3', reader: 'r1' }, |
| 34 | + { id: 'l4', book: 'b1', reader: 'r1' }, |
| 35 | + { id: 'l5', book: 'b2', reader: 'r1' }, |
| 36 | +]; |
| 37 | +const BOOKS: Row[] = [ |
| 38 | + { id: 'b1', title: 'Dune' }, |
| 39 | + { id: 'b2', title: 'Emma' }, |
| 40 | + { id: 'b3', title: 'Ulysses' }, |
| 41 | +]; |
| 42 | + |
| 43 | +function matches(row: Row, where: unknown): boolean { |
| 44 | + if (!where || typeof where !== 'object') return true; |
| 45 | + for (const [k, v] of Object.entries(where as Row)) { |
| 46 | + if (k === '$and') { if (!(v as unknown[]).every((w) => matches(row, w))) return false; continue; } |
| 47 | + if (k === '$or') { if (!(v as unknown[]).some((w) => matches(row, w))) return false; continue; } |
| 48 | + const op = v as Row; |
| 49 | + if (op && typeof op === 'object' && '$icontains' in op) { |
| 50 | + if (!String(row[k] ?? '').toLowerCase().includes(String(op.$icontains).toLowerCase())) return false; |
| 51 | + continue; |
| 52 | + } |
| 53 | + if (row[k] !== v) return false; |
| 54 | + } |
| 55 | + return true; |
| 56 | +} |
| 57 | + |
| 58 | +function makeDriver() { |
| 59 | + const reads: Array<{ object: string; via: string }> = []; |
| 60 | + const tables: Record<string, Row[]> = { lib_loan: LOANS, lib_book: BOOKS, lib_token: [{ id: 't1', digest: 'abc' }] }; |
| 61 | + const read = (object: string, ast: any) => { |
| 62 | + const rows = (tables[object] ?? []).filter((r) => matches(r, ast?.where)); |
| 63 | + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; |
| 64 | + }; |
| 65 | + const driver: any = { |
| 66 | + name: 'mem', version: '0.0.0', supports: {}, |
| 67 | + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, |
| 68 | + async find(object: string, ast: any) { reads.push({ object, via: 'find' }); return read(object, ast).map((r) => ({ ...r })); }, |
| 69 | + async findOne(object: string, ast: any) { reads.push({ object, via: 'findOne' }); return read(object, ast)[0] ?? null; }, |
| 70 | + async count(object: string, ast: any) { reads.push({ object, via: 'count' }); return read(object, ast).length; }, |
| 71 | + async create(_o: string, d: any) { return d; }, |
| 72 | + async update(_o: string, _id: string, d: any) { return d; }, |
| 73 | + async delete() { return true; }, |
| 74 | + async bulkCreate(_o: string, r: any[]) { return r; }, |
| 75 | + async bulkUpdate() { return []; }, async bulkDelete() {}, |
| 76 | + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, |
| 77 | + async commit() {}, async rollback() {}, |
| 78 | + }; |
| 79 | + return { driver, reads }; |
| 80 | +} |
| 81 | + |
| 82 | +const REFUSAL = { |
| 83 | + code: 'INVALID_FIELD', |
| 84 | + status: 400, |
| 85 | + object: 'lib_loan', |
| 86 | + param: 'search', |
| 87 | +}; |
| 88 | + |
| 89 | +describe('[#24284] a search over an empty resolved search set is refused', () => { |
| 90 | + let engine: ObjectQL; |
| 91 | + let reads: ReturnType<typeof makeDriver>['reads']; |
| 92 | + |
| 93 | + beforeEach(async () => { |
| 94 | + const made = makeDriver(); |
| 95 | + reads = made.reads; |
| 96 | + engine = new ObjectQL(); |
| 97 | + engine.registerDriver(made.driver, true); |
| 98 | + await engine.init(); |
| 99 | + const registry = engine.registry as any; |
| 100 | + registry.registerObject({ name: 'lib_book', fields: { title: { type: 'text' } } }, 'test'); |
| 101 | + registry.registerObject({ |
| 102 | + name: 'lib_loan', |
| 103 | + nameField: 'title', |
| 104 | + fields: { |
| 105 | + book: { type: 'lookup', reference: 'lib_book' }, |
| 106 | + reader: { type: 'lookup', reference: 'lib_reader' }, |
| 107 | + title: { type: 'formula', expression: "record.book.title + ' · ' + record.reader.name" }, |
| 108 | + }, |
| 109 | + }, 'test'); |
| 110 | + // A textual column that is `internal: true` — excluded from the scan, |
| 111 | + // so the resolved set is empty for the same reason by another road. |
| 112 | + registry.registerObject({ |
| 113 | + name: 'lib_token', |
| 114 | + fields: { digest: { type: 'text', internal: true } }, |
| 115 | + }, 'test'); |
| 116 | + }); |
| 117 | + |
| 118 | + const verbs: Array<[string, (e: ObjectQL, search: Search) => Promise<unknown>]> = [ |
| 119 | + ['find', (e, search) => e.find('lib_loan', { search })], |
| 120 | + ['findOne', (e, search) => e.findOne('lib_loan', { search })], |
| 121 | + ['count', (e, search) => e.count('lib_loan', { search })], |
| 122 | + ['aggregate', (e, search) => e.aggregate('lib_loan', { |
| 123 | + search, groupBy: ['book'], aggregations: [{ function: 'count', alias: 'n' }], |
| 124 | + })], |
| 125 | + ]; |
| 126 | + |
| 127 | + it.each(verbs)('%s answers 400 INVALID_FIELD naming the object and both remedies — and reads nothing', async (_verb, run) => { |
| 128 | + for (const term of ['zzzz-nothing', 'Dune', { query: 'Dune' }] as Search[]) { |
| 129 | + const err: any = await run(engine, term).then(() => null, (e) => e); |
| 130 | + expect(err, `term ${JSON.stringify(term)} was answered`).not.toBeNull(); |
| 131 | + expect(err).toMatchObject(REFUSAL); |
| 132 | + expect(String(err.message)).toMatch(/^Object 'lib_loan' has no searchable field, so 'search' cannot be honoured on it/); |
| 133 | + expect(String(err.message)).toContain("it declares no 'searchableFields'"); |
| 134 | + expect(String(err.message)).toContain("Declare 'searchableFields' on 'lib_loan' naming a stored field"); |
| 135 | + expect(String(err.message)).toContain('mirror the composed title onto a stored text field'); |
| 136 | + } |
| 137 | + expect(reads.filter((r) => r.object === 'lib_loan')).toEqual([]); |
| 138 | + }); |
| 139 | + |
| 140 | + it('an explicit searchFields narrowing does not rescue it — the allowed set is empty, so the fallback is too', async () => { |
| 141 | + for (const searchFields of [['title'], ['book'], ['nope'], []]) { |
| 142 | + const err: any = await engine.find('lib_loan', { search: 'Dune', searchFields }).then(() => null, (e) => e); |
| 143 | + expect(err, `searchFields ${JSON.stringify(searchFields)}`).toMatchObject(REFUSAL); |
| 144 | + } |
| 145 | + }); |
| 146 | + |
| 147 | + it('a declaration naming only unscannable entries names them in the refusal', async () => { |
| 148 | + (engine.registry as any).registerObject({ |
| 149 | + name: 'lib_loan', |
| 150 | + searchableFields: ['title', 'gone'], |
| 151 | + fields: { |
| 152 | + book: { type: 'lookup', reference: 'lib_book' }, |
| 153 | + title: { type: 'formula', expression: 'record.book.title' }, |
| 154 | + }, |
| 155 | + }, 'test'); |
| 156 | + const err: any = await engine.find('lib_loan', { search: 'Dune' }).then(() => null, (e) => e); |
| 157 | + expect(err).toMatchObject(REFUSAL); |
| 158 | + expect(String(err.message)).toContain("its 'searchableFields' (title, gone) names no stored field"); |
| 159 | + }); |
| 160 | + |
| 161 | + it('an object whose only text column is internal: true is refused the same way, saying why', async () => { |
| 162 | + const err: any = await engine.find('lib_token', { search: 'abc' }).then(() => null, (e) => e); |
| 163 | + expect(err).toMatchObject({ ...REFUSAL, object: 'lib_token' }); |
| 164 | + expect(String(err.message)).toContain("('internal: true' fields are never searched)"); |
| 165 | + expect(reads.filter((r) => r.object === 'lib_token')).toEqual([]); |
| 166 | + }); |
| 167 | + |
| 168 | + it('a blank or whitespace term is not a search: every row, where untouched, on every verb', async () => { |
| 169 | + for (const search of ['', ' ', { query: ' ' }] as Search[]) { |
| 170 | + expect(await engine.find('lib_loan', { search })).toHaveLength(5); |
| 171 | + expect(await engine.count('lib_loan', { search })).toBe(5); |
| 172 | + expect(await engine.find('lib_loan', { search, where: { book: 'b1' } })).toHaveLength(2); |
| 173 | + } |
| 174 | + }); |
| 175 | + |
| 176 | + it('control: an object with a stored text field still filters, and an override intersected to nothing falls back to its set', async () => { |
| 177 | + expect((await engine.find('lib_book', { search: 'Dune' })).map((r) => r.id)).toEqual(['b1']); |
| 178 | + expect(await engine.find('lib_book', { search: 'zzzz-nothing' })).toEqual([]); |
| 179 | + expect(await engine.count('lib_book', { search: 'zzzz-nothing' })).toBe(0); |
| 180 | + // A direct engine caller naming only fields outside the allowed set: the |
| 181 | + // tolerant intersection falls back to the allowed set — a FILTERED |
| 182 | + // answer, never every row. |
| 183 | + expect((await engine.find('lib_book', { search: 'emma', searchFields: ['nope'] })).map((r) => r.id)).toEqual(['b2']); |
| 184 | + }); |
| 185 | +}); |
| 186 | + |
| 187 | +/** |
| 188 | + * The internal producers and the data door, over a real engine: `searchAll` |
| 189 | + * (the ⌘K palette) hands `search` to objects it did not choose, so it must |
| 190 | + * keep skipping an object with nothing to scan BEFORE it queries — a refusal |
| 191 | + * there would fail the whole sweep. `findData` (the REST list / query door) |
| 192 | + * surfaces the refusal as the read's own answer. |
| 193 | + */ |
| 194 | +describe('[#24284] the palette keeps its skip; the data door surfaces the refusal', () => { |
| 195 | + async function makeHarness() { |
| 196 | + const made = makeDriver(); |
| 197 | + const engine = new ObjectQL(); |
| 198 | + engine.registerDriver(made.driver, true); |
| 199 | + await engine.init(); |
| 200 | + const registry = engine.registry as any; |
| 201 | + registry.registerObject({ name: 'lib_book', fields: { title: { type: 'text' } } }, 'test'); |
| 202 | + registry.registerObject({ |
| 203 | + name: 'lib_loan', |
| 204 | + nameField: 'title', |
| 205 | + fields: { |
| 206 | + book: { type: 'lookup', reference: 'lib_book' }, |
| 207 | + reader: { type: 'lookup', reference: 'lib_reader' }, |
| 208 | + title: { type: 'formula', expression: "record.book.title + ' · ' + record.reader.name" }, |
| 209 | + }, |
| 210 | + }, 'test'); |
| 211 | + registry.registerObject({ name: 'lib_token', fields: { digest: { type: 'text', internal: true } } }, 'test'); |
| 212 | + const protocol = new ObjectStackProtocolImplementation(engine as never); |
| 213 | + return { protocol, reads: made.reads }; |
| 214 | + } |
| 215 | + |
| 216 | + it('searchAll answers the other objects\' hits, and leaves the junction and the internal-only object out of objectsScanned', async () => { |
| 217 | + const { protocol, reads } = await makeHarness(); |
| 218 | + const objects = ['lib_book', 'lib_loan', 'lib_token']; |
| 219 | + const res: any = await protocol.searchAll({ q: 'Dune', objects, perObject: 5, limit: 25 }); |
| 220 | + expect(res.hits.map((h: any) => [h.object, h.id])).toEqual([['lib_book', 'b1']]); |
| 221 | + expect(res.totalObjects).toBe(1); |
| 222 | + expect(reads.filter((r) => r.object !== 'lib_book')).toEqual([]); |
| 223 | + const none: any = await protocol.searchAll({ q: 'Dune', objects: ['lib_loan', 'lib_token'], perObject: 5, limit: 25 }); |
| 224 | + expect(none).toMatchObject({ hits: [], totalObjects: 0, totalHits: 0 }); |
| 225 | + }); |
| 226 | + |
| 227 | + it('findData over the junction answers the refusal, on the list and the query shapes', async () => { |
| 228 | + const { protocol, reads } = await makeHarness(); |
| 229 | + for (const query of [{ search: 'zzzz-nothing' }, { search: 'Dune', searchFields: '' }, { search: { query: 'Dune' } }]) { |
| 230 | + const err: any = await (protocol as any).findData({ object: 'lib_loan', query }).then(() => null, (e: any) => e); |
| 231 | + expect(err, JSON.stringify(query)).toMatchObject(REFUSAL); |
| 232 | + } |
| 233 | + expect(reads.filter((r) => r.object === 'lib_loan')).toEqual([]); |
| 234 | + // …and the control object and the blank term are untouched. |
| 235 | + const book: any = await (protocol as any).findData({ object: 'lib_book', query: { search: 'Dune' } }); |
| 236 | + expect(book.records.map((r: any) => r.id)).toEqual(['b1']); |
| 237 | + const blank: any = await (protocol as any).findData({ object: 'lib_loan', query: { search: '' } }); |
| 238 | + expect(blank.records).toHaveLength(5); |
| 239 | + }); |
| 240 | +}); |
0 commit comments