|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#24618] A `Field.date` dimension buckets on its OWN calendar day under every |
| 5 | + * reference timezone — on the in-memory rows path exactly as on the pushdown |
| 6 | + * path. |
| 7 | + * |
| 8 | + * ADR-0053: a `date` is a timezone-naive calendar day, "never converted to an |
| 9 | + * instant and never timezone-shifted"; a `datetime` keeps the requested zone. |
| 10 | + * The rows path used to hand a `date` value to `bucketDateKey` WITH the zone, |
| 11 | + * which read `'2026-10-01'` as the instant `2026-10-01T00:00Z` and placed it on |
| 12 | + * that zone's calendar: `2026-09-30` in America/New_York. Measured at the |
| 13 | + * public door (`POST /api/v1/analytics/query`, stock showcase): every day key |
| 14 | + * one day early, month `{2026-09: 1, 2026-10: 5, 2026-11: 4}` for a stored |
| 15 | + * `{2026-10: 6, 2026-11: 4}`, quarter `{2026-Q3: 1, 2026-Q4: 9}` for |
| 16 | + * `{2026-Q4: 10}` — while the SQL path, taken at UTC, answered the stored days. |
| 17 | + * |
| 18 | + * The expected labels below are LITERALS, the calendar facts of each stored |
| 19 | + * day — never computed by the labeller under test. |
| 20 | + * |
| 21 | + * Three parts: |
| 22 | + * (a) the rows path, declared `date`, under a negative-offset, a zero and a |
| 23 | + * positive-offset zone, at every granularity — and the `Date` value shape; |
| 24 | + * (b) the control: a declared `datetime` still follows the zone; |
| 25 | + * (c) `engine.aggregate`: the pushdown route (taken only at UTC) and the rows |
| 26 | + * route (forced by any other zone) answer ONE set of labels for a `date`. |
| 27 | + * The pushdown side here is a driver double answering the literal labels; |
| 28 | + * the REAL SQL faces are held to the same labels at UTC by |
| 29 | + * `checkDateBucketParity` (`@objectstack/verify`), so (c) + that check |
| 30 | + * are the parity under a non-UTC zone. |
| 31 | + */ |
| 32 | + |
| 33 | +import { describe, it, expect } from 'vitest'; |
| 34 | +import type { EngineAggregateOptions } from '@objectstack/spec/data'; |
| 35 | +import { ObjectQL } from './engine.js'; |
| 36 | +import { applyInMemoryAggregation } from './in-memory-aggregation.js'; |
| 37 | + |
| 38 | +type Granularity = 'day' | 'week' | 'month' | 'quarter' | 'year'; |
| 39 | +const GRANULARITIES: readonly Granularity[] = ['day', 'week', 'month', 'quarter', 'year']; |
| 40 | + |
| 41 | +/** |
| 42 | + * Each stored day's label at each granularity — its own calendar, no zone. |
| 43 | + * Chosen for the edges a one-day-early shift crosses: a quarter and month start |
| 44 | + * (10-01), two ISO-week Mondays (10-05, 11-02), a month start on a Sunday |
| 45 | + * (11-01) and a year start whose ISO week is the previous year's 53rd (2027-01-01). |
| 46 | + */ |
| 47 | +const STORED_DAY_LABELS: Readonly<Record<string, Readonly<Record<Granularity, string>>>> = { |
| 48 | + '2026-10-01': { day: '2026-10-01', week: '2026-W40', month: '2026-10', quarter: '2026-Q4', year: '2026' }, |
| 49 | + '2026-10-05': { day: '2026-10-05', week: '2026-W41', month: '2026-10', quarter: '2026-Q4', year: '2026' }, |
| 50 | + '2026-10-13': { day: '2026-10-13', week: '2026-W42', month: '2026-10', quarter: '2026-Q4', year: '2026' }, |
| 51 | + '2026-10-21': { day: '2026-10-21', week: '2026-W43', month: '2026-10', quarter: '2026-Q4', year: '2026' }, |
| 52 | + '2026-10-31': { day: '2026-10-31', week: '2026-W44', month: '2026-10', quarter: '2026-Q4', year: '2026' }, |
| 53 | + '2026-11-01': { day: '2026-11-01', week: '2026-W44', month: '2026-11', quarter: '2026-Q4', year: '2026' }, |
| 54 | + '2026-11-02': { day: '2026-11-02', week: '2026-W45', month: '2026-11', quarter: '2026-Q4', year: '2026' }, |
| 55 | + '2026-11-16': { day: '2026-11-16', week: '2026-W47', month: '2026-11', quarter: '2026-Q4', year: '2026' }, |
| 56 | + '2026-11-30': { day: '2026-11-30', week: '2026-W49', month: '2026-11', quarter: '2026-Q4', year: '2026' }, |
| 57 | + '2027-01-01': { day: '2027-01-01', week: '2026-W53', month: '2027-01', quarter: '2027-Q1', year: '2027' }, |
| 58 | +}; |
| 59 | + |
| 60 | +/** The empty bucket's label, chosen so no `String(value)` can produce it (#3839). */ |
| 61 | +const EMPTY = '‹empty bucket›'; |
| 62 | + |
| 63 | +/** |
| 64 | + * Ten deliveries in the shape of the door's fixture (six in October, four in |
| 65 | + * November, so the stored month and quarter counts are the door's expected |
| 66 | + * ones), plus a year-edge row and an unset day. |
| 67 | + */ |
| 68 | +const DUE_DATES: ReadonlyArray<string | null> = [ |
| 69 | + '2026-10-01', '2026-10-05', '2026-10-13', '2026-10-21', '2026-10-21', '2026-10-31', |
| 70 | + '2026-11-01', '2026-11-02', '2026-11-16', '2026-11-30', |
| 71 | + '2027-01-01', |
| 72 | + null, |
| 73 | +]; |
| 74 | + |
| 75 | +/** `{label: count}` per granularity, for the rows above, as literals. */ |
| 76 | +const EXPECTED: Readonly<Record<Granularity, Readonly<Record<string, number>>>> = { |
| 77 | + day: { |
| 78 | + '2026-10-01': 1, '2026-10-05': 1, '2026-10-13': 1, '2026-10-21': 2, '2026-10-31': 1, |
| 79 | + '2026-11-01': 1, '2026-11-02': 1, '2026-11-16': 1, '2026-11-30': 1, '2027-01-01': 1, [EMPTY]: 1, |
| 80 | + }, |
| 81 | + week: { |
| 82 | + '2026-W40': 1, '2026-W41': 1, '2026-W42': 1, '2026-W43': 2, '2026-W44': 2, |
| 83 | + '2026-W45': 1, '2026-W47': 1, '2026-W49': 1, '2026-W53': 1, [EMPTY]: 1, |
| 84 | + }, |
| 85 | + month: { '2026-10': 6, '2026-11': 4, '2027-01': 1, [EMPTY]: 1 }, |
| 86 | + quarter: { '2026-Q4': 10, '2027-Q1': 1, [EMPTY]: 1 }, |
| 87 | + year: { '2026': 10, '2027': 1, [EMPTY]: 1 }, |
| 88 | +}; |
| 89 | + |
| 90 | +const FIELDS = { |
| 91 | + due_date: { type: 'date' }, |
| 92 | + shipped_at: { type: 'datetime' }, |
| 93 | + n: { type: 'number' }, |
| 94 | +} as const; |
| 95 | + |
| 96 | +const ROWS = DUE_DATES.map((due_date, i) => ({ id: `d${i}`, due_date, n: 1 })); |
| 97 | + |
| 98 | +/** A negative-offset pair, the zero zone, and a positive-offset zone. */ |
| 99 | +const ZONES = ['America/New_York', 'America/Los_Angeles', 'UTC', 'Asia/Tokyo'] as const; |
| 100 | + |
| 101 | +function bucketAst(field: string, granularity: Granularity): EngineAggregateOptions { |
| 102 | + return { |
| 103 | + groupBy: [{ field, dateGranularity: granularity }], |
| 104 | + aggregations: [{ function: 'count', alias: 'n' }], |
| 105 | + }; |
| 106 | +} |
| 107 | + |
| 108 | +function labelCounts(rows: any[], field: string): Record<string, number> { |
| 109 | + const out: Record<string, number> = {}; |
| 110 | + for (const row of rows) { |
| 111 | + const v = row?.[field]; |
| 112 | + out[v == null ? EMPTY : String(v)] = Number(row?.n ?? 0); |
| 113 | + } |
| 114 | + return out; |
| 115 | +} |
| 116 | + |
| 117 | +describe('(a) the rows path buckets a declared `date` by its stored day under every zone (#24618)', () => { |
| 118 | + for (const tz of ZONES) { |
| 119 | + for (const granularity of GRANULARITIES) { |
| 120 | + it(`${granularity} under ${tz}`, () => { |
| 121 | + const out = applyInMemoryAggregation(ROWS, bucketAst('due_date', granularity), tz, FIELDS); |
| 122 | + expect(labelCounts(out, 'due_date')).toEqual(EXPECTED[granularity]); |
| 123 | + }); |
| 124 | + } |
| 125 | + } |
| 126 | + |
| 127 | + it('the door\'s expected month and quarter counts, under America/New_York', () => { |
| 128 | + const month = labelCounts( |
| 129 | + applyInMemoryAggregation(ROWS.slice(0, 10), bucketAst('due_date', 'month'), 'America/New_York', FIELDS), |
| 130 | + 'due_date', |
| 131 | + ); |
| 132 | + const quarter = labelCounts( |
| 133 | + applyInMemoryAggregation(ROWS.slice(0, 10), bucketAst('due_date', 'quarter'), 'America/New_York', FIELDS), |
| 134 | + 'due_date', |
| 135 | + ); |
| 136 | + expect(month).toEqual({ '2026-10': 6, '2026-11': 4 }); |
| 137 | + expect(quarter).toEqual({ '2026-Q4': 10 }); |
| 138 | + }); |
| 139 | + |
| 140 | + it('a `Date` value of a `date` field buckets by its UTC calendar day — the `date` storage rule', () => { |
| 141 | + // Some drivers hand a `date` back as a `Date` at that day's UTC midnight; |
| 142 | + // `temporalStorageForm(value, 'date')` reads it as its UTC calendar day. |
| 143 | + const asDates = ROWS.map((r) => ({ ...r, due_date: r.due_date === null ? null : new Date(`${r.due_date}T00:00:00.000Z`) })); |
| 144 | + for (const granularity of GRANULARITIES) { |
| 145 | + const out = applyInMemoryAggregation(asDates, bucketAst('due_date', granularity), 'America/New_York', FIELDS); |
| 146 | + expect(labelCounts(out, 'due_date')).toEqual(EXPECTED[granularity]); |
| 147 | + } |
| 148 | + }); |
| 149 | + |
| 150 | + it('an alias names the output column only — the declaration is read off `field`', () => { |
| 151 | + const out = applyInMemoryAggregation( |
| 152 | + ROWS, |
| 153 | + { |
| 154 | + groupBy: [{ field: 'due_date', dateGranularity: 'month', alias: 'due_month' }], |
| 155 | + aggregations: [{ function: 'count', alias: 'n' }], |
| 156 | + }, |
| 157 | + 'America/Los_Angeles', |
| 158 | + FIELDS, |
| 159 | + ); |
| 160 | + expect(labelCounts(out, 'due_month')).toEqual(EXPECTED.month); |
| 161 | + }); |
| 162 | +}); |
| 163 | + |
| 164 | +describe('(b) control: a declared `datetime` keeps the reference zone (#24618)', () => { |
| 165 | + // Two instants either side of the New York midnight that opens 2026-10-01 |
| 166 | + // (04:00Z, EDT −04), and one an hour into 2026-11-01 UTC. |
| 167 | + const SHIPPED = [ |
| 168 | + { id: 's1', shipped_at: '2026-10-01T02:00:00.000Z', n: 1 }, // NY 2026-09-30 22:00 |
| 169 | + { id: 's2', shipped_at: '2026-10-01T15:00:00.000Z', n: 1 }, // NY 2026-10-01 11:00 |
| 170 | + { id: 's3', shipped_at: '2026-11-01T01:00:00.000Z', n: 1 }, // NY 2026-10-31 21:00 |
| 171 | + ]; |
| 172 | + |
| 173 | + it('under America/New_York an instant lands on the New York calendar day', () => { |
| 174 | + const day = applyInMemoryAggregation(SHIPPED, bucketAst('shipped_at', 'day'), 'America/New_York', FIELDS); |
| 175 | + expect(labelCounts(day, 'shipped_at')).toEqual({ '2026-09-30': 1, '2026-10-01': 1, '2026-10-31': 1 }); |
| 176 | + const quarter = applyInMemoryAggregation(SHIPPED, bucketAst('shipped_at', 'quarter'), 'America/New_York', FIELDS); |
| 177 | + expect(labelCounts(quarter, 'shipped_at')).toEqual({ '2026-Q3': 1, '2026-Q4': 2 }); |
| 178 | + }); |
| 179 | + |
| 180 | + it('under UTC the same instants land on the UTC calendar day', () => { |
| 181 | + const day = applyInMemoryAggregation(SHIPPED, bucketAst('shipped_at', 'day'), 'UTC', FIELDS); |
| 182 | + expect(labelCounts(day, 'shipped_at')).toEqual({ '2026-10-01': 2, '2026-11-01': 1 }); |
| 183 | + }); |
| 184 | +}); |
| 185 | + |
| 186 | +describe('(c) engine.aggregate answers one set of labels for a `date` on both routes (#24618)', () => { |
| 187 | + /** |
| 188 | + * A driver advertising native bucketing at every granularity. Its native |
| 189 | + * `aggregate` is the pushdown route: it labels each row by the literal table |
| 190 | + * above — what a SQL face answers for a `date` column, where no zone is ever |
| 191 | + * applied — and counts how often it ran, so each case can assert the route. |
| 192 | + */ |
| 193 | + function makeEngine() { |
| 194 | + let nativeCalls = 0; |
| 195 | + const driver: any = { |
| 196 | + name: 'date-bucket-pushdown-double', |
| 197 | + version: '0.0.0', |
| 198 | + supports: { queryDateGranularity: { day: true, week: true, month: true, quarter: true, year: true } }, |
| 199 | + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, |
| 200 | + async find() { return ROWS.map((r) => ({ ...r })); }, |
| 201 | + async findOne() { return null; }, |
| 202 | + async create(_o: string, d: any) { return d; }, |
| 203 | + async update(_o: string, _id: string, d: any) { return d; }, |
| 204 | + async delete() { return true; }, |
| 205 | + async count() { return ROWS.length; }, |
| 206 | + async bulkCreate(_o: string, r: any[]) { return r; }, |
| 207 | + async bulkUpdate() { return []; }, async bulkDelete() {}, |
| 208 | + async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; }, |
| 209 | + async commit() {}, async rollback() {}, |
| 210 | + async aggregate(_object: string, ast: any) { |
| 211 | + nativeCalls += 1; |
| 212 | + const { field, dateGranularity } = ast.groupBy[0]; |
| 213 | + const counts = new Map<string | null, number>(); |
| 214 | + for (const r of ROWS) { |
| 215 | + const v = (r as Record<string, unknown>)[field] as string | null; |
| 216 | + const label = v == null ? null : STORED_DAY_LABELS[v][dateGranularity as Granularity]; |
| 217 | + counts.set(label, (counts.get(label) ?? 0) + 1); |
| 218 | + } |
| 219 | + return Array.from(counts, ([label, n]) => ({ [field]: label, n })); |
| 220 | + }, |
| 221 | + }; |
| 222 | + const engine = new ObjectQL(); |
| 223 | + engine.registerDriver(driver, true); |
| 224 | + return { engine, driver, nativeCalls: () => nativeCalls }; |
| 225 | + } |
| 226 | + |
| 227 | + for (const granularity of GRANULARITIES) { |
| 228 | + it(`${granularity}: the rows route under every non-UTC zone equals the pushdown route at UTC`, async () => { |
| 229 | + const { engine, nativeCalls } = makeEngine(); |
| 230 | + await engine.init(); |
| 231 | + engine.registry.registerObject({ name: 'delivery', fields: FIELDS } as any); |
| 232 | + |
| 233 | + const pushedDown = await engine.aggregate('delivery', { ...bucketAst('due_date', granularity), timezone: 'UTC' }); |
| 234 | + expect(nativeCalls()).toBe(1); // the pushdown route |
| 235 | + expect(labelCounts(pushedDown, 'due_date')).toEqual(EXPECTED[granularity]); |
| 236 | + |
| 237 | + for (const tz of ZONES.filter((z) => z !== 'UTC')) { |
| 238 | + const inMemory = await engine.aggregate('delivery', { ...bucketAst('due_date', granularity), timezone: tz }); |
| 239 | + expect(nativeCalls()).toBe(1); // a non-UTC zone forced the rows route |
| 240 | + expect(labelCounts(inMemory, 'due_date')).toEqual(labelCounts(pushedDown, 'due_date')); |
| 241 | + } |
| 242 | + }); |
| 243 | + } |
| 244 | +}); |
0 commit comments