Skip to content

Commit cb155ed

Browse files
committed
test(driver-memory): pin the cube lte whole-day order on undeclared and declared datetime (#20661)
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
1 parent 90d9dc7 commit cb155ed

1 file changed

Lines changed: 165 additions & 0 deletions

File tree

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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

Comments
 (0)