|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#20822 · ADR-0053 D-D1 item 7, as amended] The engine's `where` seam has no |
| 5 | + * declared type to read for an object with no field map, and item 7 says what |
| 6 | + * such a seam does: "A seam that cannot applies the rewrite type-blind." So a |
| 7 | + * bare-day upper bound on ANY column of such an object leaves the engine |
| 8 | + * already lowered — `$lte` a day becomes `$lt` the next day, and `$between` |
| 9 | + * splits — on every verb that reads the object's declarations. |
| 10 | + * |
| 11 | + * Before #20822 this branch read "no field map" as "no datetime column" and |
| 12 | + * left the rule to the driver's own copy. Item 5 retires those copies, so that |
| 13 | + * reading would have dropped the whole-day bound on every such read: measured |
| 14 | + * on `driver-memory` with its copy deleted, an unregistered object's `$lte` a |
| 15 | + * day went from the whole day to midnight only. |
| 16 | + * |
| 17 | + * An object WITH a field map keeps the typed scope byte-identical (the control |
| 18 | + * below; `engine-shared-filter-lowering-seam.test.ts` pins the rest of it). |
| 19 | + * |
| 20 | + * The witness is the recording driver's `where`, as in the seam's own pin. |
| 21 | + */ |
| 22 | + |
| 23 | +import { beforeEach, describe, expect, it } from 'vitest'; |
| 24 | +import type { EngineAggregateOptions } from '@objectstack/spec/data'; |
| 25 | +import { ObjectQL } from './engine.js'; |
| 26 | + |
| 27 | +const UNREGISTERED = 'no_field_map_probe'; |
| 28 | +const REGISTERED = 'typed_probe'; |
| 29 | + |
| 30 | +const TYPED_SCHEMA = { |
| 31 | + name: REGISTERED, |
| 32 | + label: 'Typed probe', |
| 33 | + fields: { |
| 34 | + id: { name: 'id', type: 'text' }, |
| 35 | + note: { name: 'note', type: 'text' }, |
| 36 | + closed_at: { name: 'closed_at', type: 'datetime' }, |
| 37 | + due_on: { name: 'due_on', type: 'date' }, |
| 38 | + }, |
| 39 | +}; |
| 40 | + |
| 41 | +interface Seen { verb: string; ast: any } |
| 42 | + |
| 43 | +function makeRecordingDriver() { |
| 44 | + const seen: Seen[] = []; |
| 45 | + const driver: any = { |
| 46 | + name: 'recording', version: '0.0.0', supports: {}, |
| 47 | + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, |
| 48 | + async find(_o: string, ast: any) { seen.push({ verb: 'find', ast }); return []; }, |
| 49 | + async findOne(_o: string, ast: any) { seen.push({ verb: 'findOne', ast }); return null; }, |
| 50 | + async count(_o: string, ast: any) { seen.push({ verb: 'count', ast }); return 0; }, |
| 51 | + }; |
| 52 | + return { driver, seen }; |
| 53 | +} |
| 54 | + |
| 55 | +describe('[#20822] an object with no field map: the engine seam lowers type-blind (ADR-0053 D-D1 item 7)', () => { |
| 56 | + let engine: ObjectQL; |
| 57 | + let seen: Seen[]; |
| 58 | + |
| 59 | + beforeEach(async () => { |
| 60 | + const rec = makeRecordingDriver(); |
| 61 | + seen = rec.seen; |
| 62 | + engine = new ObjectQL(); |
| 63 | + engine.registerDriver(rec.driver, true); |
| 64 | + await engine.init(); |
| 65 | + engine.registry.registerObject(TYPED_SCHEMA as any, 'test'); |
| 66 | + seen.length = 0; |
| 67 | + }); |
| 68 | + |
| 69 | + const lastWhere = (verb: string) => [...seen].reverse().find((s) => s.verb === verb)?.ast?.where; |
| 70 | + |
| 71 | + it('the fixture: the probe object really has no registry entry', () => { |
| 72 | + expect(engine.registry.getObject(UNREGISTERED)).toBeUndefined(); |
| 73 | + expect(engine.registry.getObject(REGISTERED)).toBeDefined(); |
| 74 | + }); |
| 75 | + |
| 76 | + it('find: a bare-day `$lte` on any column reaches the driver as `$lt` the next day', async () => { |
| 77 | + await engine.find(UNREGISTERED, { where: { closed_at: { $lte: '2026-07-28' } } }); |
| 78 | + expect(lastWhere('find')).toEqual({ closed_at: { $lt: '2026-07-29' } }); |
| 79 | + // Type-blind means every column: no declaration says `note` is not a datetime. |
| 80 | + await engine.find(UNREGISTERED, { where: { note: { $lte: '2026-07-28' } } }); |
| 81 | + expect(lastWhere('find')).toEqual({ note: { $lt: '2026-07-29' } }); |
| 82 | + }); |
| 83 | + |
| 84 | + it('find: `$between` with a bare-day maximum splits and widens its upper end', async () => { |
| 85 | + await engine.find(UNREGISTERED, { where: { closed_at: { $between: ['2026-07-01', '2026-07-28'] } } }); |
| 86 | + expect(lastWhere('find')).toEqual({ closed_at: { $gte: '2026-07-01', $lt: '2026-07-29' } }); |
| 87 | + }); |
| 88 | + |
| 89 | + it('findOne and count take the same reading', async () => { |
| 90 | + await engine.findOne(UNREGISTERED, { where: { closed_at: { $lte: '2026-07-28' } } }); |
| 91 | + expect(lastWhere('findOne') ?? lastWhere('find')).toEqual({ closed_at: { $lt: '2026-07-29' } }); |
| 92 | + await engine.count(UNREGISTERED, { where: { closed_at: { $lte: '2026-07-28' } } }); |
| 93 | + expect(lastWhere('count')).toEqual({ closed_at: { $lt: '2026-07-29' } }); |
| 94 | + }); |
| 95 | + |
| 96 | + it('aggregate: its `where` takes the same reading', async () => { |
| 97 | + await engine.aggregate(UNREGISTERED, { |
| 98 | + where: { closed_at: { $lte: '2026-07-28' } }, |
| 99 | + groupBy: ['note'], |
| 100 | + aggregations: [{ function: 'count', alias: 'n' }], |
| 101 | + } as EngineAggregateOptions); |
| 102 | + expect(lastWhere('find')).toEqual({ closed_at: { $lt: '2026-07-29' } }); |
| 103 | + }); |
| 104 | + |
| 105 | + it('an instant, and `$gte` / `$lt` on a bare day, are never widened', async () => { |
| 106 | + const where = { closed_at: { $lte: '2026-07-28T12:00:00.000Z' }, note: { $gte: '2026-07-01', $lt: '2026-07-28' } }; |
| 107 | + await engine.find(UNREGISTERED, { where }); |
| 108 | + expect(lastWhere('find')).toBe(where); |
| 109 | + }); |
| 110 | + |
| 111 | + it('control — a field map keeps the typed scope: only the declared datetime is rewritten', async () => { |
| 112 | + const where = { closed_at: { $lte: '2026-07-28' }, due_on: { $lte: '2026-07-28' }, note: { $lte: '2026-07-28' } }; |
| 113 | + await engine.find(REGISTERED, { where }); |
| 114 | + expect(lastWhere('find')).toEqual({ |
| 115 | + closed_at: { $lt: '2026-07-29' }, |
| 116 | + due_on: { $lte: '2026-07-28' }, |
| 117 | + note: { $lte: '2026-07-28' }, |
| 118 | + }); |
| 119 | + }); |
| 120 | + |
| 121 | + it('the judge runs the same stage and gains no verdict from it', () => { |
| 122 | + expect(engine.judgeFilter(UNREGISTERED, { closed_at: { $between: ['2026-07-01', '2026-07-28'] } })).toEqual({ ok: true }); |
| 123 | + }); |
| 124 | +}); |
0 commit comments