|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#20176] This driver's `where` reads a temporal comparand by the ONE storage |
| 5 | + * rule — `@objectstack/core`'s `temporalStorageForm` — that `driver-sql` and the |
| 6 | + * engine's per-aggregation `filter` / `having` read too. |
| 7 | + * |
| 8 | + * Two halves: |
| 9 | + * |
| 10 | + * 1. `coerceTemporalValue` IS that rule, shape for shape, with a list mapped |
| 11 | + * member by member. This file used to carry its own copy |
| 12 | + * (`storageDatetimeValue` / `storageDateValue` / `storageTimeValue`); the |
| 13 | + * copy and `driver-sql`'s agreed on every shape measured when the rule was |
| 14 | + * lifted, so the re-pointing moved no answer. |
| 15 | + * 2. The `where` answers the engine's per-aggregation `filter` is now held to — |
| 16 | + * the #20176 card's rows on its fixture. `@objectstack/objectql`'s |
| 17 | + * `engine-aggregate-temporal-storage-rule.test.ts` asserts the same numbers |
| 18 | + * for the per-aggregation position; this is the control half, on this |
| 19 | + * driver (`driver-sql`'s twin is the REST suite |
| 20 | + * `aggregation-filter-temporal-storage-rule.test.ts`). |
| 21 | + */ |
| 22 | + |
| 23 | +import { describe, it, expect, beforeAll } from 'vitest'; |
| 24 | +import { temporalStorageForm } from '@objectstack/core'; |
| 25 | +import { coerceTemporalValue } from './memory-temporal.js'; |
| 26 | +import { InMemoryDriver } from './memory-driver.js'; |
| 27 | + |
| 28 | +const SHAPES: ReadonlyArray<readonly [string, () => unknown]> = [ |
| 29 | + ['Date', () => new Date('2026-02-01T10:00:00.000Z')], |
| 30 | + ['Invalid Date', () => new Date('nope')], |
| 31 | + ['epoch ms', () => 1769940000000], |
| 32 | + ['epoch ms string', () => '1769940000000'], |
| 33 | + ['bare day', () => '2026-02-01'], |
| 34 | + ['zone-naive', () => '2026-02-01 09:00'], |
| 35 | + ['ISO instant', () => '2026-02-01T10:00:00Z'], |
| 36 | + ['offset instant', () => '2026-02-01T18:00:00+08:00'], |
| 37 | + ['wall clock', () => '09:00'], |
| 38 | + ['wall clock .5', () => '09:00:00.5'], |
| 39 | + ['out-of-range wall clock', () => '25:00'], |
| 40 | + ['junk', () => 'not-a-date'], |
| 41 | + ['empty', () => ''], |
| 42 | + ['null', () => null], |
| 43 | + ['boolean', () => true], |
| 44 | +]; |
| 45 | + |
| 46 | +describe('[#20176] coerceTemporalValue is core\'s temporalStorageForm', () => { |
| 47 | + for (const kind of ['datetime', 'date', 'time'] as const) { |
| 48 | + for (const [name, make] of SHAPES) { |
| 49 | + it(`${kind}: ${name}`, () => { |
| 50 | + expect(coerceTemporalValue(make(), kind)).toStrictEqual(temporalStorageForm(make(), kind)); |
| 51 | + }); |
| 52 | + } |
| 53 | + it(`${kind}: a list comparand is mapped member by member`, () => { |
| 54 | + const list = SHAPES.map(([, make]) => make()); |
| 55 | + expect(coerceTemporalValue(list, kind)).toStrictEqual(list.map((v) => temporalStorageForm(v, kind))); |
| 56 | + }); |
| 57 | + } |
| 58 | + |
| 59 | + it('a non-temporal field passes through untouched', () => { |
| 60 | + const value = '2026-02-01T10:00:00Z'; |
| 61 | + expect(coerceTemporalValue(value, undefined)).toBe(value); |
| 62 | + }); |
| 63 | +}); |
| 64 | + |
| 65 | +const OBJECT = 'ledger_order'; |
| 66 | +const ROWS = [ |
| 67 | + { id: 'o1', placed_on: '2026-01-10', opened_at: '2026-01-01T10:00:00.000Z', slot: '09:00:00' }, |
| 68 | + { id: 'o2', placed_on: '2026-01-02', opened_at: '2026-01-02T10:00:00.000Z', slot: '10:30:00' }, |
| 69 | + { id: 'o3', placed_on: '2026-03-01', opened_at: '2026-02-01T10:00:00.000Z', slot: '11:00:00' }, |
| 70 | + { id: 'o4', placed_on: '2026-02-01', opened_at: '2026-02-05T10:00:00.000Z', slot: '12:00:00' }, |
| 71 | + { id: 'o5', placed_on: '2026-01-15', opened_at: '2026-02-06T10:00:00.000Z', slot: '13:00:00' }, |
| 72 | + { id: 'o6', placed_on: '2026-02-01', opened_at: '2026-03-01T10:00:00.000Z', slot: '14:00:00' }, |
| 73 | +]; |
| 74 | + |
| 75 | +/** The #20176 card's rows, as a `where`, with the count this driver answers. */ |
| 76 | +const CARD_WHERE_TWINS: ReadonlyArray<readonly [string, () => Record<string, unknown>, number]> = [ |
| 77 | + ['row 1 — an ISO instant $gte on a date field', () => ({ placed_on: { $gte: '2026-02-01T00:00:00.000Z' } }), 3], |
| 78 | + ['row 2 — an ISO instant $eq on a date field', () => ({ placed_on: { $eq: '2026-02-01T00:00:00.000Z' } }), 2], |
| 79 | + ['row 3 — a bare day as the $lte of a datetime', () => ({ opened_at: { $lte: '2026-02-01' } }), 3], |
| 80 | + ['row 4 — a bare day as the $between max of a datetime', () => ({ opened_at: { $between: ['2026-01-01', '2026-02-01'] } }), 3], |
| 81 | + ['row 5 — an epoch-ms bound on a datetime', () => ({ opened_at: { $gt: 1769940000000 } }), 3], |
| 82 | + ['row 6 — a Date with a time of day, $gte on a date field', () => ({ placed_on: { $gte: new Date('2026-02-01T10:00:00.000Z') } }), 3], |
| 83 | + ['row 6 — the same Date, $lt', () => ({ placed_on: { $lt: new Date('2026-02-01T10:00:00.000Z') } }), 3], |
| 84 | + ['row 6 — the same Date, $eq', () => ({ placed_on: { $eq: new Date('2026-02-01T10:00:00.000Z') } }), 2], |
| 85 | + ['row 7 — a Date on a time field', () => ({ slot: { $gt: new Date('2026-02-01T11:00:00.000Z') } }), 3], |
| 86 | + ['control — a zone-naive datetime string', () => ({ opened_at: { $gt: '2026-02-01 09:00' } }), 4], |
| 87 | +]; |
| 88 | + |
| 89 | +describe('[#20176] the where twins of the card\'s rows, on this driver', () => { |
| 90 | + let driver: InMemoryDriver; |
| 91 | + |
| 92 | + beforeAll(async () => { |
| 93 | + driver = new InMemoryDriver({}); |
| 94 | + await driver.connect(); |
| 95 | + await driver.syncSchema(OBJECT, { |
| 96 | + name: OBJECT, |
| 97 | + fields: { placed_on: { type: 'date' }, opened_at: { type: 'datetime' }, slot: { type: 'time' } }, |
| 98 | + }); |
| 99 | + for (const row of ROWS) await driver.create(OBJECT, row); |
| 100 | + }); |
| 101 | + |
| 102 | + for (const [name, where, expected] of CARD_WHERE_TWINS) { |
| 103 | + it(`${name}: ${expected} of 6`, async () => { |
| 104 | + const rows = await driver.find(OBJECT, { where: where() } as any); |
| 105 | + expect(rows).toHaveLength(expected); |
| 106 | + }); |
| 107 | + } |
| 108 | +}); |
0 commit comments