|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#27732] The in-process engine refuses a search-field override that names no |
| 5 | + * field, as the Data API doors (#27484) and the shared declarations (#27683) |
| 6 | + * do. |
| 7 | + * |
| 8 | + * `searchFields` and the object-form `search.fields` narrow which columns a |
| 9 | + * `search` scans; absent means "scan the object's default search fields". The |
| 10 | + * door refuses a value naming no field with `400 VALIDATION_ERROR` |
| 11 | + * (`assertSearchFieldsAreSearchable`, `@objectstack/metadata-protocol`), and |
| 12 | + * `QuerySchema.searchFields` / `FullTextSearchSchema.fields` refuse it too. The |
| 13 | + * engine never parses its options with those schemas, and `resolveSearchFields` |
| 14 | + * read an empty override as absent, so `find` / `findOne` / `count` / |
| 15 | + * `aggregate` scanned the whole default set: a caller who wrote the override |
| 16 | + * meant to narrow, and got the widest search instead, untold. |
| 17 | + * |
| 18 | + * Driven against a REAL {@link ObjectQL} engine over a driver double that |
| 19 | + * evaluates `$or` / `$icontains` and records each read, so "refused before any |
| 20 | + * driver read" is measured rather than assumed. `harbour` hits `name` on two |
| 21 | + * rows and `city` on a third, so a narrowing is observable. |
| 22 | + */ |
| 23 | + |
| 24 | +import { describe, it, expect, beforeEach } from 'vitest'; |
| 25 | +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; |
| 26 | +import { QuerySchema, FullTextSearchSchema } from '@objectstack/spec/data'; |
| 27 | +import type { EngineQueryOptions, EngineCountOptions, EngineAggregateOptions } from '@objectstack/spec/data'; |
| 28 | +import { mapDataError } from '@objectstack/types'; |
| 29 | +import { ObjectQL } from './engine.js'; |
| 30 | + |
| 31 | +const account = { |
| 32 | + name: 'acct_27732', |
| 33 | + label: 'Account', |
| 34 | + fields: { |
| 35 | + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, |
| 36 | + name: { name: 'name', label: 'Name', type: 'text' as const }, |
| 37 | + city: { name: 'city', label: 'City', type: 'text' as const }, |
| 38 | + region: { name: 'region', label: 'Region', type: 'text' as const }, |
| 39 | + }, |
| 40 | +}; |
| 41 | + |
| 42 | +const ROWS: ReadonlyArray<Record<string, unknown>> = [ |
| 43 | + { id: 'a1', name: 'Harbour Freight', city: 'Boston', region: 'east' }, |
| 44 | + { id: 'a2', name: 'Harbour Lights', city: 'Seattle', region: 'west' }, |
| 45 | + { id: 'a3', name: 'Northgate Mills', city: 'Harbourton', region: 'east' }, |
| 46 | + { id: 'a4', name: 'Riverside Co', city: 'Portland', region: 'west' }, |
| 47 | +]; |
| 48 | + |
| 49 | +/** The shared declarations' own refusal words, read off the published schemas. */ |
| 50 | +const declaredRefusal = (): string => { |
| 51 | + const parsed = (QuerySchema as any).shape.searchFields.safeParse([]); |
| 52 | + expect(parsed.success).toBe(false); |
| 53 | + const nested = FullTextSearchSchema.shape.fields.safeParse([]); |
| 54 | + expect(nested.success).toBe(false); |
| 55 | + expect(nested.error!.issues[0].message).toBe(parsed.error!.issues[0].message); |
| 56 | + return parsed.error!.issues[0].message; |
| 57 | +}; |
| 58 | + |
| 59 | +function matches(row: Record<string, unknown>, where: unknown): boolean { |
| 60 | + if (!where || typeof where !== 'object') return true; |
| 61 | + for (const [k, v] of Object.entries(where as Record<string, unknown>)) { |
| 62 | + if (k === '$and') { if (!(v as unknown[]).every((w) => matches(row, w))) return false; continue; } |
| 63 | + if (k === '$or') { if (!(v as unknown[]).some((w) => matches(row, w))) return false; continue; } |
| 64 | + if (k.startsWith('$')) throw new Error(`driver double does not implement the '${k}' combinator`); |
| 65 | + if (v && typeof v === 'object' && '$icontains' in (v as any)) { |
| 66 | + if (!String(row[k] ?? '').toLowerCase().includes(String((v as any).$icontains).toLowerCase())) return false; |
| 67 | + continue; |
| 68 | + } |
| 69 | + const expected = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v; |
| 70 | + if (row[k] !== expected) return false; |
| 71 | + } |
| 72 | + return true; |
| 73 | +} |
| 74 | + |
| 75 | +function makeDriver() { |
| 76 | + const reads: Array<{ via: string; where: unknown }> = []; |
| 77 | + const driver: any = { |
| 78 | + name: 'memory', |
| 79 | + version: '0.0.0', |
| 80 | + supports: {}, |
| 81 | + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, |
| 82 | + async find(_o: string, ast: any) { |
| 83 | + reads.push({ via: 'find', where: ast?.where }); |
| 84 | + const matched = ROWS.filter((r) => matches(r, ast?.where)); |
| 85 | + const page = typeof ast?.limit === 'number' ? matched.slice(0, ast.limit) : matched; |
| 86 | + return page.map((r) => ({ ...r })); |
| 87 | + }, |
| 88 | + async findOne(_o: string, ast: any) { |
| 89 | + reads.push({ via: 'findOne', where: ast?.where }); |
| 90 | + const hit = ROWS.find((r) => matches(r, ast?.where)); |
| 91 | + return hit ? { ...hit } : null; |
| 92 | + }, |
| 93 | + async count(_o: string, ast: any) { |
| 94 | + reads.push({ via: 'count', where: ast?.where }); |
| 95 | + return ROWS.filter((r) => matches(r, ast?.where)).length; |
| 96 | + }, |
| 97 | + async create(_o: string, d: any) { return d; }, |
| 98 | + async update(_o: string, _id: string, d: any) { return d; }, |
| 99 | + async delete() { return true; }, |
| 100 | + async bulkCreate(_o: string, r: any[]) { return r; }, |
| 101 | + async bulkUpdate() { return []; }, async bulkDelete() {}, |
| 102 | + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, |
| 103 | + async commit() {}, async rollback() {}, |
| 104 | + }; |
| 105 | + return { driver, reads }; |
| 106 | +} |
| 107 | + |
| 108 | +/** The refusal as the REST door would answer it: `mapDataError`'s status and code. */ |
| 109 | +async function wireAnswer(read: Promise<unknown>) { |
| 110 | + const err = await read.then( |
| 111 | + () => { throw new Error('expected a refusal, got an answer'); }, |
| 112 | + (e: unknown) => e, |
| 113 | + ); |
| 114 | + const mapped = mapDataError(err); |
| 115 | + return { status: mapped.status, code: mapped.body.code, message: (err as Error).message, param: (err as any).param }; |
| 116 | +} |
| 117 | + |
| 118 | +const COUNT = [{ function: 'count' as const, alias: 'n' }]; |
| 119 | + |
| 120 | +type Verb = 'find' | 'findOne' | 'count' | 'aggregate'; |
| 121 | +const VERBS: readonly Verb[] = ['find', 'findOne', 'count', 'aggregate']; |
| 122 | + |
| 123 | +/** |
| 124 | + * Run one verb with the search keys in `bag`. Deliberately off-contract: the |
| 125 | + * bags carry the comma-string and `null` spellings the engine-side declaration |
| 126 | + * does not admit, as the wire path hands them over. |
| 127 | + */ |
| 128 | +function run(engine: ObjectQL, verb: Verb, bag: Record<string, unknown>): Promise<unknown> { |
| 129 | + switch (verb) { |
| 130 | + case 'find': return engine.find(account.name, bag as unknown as EngineQueryOptions); |
| 131 | + case 'findOne': return engine.findOne(account.name, bag as unknown as EngineQueryOptions); |
| 132 | + case 'count': return engine.count(account.name, bag as unknown as EngineCountOptions); |
| 133 | + case 'aggregate': return engine.aggregate(account.name, { groupBy: ['region'], aggregations: COUNT, ...bag } as unknown as EngineAggregateOptions); |
| 134 | + } |
| 135 | +} |
| 136 | + |
| 137 | +/** The ids a verb's answer covers, so a narrowing is read off the result. */ |
| 138 | +async function answered(engine: ObjectQL, verb: Verb, bag: Record<string, unknown>): Promise<unknown> { |
| 139 | + const out: any = await run(engine, verb, bag); |
| 140 | + if (verb === 'find') return (out as any[]).map((r) => r.id).sort(); |
| 141 | + if (verb === 'findOne') return out?.id ?? null; |
| 142 | + if (verb === 'count') return out; |
| 143 | + return Object.fromEntries((out as any[]).map((r) => [r.region, r.n])); |
| 144 | +} |
| 145 | + |
| 146 | +/** What each verb answers for `harbour` over the DEFAULT set, and narrowed to `name`. */ |
| 147 | +const DEFAULT_ANSWER: Record<Verb, unknown> = { |
| 148 | + find: ['a1', 'a2', 'a3'], findOne: 'a1', count: 3, aggregate: { east: 2, west: 1 }, |
| 149 | +}; |
| 150 | +const NAME_ONLY_ANSWER: Record<Verb, unknown> = { |
| 151 | + find: ['a1', 'a2'], findOne: 'a1', count: 2, aggregate: { east: 1, west: 1 }, |
| 152 | +}; |
| 153 | + |
| 154 | +describe('[#27732] the in-process engine refuses a search-field override naming no field', () => { |
| 155 | + let engine: ObjectQL; |
| 156 | + let reads: Array<{ via: string; where: unknown }>; |
| 157 | + |
| 158 | + beforeEach(async () => { |
| 159 | + engine = new ObjectQL(); |
| 160 | + const made = makeDriver(); |
| 161 | + reads = made.reads; |
| 162 | + engine.registerDriver(made.driver, true); |
| 163 | + await engine.init(); |
| 164 | + engine.registry.registerObject(account, 'test-package'); |
| 165 | + }); |
| 166 | + |
| 167 | + describe.each(VERBS)('%s', (verb) => { |
| 168 | + it('searchFields: [] is refused 400 VALIDATION_ERROR in the declarations\' words, before any driver read', async () => { |
| 169 | + const answer = await wireAnswer(run(engine, verb, { search: 'harbour', searchFields: [] })); |
| 170 | + expect(answer).toMatchObject({ status: 400, code: 'VALIDATION_ERROR', param: 'searchFields' }); |
| 171 | + expect(answer.message).toContain(declaredRefusal()); |
| 172 | + expect(reads).toEqual([]); |
| 173 | + }); |
| 174 | + |
| 175 | + it('search: { query, fields: [] } is refused the same way (item 1, the search.fields coupling)', async () => { |
| 176 | + const answer = await wireAnswer(run(engine, verb, { search: { query: 'harbour', fields: [] } })); |
| 177 | + expect(answer).toMatchObject({ status: 400, code: 'VALIDATION_ERROR', param: 'search.fields' }); |
| 178 | + expect(answer.message).toContain(declaredRefusal()); |
| 179 | + expect(reads).toEqual([]); |
| 180 | + }); |
| 181 | + |
| 182 | + it('a comma string naming no field (the GET transport form) is refused the same way', async () => { |
| 183 | + const answer = await wireAnswer(run(engine, verb, { search: 'harbour', searchFields: ' , ' })); |
| 184 | + expect(answer).toMatchObject({ status: 400, code: 'VALIDATION_ERROR', param: 'searchFields' }); |
| 185 | + expect(reads).toEqual([]); |
| 186 | + }); |
| 187 | + |
| 188 | + it('CONTROL: a named override still narrows', async () => { |
| 189 | + expect(await answered(engine, verb, { search: 'harbour', searchFields: ['name'] })).toEqual(NAME_ONLY_ANSWER[verb]); |
| 190 | + expect(await answered(engine, verb, { search: { query: 'harbour', fields: ['name'] } })).toEqual(NAME_ONLY_ANSWER[verb]); |
| 191 | + }); |
| 192 | + |
| 193 | + it('CONTROL: an absent override still searches the default set', async () => { |
| 194 | + expect(await answered(engine, verb, { search: 'harbour' })).toEqual(DEFAULT_ANSWER[verb]); |
| 195 | + expect(await answered(engine, verb, { search: { query: 'harbour' } })).toEqual(DEFAULT_ANSWER[verb]); |
| 196 | + }); |
| 197 | + }); |
| 198 | + |
| 199 | + it('an empty override is refused whether or not a search term rode along, as the door judges it', async () => { |
| 200 | + const answer = await wireAnswer(engine.find(account.name, { searchFields: [] })); |
| 201 | + expect(answer).toMatchObject({ status: 400, code: 'VALIDATION_ERROR', param: 'searchFields' }); |
| 202 | + expect(reads).toEqual([]); |
| 203 | + }); |
| 204 | + |
| 205 | + it('a system-context read is refused too: the rule is about the shape, not the caller', async () => { |
| 206 | + const answer = await wireAnswer(engine.find(account.name, { search: 'harbour', searchFields: [], context: { isSystem: true } })); |
| 207 | + expect(answer).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); |
| 208 | + expect(reads).toEqual([]); |
| 209 | + }); |
| 210 | + |
| 211 | + it('the REST door answers the same status and code for the same override', async () => { |
| 212 | + const protocol = new ObjectStackProtocolImplementation(engine); |
| 213 | + const door = await wireAnswer(protocol.findData({ object: account.name, query: { search: 'harbour', searchFields: '' } as any })); |
| 214 | + const inProcess = await wireAnswer(engine.find(account.name, { search: 'harbour', searchFields: [] })); |
| 215 | + expect({ status: inProcess.status, code: inProcess.code }).toEqual({ status: door.status, code: door.code }); |
| 216 | + expect(door).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); |
| 217 | + expect(reads).toEqual([]); |
| 218 | + }); |
| 219 | + |
| 220 | + it('CONTROL: a null override is a withdrawal and searches the default set', async () => { |
| 221 | + expect(await answered(engine, 'find', { search: 'harbour', searchFields: null })).toEqual(DEFAULT_ANSWER.find); |
| 222 | + }); |
| 223 | +}); |
0 commit comments