|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#20157] `ObjectQL.judgeFilter`, the engine's judge-only filter admission |
| 5 | + * (`IObjectQLEngine.judgeFilter`, #19995 ruling C). |
| 6 | + * |
| 7 | + * What each block pins: |
| 8 | + * |
| 9 | + * - **Per class.** For each class #19995 lists (a text operator over a |
| 10 | + * non-text field, an uninterpretable temporal comparand, an unknown filter |
| 11 | + * placeholder, a filter on a virtual field, a dotted path through a lookup), |
| 12 | + * the judge returns the door's diagnostic, AND execution raises the same one. |
| 13 | + * Same `code`, `status` and `message`, on every verb that takes a `where`, |
| 14 | + * with `operation` naming the verb. |
| 15 | + * - **Ok.** A runnable filter returns `{ ok: true }`. |
| 16 | + * - **Nothing executes.** A driver spy sees no call, no driver is resolved, |
| 17 | + * and no hook or middleware runs. Each has a positive control: execution |
| 18 | + * drives the same spy. |
| 19 | + * - **Order.** A filter with two defects gets the diagnostic execution gives. |
| 20 | + * The door stage runs before the placeholder stage, and the doors inside it |
| 21 | + * keep their order. |
| 22 | + * - **Placeholders.** They resolve against the supplied context. One the |
| 23 | + * context cannot answer is refused, never resolved to `null`. |
| 24 | + * |
| 25 | + * Every refusal assertion reads `code` and `status` (the ADR-0112 envelope), |
| 26 | + * never a bare `toThrow()`. |
| 27 | + */ |
| 28 | + |
| 29 | +import { describe, it, expect, beforeEach, vi } from 'vitest'; |
| 30 | +import type { EngineFilterJudgement, EngineFilterJudgementOptions } from '@objectstack/spec/contracts'; |
| 31 | +import type { EngineQueryOptions } from '@objectstack/spec/data'; |
| 32 | +import type { ExecutionContext } from '@objectstack/spec/kernel'; |
| 33 | +import { ObjectQL } from './engine.js'; |
| 34 | + |
| 35 | +/** The `where` type every verb (and the judge) accepts. */ |
| 36 | +type Where = EngineQueryOptions['where']; |
| 37 | + |
| 38 | +const DEAL = 'judge_deal'; |
| 39 | +const ACCOUNT = 'judge_account'; |
| 40 | + |
| 41 | +const ACCOUNT_SCHEMA = { |
| 42 | + name: ACCOUNT, |
| 43 | + label: 'Judge account', |
| 44 | + fields: { |
| 45 | + id: { name: 'id', type: 'text' }, |
| 46 | + name: { name: 'name', type: 'text' }, |
| 47 | + }, |
| 48 | +}; |
| 49 | + |
| 50 | +const DEAL_SCHEMA = { |
| 51 | + name: DEAL, |
| 52 | + label: 'Judge deal', |
| 53 | + fields: { |
| 54 | + id: { name: 'id', type: 'text' }, |
| 55 | + title: { name: 'title', type: 'text' }, |
| 56 | + owner_id: { name: 'owner_id', type: 'text' }, |
| 57 | + amount: { name: 'amount', type: 'number' }, |
| 58 | + closes_on: { name: 'closes_on', type: 'date' }, |
| 59 | + // A virtual field: no driver materialises a column for it. |
| 60 | + is_open: { name: 'is_open', type: 'formula', expression: '1', returnType: 'boolean' }, |
| 61 | + account: { name: 'account', type: 'lookup', reference: ACCOUNT }, |
| 62 | + }, |
| 63 | +}; |
| 64 | + |
| 65 | +type Verb = NonNullable<EngineFilterJudgementOptions['operation']>; |
| 66 | +const VERBS: readonly Verb[] = ['find', 'findOne', 'count', 'aggregate', 'update', 'delete']; |
| 67 | + |
| 68 | +/** Run `where` through one verb's EXECUTION on `object`. */ |
| 69 | +function execute(engine: ObjectQL, verb: Verb, object: string, where: Where, context?: ExecutionContext) { |
| 70 | + switch (verb) { |
| 71 | + case 'find': return engine.find(object, { where }, { context }); |
| 72 | + case 'findOne': return engine.findOne(object, { where }, { context }); |
| 73 | + case 'count': return engine.count(object, { where }, { context }); |
| 74 | + case 'aggregate': |
| 75 | + return engine.aggregate(object, { where, aggregations: [{ function: 'count', alias: 'n' }] }, { context }); |
| 76 | + case 'update': return engine.update(object, { title: 'x' }, { where, multi: true, context }); |
| 77 | + case 'delete': return engine.delete(object, { where, multi: true, context }); |
| 78 | + } |
| 79 | +} |
| 80 | + |
| 81 | +type Thrown = Error & { code?: string; status?: number }; |
| 82 | + |
| 83 | +async function refusalOf(p: Promise<unknown>): Promise<Thrown | null> { |
| 84 | + return p.then(() => null, (e: unknown) => e as Thrown); |
| 85 | +} |
| 86 | + |
| 87 | +/** A recording driver: every method call lands in `calls`, by method name. */ |
| 88 | +function makeRecordingDriver() { |
| 89 | + const calls: string[] = []; |
| 90 | + const reads: Array<{ method: string; ast: any }> = []; |
| 91 | + const record = <A extends unknown[], R>(method: string, impl: (...args: A) => R) => |
| 92 | + (...args: A): R => { calls.push(method); return impl(...args); }; |
| 93 | + const driver: any = { |
| 94 | + name: 'judge-recording', version: '0.0.0', supports: {}, |
| 95 | + connect: record('connect', async () => {}), |
| 96 | + disconnect: record('disconnect', async () => {}), |
| 97 | + checkHealth: record('checkHealth', async () => true), |
| 98 | + execute: record('execute', async () => null), |
| 99 | + find: record('find', async (_o: string, ast: any) => { reads.push({ method: 'find', ast }); return []; }), |
| 100 | + findOne: record('findOne', async (_o: string, ast: any) => { reads.push({ method: 'findOne', ast }); return null; }), |
| 101 | + count: record('count', async (_o: string, ast: any) => { reads.push({ method: 'count', ast }); return 0; }), |
| 102 | + aggregate: record('aggregate', async (_o: string, ast: any) => { reads.push({ method: 'aggregate', ast }); return []; }), |
| 103 | + create: record('create', async (_o: string, d: any) => d), |
| 104 | + update: record('update', async (_o: string, id: any, d: any) => ({ id, ...d })), |
| 105 | + updateMany: record('updateMany', async () => 0), |
| 106 | + delete: record('delete', async () => true), |
| 107 | + deleteMany: record('deleteMany', async () => 0), |
| 108 | + bulkCreate: record('bulkCreate', async (_o: string, rows: any[]) => rows), |
| 109 | + beginTransaction: record('beginTransaction', async () => ({})), |
| 110 | + commit: record('commit', async () => {}), |
| 111 | + rollback: record('rollback', async () => {}), |
| 112 | + }; |
| 113 | + return { driver, calls, reads }; |
| 114 | +} |
| 115 | + |
| 116 | +/** |
| 117 | + * The five classes the ruling names, each with the envelope its door raises. |
| 118 | + * `where` is a factory so no case can edit a filter another case judges. |
| 119 | + */ |
| 120 | +const CLASSES: ReadonlyArray<{ name: string; where: () => Where; code: string; status: number; mentions: string }> = [ |
| 121 | + { |
| 122 | + name: 'a text operator over a non-text field', |
| 123 | + where: () => ({ amount: { $contains: '5' } }), |
| 124 | + code: 'INVALID_FILTER', status: 400, mentions: "'amount'", |
| 125 | + }, |
| 126 | + { |
| 127 | + name: 'an uninterpretable temporal comparand', |
| 128 | + where: () => ({ closes_on: { $gt: 'not-a-date' } }), |
| 129 | + code: 'INVALID_FILTER', status: 400, mentions: "'closes_on'", |
| 130 | + }, |
| 131 | + { |
| 132 | + name: 'an unknown filter placeholder', |
| 133 | + where: () => ({ owner_id: '{bogus_token}' }), |
| 134 | + code: 'FILTER_TOKEN_UNKNOWN', status: 400, mentions: '{bogus_token}', |
| 135 | + }, |
| 136 | + { |
| 137 | + name: 'a filter on a virtual field', |
| 138 | + where: () => ({ is_open: true }), |
| 139 | + code: 'INVALID_FIELD', status: 400, mentions: "'is_open'", |
| 140 | + }, |
| 141 | + { |
| 142 | + name: 'a dotted path through a lookup', |
| 143 | + where: () => ({ 'account.name': 'Acme' }), |
| 144 | + code: 'INVALID_FIELD', status: 400, mentions: "'account.name'", |
| 145 | + }, |
| 146 | +]; |
| 147 | + |
| 148 | +function refused(verdict: EngineFilterJudgement): Extract<EngineFilterJudgement, { ok: false }> { |
| 149 | + if (verdict.ok) throw new Error(`expected a refusal, the judge answered ok`); |
| 150 | + return verdict; |
| 151 | +} |
| 152 | + |
| 153 | +describe('[#20157] ObjectQL.judgeFilter: judge a where without executing it', () => { |
| 154 | + let engine: ObjectQL; |
| 155 | + let calls: string[]; |
| 156 | + let reads: Array<{ method: string; ast: any }>; |
| 157 | + |
| 158 | + beforeEach(async () => { |
| 159 | + const rec = makeRecordingDriver(); |
| 160 | + calls = rec.calls; |
| 161 | + reads = rec.reads; |
| 162 | + engine = new ObjectQL(); |
| 163 | + engine.registerDriver(rec.driver, true); |
| 164 | + await engine.init(); |
| 165 | + engine.registry.registerObject(ACCOUNT_SCHEMA as any, 'test'); |
| 166 | + engine.registry.registerObject(DEAL_SCHEMA as any, 'test'); |
| 167 | + // Boot traffic (connect, schema sync) is not the judge's; start clean. |
| 168 | + calls.length = 0; |
| 169 | + reads.length = 0; |
| 170 | + }); |
| 171 | + |
| 172 | + describe('per class: the judge returns the diagnostic execution raises', () => { |
| 173 | + for (const c of CLASSES) { |
| 174 | + for (const verb of VERBS) { |
| 175 | + it(`${c.name}, on ${verb}`, async () => { |
| 176 | + const verdict = refused(engine.judgeFilter(DEAL, c.where(), { operation: verb })); |
| 177 | + expect(calls).toEqual([]); |
| 178 | + |
| 179 | + const thrown = await refusalOf(execute(engine, verb, DEAL, c.where())); |
| 180 | + expect(thrown).not.toBeNull(); |
| 181 | + // The envelope, on both sides. |
| 182 | + expect({ code: thrown!.code, status: thrown!.status }).toEqual({ code: c.code, status: c.status }); |
| 183 | + expect({ code: verdict.code, status: verdict.status }).toEqual({ code: c.code, status: c.status }); |
| 184 | + // The same diagnostic, byte for byte, including the verb prefix. |
| 185 | + expect(verdict.message).toBe(thrown!.message); |
| 186 | + expect(verdict.message).toContain(c.mentions); |
| 187 | + }); |
| 188 | + } |
| 189 | + } |
| 190 | + |
| 191 | + it('the default operation is find', () => { |
| 192 | + const bare = refused(engine.judgeFilter(DEAL, { amount: { $contains: '5' } })); |
| 193 | + const asFind = refused(engine.judgeFilter(DEAL, { amount: { $contains: '5' } }, { operation: 'find' })); |
| 194 | + expect(bare).toEqual(asFind); |
| 195 | + expect(bare.message.startsWith(`find('${DEAL}')`)).toBe(true); |
| 196 | + }); |
| 197 | + |
| 198 | + it('the filter-array sugar is judged through the same lowering execution runs', async () => { |
| 199 | + // Input-only sugar: off the `where` type on purpose, as a caller holding |
| 200 | + // a `FilterArray` passes it. |
| 201 | + const where = [['amount', 'contains', '5']] as unknown as Where; |
| 202 | + const verdict = refused(engine.judgeFilter(DEAL, where)); |
| 203 | + const thrown = await refusalOf(engine.find(DEAL, { where })); |
| 204 | + expect({ code: verdict.code, status: verdict.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); |
| 205 | + expect({ code: thrown!.code, status: thrown!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); |
| 206 | + expect(verdict.message).toBe(thrown!.message); |
| 207 | + }); |
| 208 | + |
| 209 | + it('a where that is not a filter object gets the shape gate\'s diagnostic', async () => { |
| 210 | + // Off-contract on purpose: the shape gate is what refuses it. |
| 211 | + const where = 'status = open' as unknown as Where; |
| 212 | + const verdict = refused(engine.judgeFilter(DEAL, where)); |
| 213 | + const thrown = await refusalOf(engine.find(DEAL, { where })); |
| 214 | + expect({ code: verdict.code, status: verdict.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); |
| 215 | + expect(verdict.message).toBe(thrown!.message); |
| 216 | + }); |
| 217 | + }); |
| 218 | + |
| 219 | + describe('a runnable filter', () => { |
| 220 | + it('returns ok, and execution admits it and reaches the driver', async () => { |
| 221 | + const where = { title: 'Acme', amount: { $gt: 5 }, closes_on: { $gte: '{30_days_ago}' } }; |
| 222 | + expect(engine.judgeFilter(DEAL, where)).toEqual({ ok: true }); |
| 223 | + expect(calls).toEqual([]); |
| 224 | + |
| 225 | + // Positive control: the same filter executes, so the spy is live. |
| 226 | + await engine.find(DEAL, { where }); |
| 227 | + expect(calls).toContain('find'); |
| 228 | + }); |
| 229 | + |
| 230 | + it('an absent, null or empty where is ok', () => { |
| 231 | + expect(engine.judgeFilter(DEAL, undefined)).toEqual({ ok: true }); |
| 232 | + expect(engine.judgeFilter(DEAL, null as unknown as Where)).toEqual({ ok: true }); |
| 233 | + expect(engine.judgeFilter(DEAL, {})).toEqual({ ok: true }); |
| 234 | + expect(engine.judgeFilter(DEAL, [] as unknown as Where)).toEqual({ ok: true }); |
| 235 | + }); |
| 236 | + |
| 237 | + it('leaves the caller\'s filter untouched', () => { |
| 238 | + const where = { owner_id: '{current_user_id}', closes_on: { $gte: '{30_days_ago}' } }; |
| 239 | + const before = JSON.stringify(where); |
| 240 | + expect(engine.judgeFilter(DEAL, where, { context: { userId: 'u_1' } })).toEqual({ ok: true }); |
| 241 | + expect(JSON.stringify(where)).toBe(before); |
| 242 | + }); |
| 243 | + }); |
| 244 | + |
| 245 | + describe('nothing executes', () => { |
| 246 | + it('no driver method is called and no driver is resolved, for a refusal or an ok', async () => { |
| 247 | + const getDriver = vi.spyOn(engine as any, 'getDriver'); |
| 248 | + for (const c of CLASSES) engine.judgeFilter(DEAL, c.where()); |
| 249 | + engine.judgeFilter(DEAL, { title: 'Acme' }); |
| 250 | + expect(calls).toEqual([]); |
| 251 | + expect(getDriver).not.toHaveBeenCalled(); |
| 252 | + |
| 253 | + // Positive control: execution resolves the driver through the same spy. |
| 254 | + await engine.find(DEAL, { where: { title: 'Acme' } }); |
| 255 | + expect(getDriver).toHaveBeenCalled(); |
| 256 | + }); |
| 257 | + |
| 258 | + it('no hook and no middleware runs', async () => { |
| 259 | + const hook = vi.fn(); |
| 260 | + const middleware = vi.fn(async (_opCtx: unknown, next: () => Promise<void>) => { await next(); }); |
| 261 | + engine.registerHook('beforeFind', hook, { object: DEAL }); |
| 262 | + engine.registerMiddleware(middleware); |
| 263 | + |
| 264 | + engine.judgeFilter(DEAL, { title: 'Acme' }); |
| 265 | + engine.judgeFilter(DEAL, { is_open: true }); |
| 266 | + expect(hook).not.toHaveBeenCalled(); |
| 267 | + expect(middleware).not.toHaveBeenCalled(); |
| 268 | + |
| 269 | + // Positive control: execution runs both. |
| 270 | + await engine.find(DEAL, { where: { title: 'Acme' } }); |
| 271 | + expect(hook).toHaveBeenCalled(); |
| 272 | + expect(middleware).toHaveBeenCalled(); |
| 273 | + }); |
| 274 | + }); |
| 275 | + |
| 276 | + describe('order: the diagnostic execution gives when a filter has two defects', () => { |
| 277 | + it('the door stage runs before the placeholder stage', async () => { |
| 278 | + const where = (): Where => ({ owner_id: '{bogus_token}', amount: { $contains: '5' } }); |
| 279 | + const verdict = refused(engine.judgeFilter(DEAL, where())); |
| 280 | + const thrown = await refusalOf(engine.find(DEAL, { where: where() })); |
| 281 | + expect(verdict.code).toBe('INVALID_FILTER'); |
| 282 | + expect({ code: thrown!.code, status: thrown!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); |
| 283 | + expect(verdict.message).toBe(thrown!.message); |
| 284 | + }); |
| 285 | + |
| 286 | + it('inside the door stage, the materializable door answers before the text-operator door', async () => { |
| 287 | + const where = (): Where => ({ amount: { $contains: '5' }, is_open: true }); |
| 288 | + const verdict = refused(engine.judgeFilter(DEAL, where())); |
| 289 | + const thrown = await refusalOf(engine.find(DEAL, { where: where() })); |
| 290 | + expect(verdict.code).toBe('INVALID_FIELD'); |
| 291 | + expect({ code: thrown!.code, status: thrown!.status }).toEqual({ code: 'INVALID_FIELD', status: 400 }); |
| 292 | + expect(verdict.message).toBe(thrown!.message); |
| 293 | + }); |
| 294 | + }); |
| 295 | + |
| 296 | + describe('placeholders resolve against the supplied context, never to null', () => { |
| 297 | + it('a context placeholder with no context is refused, as execution refuses it', async () => { |
| 298 | + const where = { owner_id: '{current_user_id}' }; |
| 299 | + const verdict = refused(engine.judgeFilter(DEAL, where)); |
| 300 | + const thrown = await refusalOf(engine.find(DEAL, { where })); |
| 301 | + expect({ code: verdict.code, status: verdict.status }).toEqual({ code: 'FILTER_TOKEN_UNRESOLVED', status: 400 }); |
| 302 | + expect({ code: thrown!.code, status: thrown!.status }).toEqual({ code: 'FILTER_TOKEN_UNRESOLVED', status: 400 }); |
| 303 | + expect(verdict.message).toBe(thrown!.message); |
| 304 | + }); |
| 305 | + |
| 306 | + it('the same placeholder with the context execution would get is ok, and execution sends the resolved value', async () => { |
| 307 | + const where = { owner_id: '{current_user_id}' }; |
| 308 | + expect(engine.judgeFilter(DEAL, where, { context: { userId: 'u_1' } })).toEqual({ ok: true }); |
| 309 | + await engine.find(DEAL, { where }, { context: { userId: 'u_1' } }); |
| 310 | + expect(reads.at(-1)?.ast.where).toEqual({ owner_id: 'u_1' }); |
| 311 | + }); |
| 312 | + }); |
| 313 | + |
| 314 | + describe('an object the registry does not know', () => { |
| 315 | + it('the field-map doors answer nothing and the schema-free doors still judge, as at execution', async () => { |
| 316 | + // A virtual-field verdict needs the field map; with none, it is ok. |
| 317 | + expect(engine.judgeFilter('judge_unregistered', { is_open: true })).toEqual({ ok: true }); |
| 318 | + // The list-comparand shape gate needs no field map. |
| 319 | + const where = (): Where => ({ status: { $in: 'open' } }); |
| 320 | + const verdict = refused(engine.judgeFilter('judge_unregistered', where())); |
| 321 | + const thrown = await refusalOf(engine.find('judge_unregistered', { where: where() })); |
| 322 | + expect({ code: verdict.code, status: verdict.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); |
| 323 | + expect({ code: thrown!.code, status: thrown!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); |
| 324 | + expect(verdict.message).toBe(thrown!.message); |
| 325 | + }); |
| 326 | + }); |
| 327 | +}); |
0 commit comments