|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#20844] A relative-date placeholder is judged by the year of the value it |
| 5 | + * resolved to: outside its column's years (a `date` 0001..9999, a `datetime` |
| 6 | + * 1000..9999 — `@objectstack/core`'s `isOutsideTemporalYearRange`) it is |
| 7 | + * refused `INVALID_FILTER` / 400, naming the placeholder and the year, on |
| 8 | + * every position the resolver serves, before any driver read. |
| 9 | + * |
| 10 | + * Measured on the base (`2f2fa11d7`) through `engine.find` on InMemoryDriver |
| 11 | + * and `POST /api/v1/data/:object/query` on SqlDriver over SQLite, two rows |
| 12 | + * (`opened_at` 2026-03-01T10:00Z and 1500-03-01T10:00Z): |
| 13 | + * |
| 14 | + * | `where` | resolved to | memory | SQLite | now | |
| 15 | + * |:--|:--|:--|:--|:--| |
| 16 | + * | `opened_at $gt {8000_years_from_now}` | `10026-10-01` | both rows | both rows | 400 | |
| 17 | + * | `opened_at $lt {2027_years_ago}` | `-1-10-01`, read as 2001-01-10 | the 1500 row | the 1500 row | 400 | |
| 18 | + * | `opened_at $lt {1977_years_ago}` | `0049-10-01` | no row | no row | 400 (the `datetime` floor) | |
| 19 | + * | `placed_on $gt {8000_years_from_now}` | `10026-10-01` | both rows | both rows | 400 | |
| 20 | + * | `having` `max(opened_at) $gt {8000_years_from_now}` | `10026-10-01` | both groups | — | 400 | |
| 21 | + * | `judgeFilter` of the first two | | `{ ok: true }` | | refused | |
| 22 | + * |
| 23 | + * The right answer to each `$gt` / `$lt` above was no row; a literal of each |
| 24 | + * resolved value was already refused by the temporal-comparand door, which |
| 25 | + * steps around a placeholder. The refusal sits in front of every driver, so |
| 26 | + * this file's recording driver is enough to pin it (the memory row by |
| 27 | + * construction: the refusal answers before a driver is asked); the REST door |
| 28 | + * over SQLite, with the rows, is `packages/rest/src/data-resolved-token-year-range.test.ts`. |
| 29 | + * Every refusal sits beside its control: a placeholder that resolves inside |
| 30 | + * the range reaches the driver as the day it names. |
| 31 | + */ |
| 32 | + |
| 33 | +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; |
| 34 | +import { ObjectQL } from './engine.js'; |
| 35 | + |
| 36 | +// Wed 2026-09-30 12:00 UTC: every resolved day below is read off this instant. |
| 37 | +const PINNED_NOW = new Date('2026-09-30T12:00:00.000Z'); |
| 38 | + |
| 39 | +const ledger = { |
| 40 | + name: 'ledger', |
| 41 | + label: 'Ledger', |
| 42 | + fields: { |
| 43 | + id: { name: 'id', type: 'text' as const, primaryKey: true }, |
| 44 | + customer_id: { name: 'customer_id', type: 'text' as const }, |
| 45 | + placed_on: { name: 'placed_on', type: 'date' as const }, |
| 46 | + opened_at: { name: 'opened_at', type: 'datetime' as const }, |
| 47 | + opens_at: { name: 'opens_at', type: 'time' as const }, |
| 48 | + note: { name: 'note', type: 'text' as const }, |
| 49 | + }, |
| 50 | +}; |
| 51 | + |
| 52 | +/** placeholder · what it resolves to off PINNED_NOW · the year a refusal names */ |
| 53 | +const PAST_BOTH: ReadonlyArray<readonly [string, string, string]> = [ |
| 54 | + ['{8000_years_from_now}', '+010026-09-30', '10026'], |
| 55 | + ['{7974_years_from_now}', '+010000-09-30', '10000'], |
| 56 | + ['{2027_years_ago}', '-000001-09-30', '-1'], |
| 57 | + ['{2026_years_ago}', '0000-09-30', '0'], |
| 58 | + // A sub-day placeholder resolves to an instant. |
| 59 | + ['{80000000_hours_from_now}', new Date(PINNED_NOW.getTime() + 80_000_000 * 3_600_000).toISOString(), '11153'], |
| 60 | +]; |
| 61 | + |
| 62 | +/** Outside a `datetime`'s years only: before its floor, inside a `date`'s. */ |
| 63 | +const BEFORE_DATETIME_FLOOR: ReadonlyArray<readonly [string, string, string]> = [ |
| 64 | + ['{1977_years_ago}', '0049-09-30', '49'], |
| 65 | + ['{1027_years_ago}', '0999-09-30', '999'], |
| 66 | +]; |
| 67 | + |
| 68 | +/** Inside both kinds' years — the edges and a control — each reaches the driver as the day it names. */ |
| 69 | +const INSIDE: ReadonlyArray<readonly [string, string]> = [ |
| 70 | + ['{1026_years_ago}', '1000-09-30'], |
| 71 | + ['{7973_years_from_now}', '9999-09-30'], |
| 72 | + ['{100_years_ago}', '1926-09-30'], |
| 73 | +]; |
| 74 | + |
| 75 | +/** A driver that records every read, and answers none. */ |
| 76 | +function makeRecordingDriver() { |
| 77 | + const reads: unknown[] = []; |
| 78 | + const driver: any = { |
| 79 | + name: 'recording', version: '0.0.0', supports: {}, |
| 80 | + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, |
| 81 | + async find(_o: string, ast: unknown) { reads.push(ast); return []; }, |
| 82 | + async findOne(_o: string, ast: unknown) { reads.push(ast); return null; }, |
| 83 | + async count(_o: string, ast: unknown) { reads.push(ast); return 0; }, |
| 84 | + async aggregate(_o: string, ast: unknown) { reads.push(ast); return []; }, |
| 85 | + async create(_o: string, data: Record<string, unknown>) { return { ...data }; }, |
| 86 | + async update(_o: string, id: string, data: Record<string, unknown>) { return { ...data, id }; }, |
| 87 | + async updateMany(_o: string, ast: unknown) { reads.push(ast); return 0; }, |
| 88 | + async delete() { return true; }, |
| 89 | + async deleteMany(_o: string, ast: unknown) { reads.push(ast); return 0; }, |
| 90 | + async bulkCreate(_o: string, batch: Record<string, unknown>[]) { return batch; }, |
| 91 | + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, |
| 92 | + async commit() {}, async rollback() {}, |
| 93 | + }; |
| 94 | + return { driver, reads }; |
| 95 | +} |
| 96 | + |
| 97 | +const refusalOf = async (p: Promise<unknown>) => |
| 98 | + p.then(() => null, (e: any) => e as Error & { code?: string; status?: number }); |
| 99 | + |
| 100 | +describe('[#20844] a relative-date placeholder resolved outside its column\'s years is refused, on every position, before any read', () => { |
| 101 | + let engine: ObjectQL; |
| 102 | + let reads: unknown[]; |
| 103 | + |
| 104 | + beforeEach(async () => { |
| 105 | + vi.useFakeTimers({ toFake: ['Date'] }); |
| 106 | + vi.setSystemTime(PINNED_NOW); |
| 107 | + const rec = makeRecordingDriver(); |
| 108 | + reads = rec.reads; |
| 109 | + engine = new ObjectQL(); |
| 110 | + engine.registerDriver(rec.driver, true); |
| 111 | + await engine.init(); |
| 112 | + engine.registry.registerObject(ledger, 'test'); |
| 113 | + }); |
| 114 | + |
| 115 | + afterEach(() => { |
| 116 | + vi.useRealTimers(); |
| 117 | + }); |
| 118 | + |
| 119 | + /** Every verb and position that resolves a placeholder in a caller's condition. */ |
| 120 | + const positions = (field: string, op: string, token: string) => [ |
| 121 | + ['find where', () => engine.find('ledger', { where: { [field]: { [op]: token } } })], |
| 122 | + ['findOne where', () => engine.findOne('ledger', { where: { [field]: { [op]: token } } })], |
| 123 | + ['count where', () => engine.count('ledger', { where: { [field]: { [op]: token } } })], |
| 124 | + ['update where', () => engine.update('ledger', { note: 'x' }, { where: { [field]: { [op]: token } }, multi: true } as never)], |
| 125 | + ['delete where', () => engine.delete('ledger', { where: { [field]: { [op]: token } }, multi: true } as never)], |
| 126 | + ['aggregate where', () => engine.aggregate('ledger', { |
| 127 | + where: { [field]: { [op]: token } }, |
| 128 | + aggregations: [{ function: 'count', alias: 'n' }], |
| 129 | + } as never)], |
| 130 | + ['per-aggregation filter', () => engine.aggregate('ledger', { |
| 131 | + aggregations: [{ function: 'count', alias: 'n' }, { function: 'count', alias: 'm', filter: { [field]: { [op]: token } } }], |
| 132 | + } as never)], |
| 133 | + ['having', () => engine.aggregate('ledger', { |
| 134 | + groupBy: ['customer_id'], |
| 135 | + aggregations: [{ function: 'max', field, alias: 'last' }], |
| 136 | + having: { last: { [op]: token } }, |
| 137 | + } as never)], |
| 138 | + ] as const; |
| 139 | + |
| 140 | + for (const [field, kind] of [['opened_at', 'datetime'], ['placed_on', 'date']] as const) { |
| 141 | + it(`${kind}: a placeholder resolved outside 0001..9999 is INVALID_FILTER / 400 on every position, naming the placeholder and the year`, async () => { |
| 142 | + for (const [token, resolved, year] of PAST_BOTH) { |
| 143 | + for (const op of ['$gt', '$lt'] as const) { |
| 144 | + for (const [position, call] of positions(field, op, token)) { |
| 145 | + const err = await refusalOf(call()); |
| 146 | + const at = `${position} ${field} ${op} ${token}`; |
| 147 | + expect(err, at).not.toBeNull(); |
| 148 | + expect(err!.code, at).toBe('INVALID_FILTER'); |
| 149 | + expect(err!.status, at).toBe(400); |
| 150 | + expect(err!.message, at).toContain(`"${token}"`); |
| 151 | + expect(err!.message, at).toContain(`resolved to "${resolved}"`); |
| 152 | + expect(err!.message, at).toContain(`(the year ${year})`); |
| 153 | + expect(err!.message, at).toContain(kind === 'date' ? 'the years 0001 to 9999' : 'the years 1000 to 9999'); |
| 154 | + } |
| 155 | + } |
| 156 | + } |
| 157 | + expect(reads, 'no read — every refusal precedes the driver').toHaveLength(0); |
| 158 | + }); |
| 159 | + } |
| 160 | + |
| 161 | + it('the refusal is rooted at the position the placeholder sits in, in the door\'s words for that position', async () => { |
| 162 | + const where = await refusalOf(engine.find('ledger', { where: { opened_at: { $gt: '{8000_years_from_now}' } } })); |
| 163 | + expect(where!.message).toContain("find('ledger'): filter on 'opened_at' compares a declared datetime field against \"{8000_years_from_now}\" at where.opened_at.$gt"); |
| 164 | + expect(where!.message).toContain('does not sort as an instant'); |
| 165 | + expect(where!.message).toContain('The filter was NOT applied.'); |
| 166 | + const filter = await refusalOf(engine.aggregate('ledger', { |
| 167 | + aggregations: [{ function: 'count', alias: 'n' }, { function: 'count', alias: 'm', filter: { opened_at: { $gt: '{8000_years_from_now}' } } }], |
| 168 | + } as never)); |
| 169 | + expect(filter!.message).toContain('at aggregations[1].filter.opened_at.$gt'); |
| 170 | + const having = await refusalOf(engine.aggregate('ledger', { |
| 171 | + groupBy: ['customer_id'], |
| 172 | + aggregations: [{ function: 'max', field: 'opened_at', alias: 'last' }], |
| 173 | + having: { last: { $lt: '{2027_years_ago}' } }, |
| 174 | + } as never)); |
| 175 | + expect(having!.message).toContain("`having` on 'last' (max(opened_at), a datetime column) compares against \"{2027_years_ago}\" at having.last.$lt"); |
| 176 | + expect(having!.message).toContain('The `having` was NOT applied.'); |
| 177 | + expect(reads).toHaveLength(0); |
| 178 | + }); |
| 179 | + |
| 180 | + it('a list member, a range bound, the implicit-equality slot and a nested branch are judged too', async () => { |
| 181 | + for (const where of [ |
| 182 | + { opened_at: '{8000_years_from_now}' }, |
| 183 | + { opened_at: { $in: ['{100_years_ago}', '{2027_years_ago}'] } }, |
| 184 | + { opened_at: { $between: ['{100_years_ago}', '{8000_years_from_now}'] } }, |
| 185 | + { $or: [{ customer_id: 'x' }, { $not: { opened_at: { $gte: '{2027_years_ago}' } } }] }, |
| 186 | + [['opened_at', '>', '{8000_years_from_now}']], |
| 187 | + ]) { |
| 188 | + const err = await refusalOf(engine.find('ledger', { where: where as never })); |
| 189 | + expect(err, JSON.stringify(where)).toMatchObject({ code: 'INVALID_FILTER', status: 400 }); |
| 190 | + } |
| 191 | + expect(reads).toHaveLength(0); |
| 192 | + }); |
| 193 | + |
| 194 | + it('the datetime floor of 1000 applies to a resolved placeholder as to a literal — a date keeps those years', async () => { |
| 195 | + for (const [token, resolved, year] of BEFORE_DATETIME_FLOOR) { |
| 196 | + const err = await refusalOf(engine.find('ledger', { where: { opened_at: { $lt: token } } })); |
| 197 | + expect(err, token).toMatchObject({ code: 'INVALID_FILTER', status: 400 }); |
| 198 | + expect(err!.message).toContain(`resolved to "${resolved}" (the year ${year})`); |
| 199 | + expect(err!.message).toContain('Before year 1000'); |
| 200 | + expect(err!.message).not.toContain('does not sort'); |
| 201 | + // The control: the same placeholder on a `date` reaches the driver as its day. |
| 202 | + const before = reads.length; |
| 203 | + await engine.find('ledger', { where: { placed_on: { $lt: token } } }); |
| 204 | + expect(reads.length, `${token} on a date reached the driver`).toBe(before + 1); |
| 205 | + expect(JSON.stringify(reads[before]), token).toContain(`"${resolved}"`); |
| 206 | + } |
| 207 | + }); |
| 208 | + |
| 209 | + it('the control: a placeholder that resolves inside the range reaches the driver as the day it names, on every position', async () => { |
| 210 | + for (const [token, resolved] of INSIDE) { |
| 211 | + for (const field of ['opened_at', 'placed_on'] as const) { |
| 212 | + for (const [position, call] of positions(field, '$gt', token)) { |
| 213 | + const before = reads.length; |
| 214 | + await expect(call(), `${position} ${field} ${token}`).resolves.toBeDefined(); |
| 215 | + // A per-aggregation filter and `having` are evaluated by the engine |
| 216 | + // over the rows the driver reads; the resolved day reaches the driver |
| 217 | + // on the rest. (The REST suite reads their counts over SQLite.) |
| 218 | + if (position === 'having' || position === 'per-aggregation filter') continue; |
| 219 | + expect(reads.length, `${position} ${field} ${token} reached the driver`).toBeGreaterThan(before); |
| 220 | + expect(JSON.stringify(reads.slice(before)), `${position} ${field} ${token}`).toContain(resolved); |
| 221 | + } |
| 222 | + } |
| 223 | + } |
| 224 | + }); |
| 225 | + |
| 226 | + it('the judge (`judgeFilter`) refuses what execution refuses, with the same code, status and message', async () => { |
| 227 | + for (const [token] of PAST_BOTH) { |
| 228 | + const where = { opened_at: { $gt: token } }; |
| 229 | + const judged = engine.judgeFilter('ledger', where); |
| 230 | + const executed = await refusalOf(engine.find('ledger', { where })); |
| 231 | + expect(judged, token).toEqual({ ok: false, code: 'INVALID_FILTER', status: 400, message: executed!.message }); |
| 232 | + } |
| 233 | + expect(engine.judgeFilter('ledger', { opened_at: { $gt: '{100_years_ago}' } })).toEqual({ ok: true }); |
| 234 | + expect(engine.judgeFilter('ledger', { opened_at: { $lt: '{1977_years_ago}' } })).toMatchObject({ ok: false, code: 'INVALID_FILTER', status: 400 }); |
| 235 | + expect(engine.judgeFilter('ledger', { placed_on: { $lt: '{1977_years_ago}' } })).toEqual({ ok: true }); |
| 236 | + }); |
| 237 | + |
| 238 | + it('a placeholder on a column with no year, or a context placeholder, is not this judgement\'s', async () => { |
| 239 | + // A text column compares the resolved day as text: no kind, no range. |
| 240 | + await expect(engine.find('ledger', { where: { note: { $gt: '{8000_years_from_now}' } } })).resolves.toEqual([]); |
| 241 | + expect(JSON.stringify(reads.at(-1))).toContain('+010026-09-30'); |
| 242 | + // A context placeholder names no year. |
| 243 | + await expect(engine.find('ledger', { where: { customer_id: '{current_user_id}' }, context: { userId: 'u1' } } as never)).resolves.toEqual([]); |
| 244 | + expect(reads).toHaveLength(2); |
| 245 | + }); |
| 246 | +}); |
0 commit comments