Skip to content

Commit b3a2318

Browse files
os-zhuangclaude
andauthored
fix(driver-memory,driver-mongodb): bare-day upper bounds cover the whole day (#4042) (#4048)
The non-SQL half of #3777's calendar-day rule. Both drivers compiled a bare YYYY-MM-DD $lte (and a between max) as-is, so on timestamp values the window cut off at the final day's midnight — the dashboard date-range filter's default configuration lost the current day exactly as it did on SQL before #3777. Both drivers now compile a bare-day upper bound half-open, sharing nextUtcCalendarDay from @objectstack/core: - driver-memory: the Mongo-style and array where spellings in the mingo lowering ($lte/<= -> $lt next day; $between/between max the same), the analytics cube-filter lte, and the analytics dateRange window — which now also matches BOTH stored forms of a timestamp (ISO strings and Date objects) instead of only Dates, since mingo compares cross-type as never-equal (the $or of both spellings stands in for driver-sql's mixed-storage CASE repair). - driver-mongodb: the translateFilter lowering, all three spellings ($lte, $between, array <=/lte). Unchanged per the #3777 semantics table: full-ISO/Date comparands keep instant semantics; $gte/$gt/$lt keep their midnight anchoring. The remaining cross-type storage-form gap (JS/BSON Date values invisible to string comparands of every operator) is split out as #4047 — a storage-form problem, not a bound-semantics one. Tests: row-result probes on driver-memory (find: Mongo/array spellings, $between, $or, invariance; analytics window over a mixed-form column) and shape probes on the mongodb translator (all spellings, month rollover, instant not widened). Closes #4042 Claude-Session: https://claude.ai/code/session_01EkL3RGJrzjLEGURsLxfS2f Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3c628ce commit b3a2318

6 files changed

Lines changed: 309 additions & 18 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/driver-memory": patch
3+
"@objectstack/driver-mongodb": patch
4+
---
5+
6+
fix(driver-memory,driver-mongodb): a bare-day upper bound covers the whole day (#4042)
7+
8+
The non-SQL half of #3777's calendar-day rule. Both drivers compiled a bare
9+
`YYYY-MM-DD` `$lte` (and a `between` max) as-is, so on timestamp values the
10+
window cut off at the final day's midnight — the dashboard date-range filter's
11+
default configuration (`created_at`, 7 of 13 presets ending "today") lost the
12+
current day, exactly as it did on SQL before #3777 was fixed.
13+
14+
Both drivers now compile a bare-day upper bound half-open, sharing
15+
`nextUtcCalendarDay` from `@objectstack/core`:
16+
17+
- `driver-memory`: the Mongo-style and array `where` spellings in the mingo
18+
lowering (`$lte`/`<=``$lt` next day; `$between`/`between` max the same),
19+
the analytics cube-filter `lte`, and the analytics `dateRange` window — which
20+
now also matches BOTH stored forms of a timestamp (ISO strings and `Date`
21+
objects) instead of only `Date`s, since mingo compares cross-type as
22+
never-equal.
23+
- `driver-mongodb`: the `translateFilter` lowering, all three spellings
24+
(`$lte`, `$between`, array `<=`/`lte`).
25+
26+
Unchanged on purpose, matching the #3777 semantics table: full-ISO/`Date`
27+
comparands keep instant semantics, and `$gte`/`$gt`/`$lt` keep their midnight
28+
anchoring. Known remaining gap (tracked separately): values stored as BSON
29+
`Date` (mongodb) or JS `Date` (memory `find()`) never match *string* comparands
30+
of any operator — a storage-form problem, not a bound-semantics one.

packages/plugins/driver-memory/src/memory-analytics.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import type { IAnalyticsService, AnalyticsResult, CubeMeta } from '@objectstack/spec/contracts';
44
import type { Cube, AnalyticsQuery } from '@objectstack/spec/data';
55
import type { InMemoryDriver } from './memory-driver.js';
6-
import { Logger, createLogger } from '@objectstack/core';
6+
import { Logger, createLogger, nextUtcCalendarDay } from '@objectstack/core';
77

88
/**
99
* Configuration for MemoryAnalyticsService
@@ -91,6 +91,13 @@ export class MemoryAnalyticsService implements IAnalyticsService {
9191
matchStage[fieldPath] = { $in: coerced };
9292
} else if (mongoOp === '$nin') {
9393
matchStage[fieldPath] = { $nin: coerced };
94+
} else if (mongoOp === '$lte') {
95+
// A bare-day `lte` bound means "through that whole day" (#4042;
96+
// the SQL twin is #3777): compile half-open so timestamp values on
97+
// the final day stay in. Order-equivalent to `$lte` for plain
98+
// `YYYY-MM-DD` values.
99+
const nextDay = nextUtcCalendarDay(coerced[0]);
100+
matchStage[fieldPath] = nextDay != null ? { $lt: nextDay } : { $lte: coerced[0] };
94101
} else {
95102
matchStage[fieldPath] = { [mongoOp]: coerced[0] };
96103
}
@@ -108,17 +115,39 @@ export class MemoryAnalyticsService implements IAnalyticsService {
108115
for (const timeDim of query.timeDimensions) {
109116
const fieldPath = this.resolveFieldPath(cube, timeDim.dimension);
110117
if (timeDim.dateRange) {
111-
const range = Array.isArray(timeDim.dateRange)
112-
? timeDim.dateRange
118+
const range = Array.isArray(timeDim.dateRange)
119+
? timeDim.dateRange
113120
: this.parseDateRangeString(timeDim.dateRange);
114-
121+
115122
if (range.length === 2) {
123+
// The window matches BOTH stored forms of a datetime value — the
124+
// in-memory table holds whatever the writer produced: `Date`
125+
// objects from direct JS callers AND ISO strings (the driver's own
126+
// `created_at` default, every REST/JSON write). Mingo compares
127+
// cross-type as never-equal, so a single-form bound silently
128+
// empties the other half — the same disease driver-sql's
129+
// mixed-storage CASE repair cures, expressed as the `$or` a
130+
// schemaless store allows.
131+
//
132+
// Both spellings are half-open on a bare-day end (#4042; the SQL
133+
// twin is #3777): a `$lte`-at-midnight upper bound dropped the
134+
// final day's rows for `Date` values and the string spelling
135+
// inherits `<= day`'s whole-day intent via `< nextDay`.
136+
const start = String(range[0]);
137+
const end = String(range[1]);
138+
const nextDay = nextUtcCalendarDay(end);
139+
const stringBounds = nextDay != null
140+
? { $gte: start, $lt: nextDay }
141+
: { $gte: start, $lte: end };
142+
const dateBounds = nextDay != null
143+
? { $gte: new Date(start), $lt: new Date(`${nextDay}T00:00:00.000Z`) }
144+
: { $gte: new Date(start), $lte: new Date(end) };
116145
pipeline.push({
117146
$match: {
118-
[fieldPath]: {
119-
$gte: new Date(range[0]),
120-
$lte: new Date(range[1])
121-
}
147+
$or: [
148+
{ [fieldPath]: stringBounds },
149+
{ [fieldPath]: dateBounds },
150+
],
122151
}
123152
});
124153
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Calendar-day upper bounds in the in-memory driver (#4042) — the
5+
* driver-memory half of #3777's rule: a bare `YYYY-MM-DD` used as an upper
6+
* bound (`$lte`, `<=`, a `between` max, a dateRange end) means "through that
7+
* whole day" and compiles half-open (`$lt` next day).
8+
*
9+
* Rows store ISO-string timestamps — the driver's OWN storage form (its
10+
* `created_at` default is `new Date().toISOString()`, and every REST/JSON
11+
* write arrives as a string). Mingo compares strings lexicographically, so
12+
* pre-fix `$lte: '2026-07-28'` cut the window at the midnight prefix and the
13+
* final day's rows vanished, exactly like the SQL drivers. (`Date`-object
14+
* rows are a separate storage-form problem — mingo compares cross-type as
15+
* never-equal for EVERY operator, `$gte` included — covered for the analytics
16+
* window below and tracked separately for `find()`.)
17+
*/
18+
19+
import { describe, it, expect, beforeEach } from 'vitest';
20+
import { InMemoryDriver } from './memory-driver.js';
21+
import { MemoryAnalyticsService } from './memory-analytics.js';
22+
import type { Cube } from '@objectstack/spec/data';
23+
24+
const ids = (rows: any[]) => rows.map((r: any) => r.id).sort();
25+
26+
describe('InMemoryDriver — bare-day $lte covers the whole day (#4042)', () => {
27+
let driver: InMemoryDriver;
28+
29+
beforeEach(async () => {
30+
driver = new InMemoryDriver({
31+
initialData: {
32+
task: [
33+
{ id: 't_midnight', title: 't_midnight', created_at: '2026-07-28T00:00:00.000Z' },
34+
{ id: 't_morning', title: 't_morning', created_at: '2026-07-28T09:15:00.000Z' },
35+
{ id: 't_evening', title: 't_evening', created_at: '2026-07-28T21:40:00.000Z' },
36+
{ id: 't_yesterday', title: 't_yesterday', created_at: '2026-07-27T14:00:00.000Z' },
37+
{ id: 't_old', title: 't_old', created_at: '2026-04-19T10:00:00.000Z' },
38+
],
39+
},
40+
});
41+
await driver.connect();
42+
});
43+
44+
it('keeps the whole final day — the dashboard default-config window', async () => {
45+
const found = await driver.find('task', {
46+
where: { created_at: { $gte: '2026-04-29', $lte: '2026-07-28' } },
47+
} as any);
48+
// Pre-fix: only [t_midnight, t_yesterday] — the 09:15 / 21:40 rows fell
49+
// past the midnight-anchored string prefix.
50+
expect(ids(found)).toEqual(['t_evening', 't_midnight', 't_morning', 't_yesterday']);
51+
});
52+
53+
it('a full-ISO $lte keeps instant semantics — only the bare day is widened', async () => {
54+
const found = await driver.find('task', {
55+
where: { created_at: { $lte: '2026-07-28T12:00:00.000Z' } },
56+
} as any);
57+
expect(ids(found)).toEqual(['t_midnight', 't_morning', 't_old', 't_yesterday']);
58+
});
59+
60+
it('$gte / $gt / $lt keep their midnight anchoring', async () => {
61+
const gte = await driver.find('task', { where: { created_at: { $gte: '2026-07-28' } } } as any);
62+
expect(ids(gte)).toEqual(['t_evening', 't_midnight', 't_morning']);
63+
64+
const gt = await driver.find('task', { where: { created_at: { $gt: '2026-07-28' } } } as any);
65+
expect(ids(gt)).toEqual(['t_evening', 't_midnight', 't_morning']); // string '…T00:00' > '2026-07-28'
66+
67+
const lt = await driver.find('task', { where: { created_at: { $lt: '2026-07-28' } } } as any);
68+
expect(ids(lt)).toEqual(['t_old', 't_yesterday']);
69+
});
70+
71+
it('$between with a bare-day max covers the whole final day', async () => {
72+
const found = await driver.find('task', {
73+
where: { created_at: { $between: ['2026-04-29', '2026-07-28'] } },
74+
} as any);
75+
expect(ids(found)).toEqual(['t_evening', 't_midnight', 't_morning', 't_yesterday']);
76+
});
77+
78+
it('stays correct inside an $or branch', async () => {
79+
const found = await driver.find('task', {
80+
where: {
81+
$or: [
82+
{ created_at: { $gte: '2026-07-28', $lte: '2026-07-28' } }, // "today" preset
83+
{ title: 't_old' },
84+
],
85+
},
86+
} as any);
87+
expect(ids(found)).toEqual(['t_evening', 't_midnight', 't_morning', 't_old']);
88+
});
89+
90+
it('applies to the array (`[field, op, value]`) where spelling too', async () => {
91+
const lte = await driver.find('task', {
92+
where: [['created_at', '>=', '2026-04-29'], 'and', ['created_at', '<=', '2026-07-28']],
93+
} as any);
94+
expect(ids(lte)).toEqual(['t_evening', 't_midnight', 't_morning', 't_yesterday']);
95+
96+
const between = await driver.find('task', {
97+
where: [['created_at', 'between', ['2026-04-29', '2026-07-28']]],
98+
} as any);
99+
expect(ids(between)).toEqual(['t_evening', 't_midnight', 't_morning', 't_yesterday']);
100+
});
101+
});
102+
103+
describe('MemoryAnalyticsService — dateRange window (#4042)', () => {
104+
const CUBE: Cube = {
105+
name: 'tasks',
106+
title: 'Tasks',
107+
sql: 'task',
108+
measures: {
109+
count: { name: 'count', label: 'Count', type: 'count', sql: 'id' },
110+
},
111+
dimensions: {
112+
created_at: { name: 'created_at', label: 'Created', type: 'time', sql: 'created_at' },
113+
},
114+
} as unknown as Cube;
115+
116+
it('keeps the final day for BOTH stored forms — ISO strings and Date objects', async () => {
117+
// One column, two writer forms: the driver's own ISO-string default and a
118+
// direct JS `Date`. The window must keep the final day's rows of each —
119+
// the $or-of-both-spellings that stands in for driver-sql's mixed-storage
120+
// CASE repair.
121+
const driver = new InMemoryDriver({
122+
initialData: {
123+
task: [
124+
{ id: 's_final_day', created_at: '2026-07-28T21:40:00.000Z' },
125+
{ id: 's_in_range', created_at: '2026-07-10T08:00:00.000Z' },
126+
{ id: 's_next_day', created_at: '2026-07-29T00:00:00.000Z' },
127+
{ id: 'd_final_day', created_at: new Date('2026-07-28T09:15:00Z') },
128+
{ id: 'd_in_range', created_at: new Date('2026-07-10T12:00:00Z') },
129+
{ id: 'd_next_day', created_at: new Date('2026-07-29T00:00:00Z') },
130+
{ id: 'd_old', created_at: new Date('2026-04-19T10:00:00Z') },
131+
],
132+
},
133+
});
134+
await driver.connect();
135+
const service = new MemoryAnalyticsService({ driver, cubes: [CUBE] });
136+
137+
const result = await service.query({
138+
cube: 'tasks',
139+
measures: ['count'],
140+
timeDimensions: [
141+
{ dimension: 'created_at', dateRange: ['2026-04-29', '2026-07-28'] },
142+
],
143+
} as any);
144+
145+
// 4 rows inside the window (2 per storage form, both final-day rows kept);
146+
// the two next-day-midnight rows and the pre-window Date row are out.
147+
expect(result.rows[0]?.count).toBe(4);
148+
});
149+
});

packages/plugins/driver-memory/src/memory-driver.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import type { QueryAST, QueryInput, DriverOptions } from '@objectstack/spec/data';
44
import { canonicalAstOperator } from '@objectstack/spec/data';
55
import type { IDataDriver } from '@objectstack/spec/contracts';
6-
import { Logger, createLogger } from '@objectstack/core';
6+
import { Logger, createLogger, nextUtcCalendarDay } from '@objectstack/core';
77
import { Query, Aggregator } from 'mingo';
88
import { getValueByPath } from './memory-matcher.js';
99

@@ -777,8 +777,15 @@ export class InMemoryDriver implements IDataDriver {
777777
return { [field]: { $gte: value } };
778778
case '<':
779779
return { [field]: { $lt: value } };
780-
case '<=':
781-
return { [field]: { $lte: value } };
780+
case '<=': {
781+
// A bare-day upper bound means "through that whole day" (#4042, the
782+
// driver-sql twin is #3777): `<= 2026-07-28` on an ISO-timestamp value
783+
// compiles half-open (`< 2026-07-29`), which is also order-equivalent
784+
// to `<=` for plain `YYYY-MM-DD` date values — so no field-type lookup
785+
// is needed, exactly the argument the preview evaluator uses.
786+
const nextDay = nextUtcCalendarDay(value);
787+
return { [field]: nextDay != null ? { $lt: nextDay } : { $lte: value } };
788+
}
782789
case 'in':
783790
return { [field]: { $in: value } };
784791
case 'nin': case 'not_in': case 'notin': case 'not in':
@@ -806,7 +813,13 @@ export class InMemoryDriver implements IDataDriver {
806813
return { [field]: { $ne: null } };
807814
case 'between':
808815
if (Array.isArray(value) && value.length === 2) {
809-
return { [field]: { $gte: value[0], $lte: value[1] } };
816+
// Bare-day max → half-open, inheriting `<=`'s whole-day rule (#4042).
817+
const nextDay = nextUtcCalendarDay(value[1]);
818+
return {
819+
[field]: nextDay != null
820+
? { $gte: value[0], $lt: nextDay }
821+
: { $gte: value[0], $lte: value[1] },
822+
};
810823
}
811824
throw new Error(
812825
`[driver-memory] "between" on field "${field}" needs a two-element array, got ` +
@@ -916,9 +929,21 @@ export class InMemoryDriver implements IDataDriver {
916929
case '$between':
917930
if (Array.isArray(val) && val.length === 2) {
918931
result.$gte = val[0];
919-
result.$lte = val[1];
932+
// Bare-day max → half-open, inheriting `$lte`'s whole-day rule (#4042).
933+
const betweenNextDay = nextUtcCalendarDay(val[1]);
934+
if (betweenNextDay != null) result.$lt = betweenNextDay;
935+
else result.$lte = val[1];
920936
}
921937
break;
938+
case '$lte': {
939+
// A bare-day upper bound means "through that whole day" (#4042; the
940+
// driver-sql twin is #3777). Order-equivalent to `<=` for plain
941+
// `YYYY-MM-DD` values, so it applies without a field-type lookup.
942+
const nextDay = nextUtcCalendarDay(val);
943+
if (nextDay != null) result.$lt = nextDay;
944+
else result.$lte = val;
945+
break;
946+
}
922947
case '$null':
923948
// $null: true → field is null, $null: false → field is not null
924949
// Use $eq/$ne null for Mingo compatibility

packages/plugins/driver-mongodb/src/mongodb-filter.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,44 @@ describe('MongoDB Filter Translator', () => {
4242
});
4343
});
4444

45+
describe('calendar-day upper bounds (#4042; the SQL twin is #3777)', () => {
46+
it('a bare-day $lte compiles half-open — through the whole day', () => {
47+
expect(translateFilter({ created_at: { $lte: '2026-07-28' } })).toEqual({
48+
created_at: { $lt: '2026-07-29' },
49+
});
50+
});
51+
52+
it('a full-ISO $lte keeps instant semantics — only the bare day is widened', () => {
53+
expect(translateFilter({ created_at: { $lte: '2026-07-28T12:00:00.000Z' } })).toEqual({
54+
created_at: { $lte: '2026-07-28T12:00:00.000Z' },
55+
});
56+
});
57+
58+
it('bare-day $gte / $gt / $lt keep their midnight anchoring', () => {
59+
expect(translateFilter({ created_at: { $gte: '2026-07-28' } })).toEqual({
60+
created_at: { $gte: '2026-07-28' },
61+
});
62+
expect(translateFilter({ created_at: { $lt: '2026-07-28' } })).toEqual({
63+
created_at: { $lt: '2026-07-28' },
64+
});
65+
});
66+
67+
it('$between with a bare-day max decomposes half-open, rolling the month', () => {
68+
expect(translateFilter({ created_at: { $between: ['2026-04-29', '2026-07-31'] } })).toEqual({
69+
created_at: { $gte: '2026-04-29', $lt: '2026-08-01' },
70+
});
71+
});
72+
73+
it('array-style `<=` takes the same rule', () => {
74+
expect(translateFilter([['created_at', '<=', '2026-07-28']])).toEqual({
75+
created_at: { $lt: '2026-07-29' },
76+
});
77+
expect(translateFilter([['created_at', '<=', '2026-07-28T12:00:00.000Z']])).toEqual({
78+
created_at: { $lte: '2026-07-28T12:00:00.000Z' },
79+
});
80+
});
81+
});
82+
4583
describe('string operators', () => {
4684
it('translates $contains to $regex', () => {
4785
const result = translateFilter({ name: { $contains: 'test' } });

0 commit comments

Comments
 (0)