|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#20148] The KEYS inside a per-aggregation `filter`, at the read door. |
| 5 | + * |
| 6 | + * `findData` judged a `where`'s keys against the object's field set (#7534, |
| 7 | + * {@link ObjectStackProtocolImplementation} `assertFilterFieldsExist`) and an |
| 8 | + * aggregation's `function` / `alias` / `field` (#4254), but never the keys of |
| 9 | + * the filter an aggregation carries. Measured on the base through |
| 10 | + * `POST /api/v1/data/:object/query` on driver-memory and driver-sql: |
| 11 | + * |
| 12 | + * ``` |
| 13 | + * aggregations: [{ …, filter: { nope: 1 } }] -> 200, that count 0 |
| 14 | + * aggregations: [{ …, filter: { nope: { $ne: 1 } } }] -> 200, EVERY row counted |
| 15 | + * where: { nope: 1 } -> 400 INVALID_FIELD |
| 16 | + * ``` |
| 17 | + * |
| 18 | + * Now each aggregation's filter goes through the `where` gate itself: the same |
| 19 | + * field set, the same `unknown` > `dotted` > virtual ladder, the same |
| 20 | + * `INVALID_FIELD` / 400, the caller's position (`aggregations[1].filter`) as |
| 21 | + * the parameter — and the engine is never reached. Every pin below asserts |
| 22 | + * `code` AND `status` AND that `engine.aggregate` was not called; the |
| 23 | + * preservation half asserts what the engine received. |
| 24 | + */ |
| 25 | + |
| 26 | +import { describe, it, expect, vi } from 'vitest'; |
| 27 | +import { ObjectStackProtocolImplementation } from './protocol.js'; |
| 28 | + |
| 29 | +const SCHEMA = { |
| 30 | + name: 'invoice', |
| 31 | + fields: { |
| 32 | + name: { name: 'name', type: 'text' }, |
| 33 | + status: { name: 'status', type: 'text' }, |
| 34 | + amount: { name: 'amount', type: 'number' }, |
| 35 | + doubled: { name: 'doubled', type: 'formula', expression: 'amount * 2' }, |
| 36 | + owner_id: { name: 'owner_id', type: 'lookup', reference: 'sys_user' }, |
| 37 | + }, |
| 38 | +}; |
| 39 | + |
| 40 | +function makeProtocol() { |
| 41 | + const find = vi.fn(async () => [] as unknown[]); |
| 42 | + const aggregate = vi.fn(async () => [] as unknown[]); |
| 43 | + const engine = { |
| 44 | + registry: { getObject: (n: string) => (n === 'invoice' ? SCHEMA : undefined) }, |
| 45 | + find, |
| 46 | + aggregate, |
| 47 | + count: vi.fn(async () => 0), |
| 48 | + }; |
| 49 | + return { p: new ObjectStackProtocolImplementation(engine as any), find, aggregate }; |
| 50 | +} |
| 51 | + |
| 52 | +/** The second of two aggregations carries `filter`, so the position must name WHICH one. */ |
| 53 | +function withFilter(filter: unknown) { |
| 54 | + return { |
| 55 | + aggregations: [ |
| 56 | + { function: 'count', alias: 'n' }, |
| 57 | + { function: 'count', alias: 'm', filter }, |
| 58 | + ], |
| 59 | + }; |
| 60 | +} |
| 61 | + |
| 62 | +async function refusalFor(query: Record<string, unknown>) { |
| 63 | + const { p, find, aggregate } = makeProtocol(); |
| 64 | + let answered: unknown; |
| 65 | + try { |
| 66 | + answered = await p.findData({ object: 'invoice', query } as never); |
| 67 | + } catch (e) { |
| 68 | + expect(find, 'the engine was reached before the refusal').not.toHaveBeenCalled(); |
| 69 | + expect(aggregate, 'the engine was reached before the refusal').not.toHaveBeenCalled(); |
| 70 | + return e as Error & { status?: number; code?: string; param?: string; field?: string; fields?: string[] }; |
| 71 | + } |
| 72 | + throw new Error(`${JSON.stringify(query)} was ACCEPTED (answered ${JSON.stringify(answered)}) instead of refused`); |
| 73 | +} |
| 74 | + |
| 75 | +describe('[#20148] findData — the keys inside a per-aggregation filter meet the where gate', () => { |
| 76 | + const REFUSED: ReadonlyArray<readonly [string, Record<string, unknown>, string]> = [ |
| 77 | + ['an unknown key (the card\'s row 4)', { nope: 1 }, 'nope'], |
| 78 | + ['an unknown key under $ne', { nope: { $ne: 1 } }, 'nope'], |
| 79 | + ['an unknown key under $and', { $and: [{ amount: { $gt: 0 } }, { nope: 1 }] }, 'nope'], |
| 80 | + ['an unknown key behind a $or branch that holds', { $or: [{ amount: { $gt: 0 } }, { nope: 1 }] }, 'nope'], |
| 81 | + ['an unknown key under $not', { $not: { nope: 1 } }, 'nope'], |
| 82 | + ['an unknown key beside an unknown operator', { nope: { $median: 1 } }, 'nope'], |
| 83 | + ]; |
| 84 | + |
| 85 | + for (const [name, filter, key] of REFUSED) { |
| 86 | + it(`${name}: 400 INVALID_FIELD at the aggregation's position, as the same key in where is`, async () => { |
| 87 | + const err = await refusalFor(withFilter(filter)); |
| 88 | + expect(err.code).toBe('INVALID_FIELD'); |
| 89 | + expect(err.status).toBe(400); |
| 90 | + expect(err.field).toBe(key); |
| 91 | + expect(err.param).toBe('aggregations[1].filter'); |
| 92 | + // The twin: the same condition as the call's `where`. |
| 93 | + const twin = await refusalFor({ where: filter, aggregations: [{ function: 'count', alias: 'n' }] }); |
| 94 | + expect(twin.code).toBe('INVALID_FIELD'); |
| 95 | + expect(twin.status).toBe(400); |
| 96 | + expect(twin.field).toBe(key); |
| 97 | + }); |
| 98 | + } |
| 99 | + |
| 100 | + it('the gate\'s other two verdicts come with it — a dotted path and a virtual field, each as in where', async () => { |
| 101 | + for (const [filter, field] of [[{ 'status.name': 'x' }, 'status.name'], [{ doubled: { $gt: 10 } }, 'doubled']] as const) { |
| 102 | + const err = await refusalFor(withFilter(filter)); |
| 103 | + const twin = await refusalFor({ where: filter, aggregations: [{ function: 'count', alias: 'n' }] }); |
| 104 | + for (const e of [err, twin]) { |
| 105 | + expect(e.code).toBe('INVALID_FIELD'); |
| 106 | + expect(e.status).toBe(400); |
| 107 | + expect(e.field).toBe(field); |
| 108 | + } |
| 109 | + expect(err.param).toBe('aggregations[1].filter'); |
| 110 | + } |
| 111 | + }); |
| 112 | + |
| 113 | + it('an unknown aggregated field keeps its own verdict ahead of a filter key', async () => { |
| 114 | + const err = await refusalFor({ |
| 115 | + aggregations: [{ function: 'sum', field: 'amout', alias: 't', filter: { nope: 1 } }], |
| 116 | + }); |
| 117 | + expect(err.code).toBe('INVALID_FIELD'); |
| 118 | + expect(err.param).toBe('aggregations'); |
| 119 | + expect(err.field).toBe('amout'); |
| 120 | + }); |
| 121 | + |
| 122 | + const ADMITTED: ReadonlyArray<readonly [string, unknown]> = [ |
| 123 | + ['declared keys, composed', { $or: [{ amount: { $gt: 100 } }, { status: 'paid' }] }], |
| 124 | + ['the columns every row carries', { id: 'i1', created_at: { $ne: null }, updated_at: { $ne: null } }], |
| 125 | + ['a relation head in the nested-object form', { owner_id: { $ne: null } }], |
| 126 | + ['a filter that is not an object (left to the engine\'s shape gate)', 'status = paid'], |
| 127 | + ['an array filter (left to the engine\'s shape gate)', [['amount', '>', 1]]], |
| 128 | + ]; |
| 129 | + |
| 130 | + for (const [name, filter] of ADMITTED) { |
| 131 | + it(`${name}: reaches engine.aggregate with the filter untouched`, async () => { |
| 132 | + const { p, aggregate } = makeProtocol(); |
| 133 | + await p.findData({ object: 'invoice', query: withFilter(filter) } as never); |
| 134 | + expect(aggregate).toHaveBeenCalledTimes(1); |
| 135 | + const opts = (aggregate.mock.calls[0] as unknown[])[1] as { aggregations: Array<{ filter?: unknown }> }; |
| 136 | + expect(opts.aggregations[1].filter).toEqual(filter); |
| 137 | + }); |
| 138 | + } |
| 139 | +}); |
0 commit comments