|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#20661] The analytics (cube) face widens a bare-day `lte` BEFORE it puts the |
| 5 | + * bound into the field's storage form — ADR-0053 D-E3's order, the one `find()` |
| 6 | + * already follows (`memory-driver.ts` widens `val`, then converts `nextDay`). |
| 7 | + * |
| 8 | + * Both `lte` rows (the mingo `$match` and the SQL echo) used to ask |
| 9 | + * `nextUtcCalendarDay` about the comparand AFTER the conversion. On an |
| 10 | + * undeclared field the storage form is the authored string, so the day |
| 11 | + * widened. On a DECLARED `datetime` field it is the instant |
| 12 | + * `2026-07-28T00:00:00.000Z`, which the helper correctly refuses to widen, so |
| 13 | + * the cube compiled `<= '2026-07-28T00:00:00.000Z'` and dropped the rest of the |
| 14 | + * named day — one row where `find()` on the same filter answers two. |
| 15 | + * |
| 16 | + * Every case drives both exits and `find()` on the same data, for an |
| 17 | + * undeclared field and for the same field declared through `syncSchema`. |
| 18 | + */ |
| 19 | + |
| 20 | +import { describe, it, expect } from 'vitest'; |
| 21 | +import type { Cube, FilterCondition } from '@objectstack/spec/data'; |
| 22 | +import { InMemoryDriver } from './memory-driver.js'; |
| 23 | +import { MemoryAnalyticsService } from './memory-analytics.js'; |
| 24 | + |
| 25 | +const ids = (rows: ReadonlyArray<Record<string, unknown>>) => rows.map((r) => String(r.id)).sort(); |
| 26 | + |
| 27 | +/** The card's two rows: the named day's morning, and the day before. */ |
| 28 | +const CARD_ROWS = [ |
| 29 | + { id: 'r28', created_at: '2026-07-28T10:00:00.000Z', made_on: '2026-07-28' }, |
| 30 | + { id: 'r27', created_at: '2026-07-27T10:00:00.000Z', made_on: '2026-07-27' }, |
| 31 | +] as const; |
| 32 | + |
| 33 | +const CUBE = { |
| 34 | + name: 'tasks', |
| 35 | + title: 'Tasks', |
| 36 | + sql: 'task', |
| 37 | + measures: { count: { label: 'Count', type: 'count', sql: 'id' } }, |
| 38 | + dimensions: { |
| 39 | + id: { label: 'Id', type: 'string', sql: 'id' }, |
| 40 | + created_at: { label: 'Created', type: 'time', sql: 'created_at' }, |
| 41 | + made_on: { label: 'Made on', type: 'time', sql: 'made_on' }, |
| 42 | + }, |
| 43 | +} as unknown as Cube; |
| 44 | + |
| 45 | +type Declaration = 'undeclared' | 'declared'; |
| 46 | + |
| 47 | +async function setup(declaration: Declaration, rows: ReadonlyArray<Record<string, unknown>> = CARD_ROWS) { |
| 48 | + const driver = new InMemoryDriver({}); |
| 49 | + await driver.connect(); |
| 50 | + if (declaration === 'declared') { |
| 51 | + await driver.syncSchema('task', { |
| 52 | + name: 'task', |
| 53 | + fields: { created_at: { type: 'datetime' }, made_on: { type: 'date' } }, |
| 54 | + }); |
| 55 | + } |
| 56 | + for (const row of rows) await driver.create('task', { ...row }); |
| 57 | + const service = new MemoryAnalyticsService({ driver, cubes: [CUBE] }); |
| 58 | + return { driver, service }; |
| 59 | +} |
| 60 | + |
| 61 | +const cubeQuery = (where: FilterCondition) => |
| 62 | + ({ cube: 'tasks', measures: ['count'], dimensions: ['id'], where }) as any; |
| 63 | + |
| 64 | +/** The echo's WHERE clause alone. */ |
| 65 | +const whereOf = (sql: string) => /WHERE (.*?)(?: GROUP BY|$)/.exec(sql)?.[1]; |
| 66 | + |
| 67 | +/** |
| 68 | + * One `where` on all three readings: `find()`, the cube's rows, and the echo. |
| 69 | + * The ids are asserted twice on purpose — equal to `find()` (the invariant) and |
| 70 | + * equal to the literal list (so the two cannot be wrong together). |
| 71 | + */ |
| 72 | +async function answer(declaration: Declaration, where: FilterCondition, rows?: ReadonlyArray<Record<string, unknown>>) { |
| 73 | + const { driver, service } = await setup(declaration, rows); |
| 74 | + return { |
| 75 | + find: ids(await driver.find('task', { where })), |
| 76 | + cube: ids((await service.query(cubeQuery(where))).rows), |
| 77 | + echo: whereOf((await service.generateSql(cubeQuery(where))).sql), |
| 78 | + }; |
| 79 | +} |
| 80 | + |
| 81 | +describe('[#20661] cube `lte` on a bare day widens the authored day, then converts the bound', () => { |
| 82 | + const where: FilterCondition = { created_at: { $lte: '2026-07-28' } }; |
| 83 | + |
| 84 | + it('undeclared `created_at`: both rows, the half-open bound at the next day', async () => { |
| 85 | + const got = await answer('undeclared', where); |
| 86 | + expect(got.cube).toEqual(got.find); |
| 87 | + expect(got.cube).toEqual(['r27', 'r28']); |
| 88 | + expect(got.echo).toBe("created_at < '2026-07-29'"); |
| 89 | + }); |
| 90 | + |
| 91 | + it('declared `datetime` `created_at`: the same rows as find(), the bound in storage form', async () => { |
| 92 | + const got = await answer('declared', where); |
| 93 | + expect(got.cube).toEqual(got.find); |
| 94 | + // Before the fix: ['r27'], echoed as `created_at <= '2026-07-28T00:00:00.000Z'`. |
| 95 | + expect(got.cube).toEqual(['r27', 'r28']); |
| 96 | + expect(got.echo).toBe("created_at < '2026-07-29T00:00:00.000Z'"); |
| 97 | + }); |
| 98 | + |
| 99 | + it('a full timestamp keeps instant semantics: inclusive, in storage form, not widened', async () => { |
| 100 | + const instant: FilterCondition = { created_at: { $lte: '2026-07-28T10:00:00Z' } }; |
| 101 | + const got = await answer('declared', instant); |
| 102 | + expect(got.cube).toEqual(got.find); |
| 103 | + expect(got.cube).toEqual(['r27', 'r28']); |
| 104 | + expect(got.echo).toBe("created_at <= '2026-07-28T10:00:00.000Z'"); |
| 105 | + }); |
| 106 | + |
| 107 | + it('declared `date` control: a bare day is its own storage form, so nothing moves', async () => { |
| 108 | + const dateWhere: FilterCondition = { made_on: { $lte: '2026-07-28' } }; |
| 109 | + for (const declaration of ['undeclared', 'declared'] as const) { |
| 110 | + const got = await answer(declaration, dateWhere); |
| 111 | + expect(got.cube, declaration).toEqual(got.find); |
| 112 | + expect(got.cube, declaration).toEqual(['r27', 'r28']); |
| 113 | + expect(got.echo, declaration).toBe("made_on < '2026-07-29'"); |
| 114 | + } |
| 115 | + }); |
| 116 | + |
| 117 | + it('declared `datetime` on the last supported day asks only for a value; the day before is a bound', async () => { |
| 118 | + const rows = [ |
| 119 | + { id: 'c26', created_at: '2026-07-15T14:00:00.000Z' }, |
| 120 | + { id: 'prev', created_at: '9999-12-30T10:00:00.000Z' }, |
| 121 | + { id: 'mid', created_at: '9999-12-31T10:00:00.000Z' }, |
| 122 | + { id: 'last', created_at: '9999-12-31T23:59:59.999Z' }, |
| 123 | + ]; |
| 124 | + const lastDay = await answer('declared', { created_at: { $lte: '9999-12-31' } }, rows); |
| 125 | + expect(lastDay.cube).toEqual(lastDay.find); |
| 126 | + // Before the fix: ['c26', 'prev'], echoed as `created_at <= '9999-12-31T00:00:00.000Z'`. |
| 127 | + expect(lastDay.cube).toEqual(['c26', 'last', 'mid', 'prev']); |
| 128 | + expect(lastDay.echo).toBe('created_at IS NOT NULL'); |
| 129 | + |
| 130 | + const dayBefore = await answer('declared', { created_at: { $lte: '9999-12-30' } }, rows); |
| 131 | + expect(dayBefore.cube).toEqual(dayBefore.find); |
| 132 | + expect(dayBefore.cube).toEqual(['c26', 'prev']); |
| 133 | + expect(dayBefore.echo).toBe("created_at < '9999-12-31T00:00:00.000Z'"); |
| 134 | + }); |
| 135 | +}); |
| 136 | + |
| 137 | +describe('[#20661] the siblings on this face', () => { |
| 138 | + it('a `dateRange` bare-day end already widens the authored end: declared `datetime` keeps the whole day', async () => { |
| 139 | + const { driver, service } = await setup('declared'); |
| 140 | + const result = await service.query({ |
| 141 | + cube: 'tasks', |
| 142 | + measures: ['count'], |
| 143 | + dimensions: ['id'], |
| 144 | + timeDimensions: [{ dimension: 'created_at', dateRange: ['2026-07-01', '2026-07-28'] }], |
| 145 | + } as any); |
| 146 | + const found = ids(await driver.find('task', { where: { created_at: { $gte: '2026-07-01', $lte: '2026-07-28' } } })); |
| 147 | + expect(ids(result.rows)).toEqual(found); |
| 148 | + expect(ids(result.rows)).toEqual(['r27', 'r28']); |
| 149 | + }); |
| 150 | + |
| 151 | + it('`$between` is refused on both exits, so its maximum never reaches a bound here', async () => { |
| 152 | + // Not a lowering this face owns: widening `MONGO_TO_CUBE_OPERATOR` to take |
| 153 | + // `$between` turns this red, and its maximum then owes the same whole-day |
| 154 | + // order as the `lte` rows (`lteUpperBound`). |
| 155 | + const { service } = await setup('declared'); |
| 156 | + const between = cubeQuery({ created_at: { $between: ['2026-07-01', '2026-07-28'] } }); |
| 157 | + for (const exit of [() => service.query(between), () => service.generateSql(between)]) { |
| 158 | + const err = await exit().then( |
| 159 | + () => undefined, |
| 160 | + (e: unknown) => e as { code?: unknown; status?: unknown }, |
| 161 | + ); |
| 162 | + expect(err).toMatchObject({ code: 'INVALID_FILTER', status: 400 }); |
| 163 | + } |
| 164 | + }); |
| 165 | +}); |
0 commit comments