Skip to content

Commit d3f88fa

Browse files
fix(driver-memory): a cube $lte on a bare day keeps the whole day on a declared datetime field (#20661) (#20714)
Fixes #20661 Clause-②: no The memory analytics (cube) face now applies the whole-day rule for a bare-day `$lte` BEFORE it converts the bound to the field's storage form, as ADR-0053 D-E3 orders. On a declared `datetime` field, `where { created_at: { $lte: '2026-07-28' } }` now answers the same rows as `find()`, and the SQL echo shows the bound that runs. ## The change `packages/drivers/driver-memory/src/memory-analytics.ts`: - New module function `lteUpperBound(authored, comparand, storageForm)`. It asks `nextUtcCalendarDay` about the AUTHORED value and returns one of three decisions: `before` (a bare day: the next day's midnight, converted to storage form, exclusive), `unbounded` (`9999-12-31`, which has no next day), or `through` (anything else: the authored comparand in storage form, inclusive). - Both `lte` rows, the mingo `$match` row (`CUBE_OPERATOR_TO_MONGO_PREDICATE`) and the SQL echo row (`CUBE_OPERATOR_TO_SQL_PREDICATE`), now only render that one decision. They used to call `nextUtcCalendarDay(comparands[0])`, and `comparands` is already in storage form. On a declared `datetime` field that value is the instant `2026-07-28T00:00:00.000Z`, which the helper correctly refuses to widen. - The storage-form conversion still has one home. `comparandsFor` became `storageFormFor(cube, member)`, which returns the per-member conversion (still `driver.filterComparandStorageForm`). Each exit maps its comparands through it and passes the same function to its builder as `storageForm`, for the one bound a builder derives instead of receiving. `comparands` is still computed for every operator, so every conversion (and any refusal it raises) happens exactly as before. - `nextUtcCalendarDay` (`packages/spec` `calendar-day.ts`) is untouched. The driver's own filter path (`memory-driver.ts`) was already in this order (`nextUtcCalendarDay(val)`, then `store(nextDay)`) and is untouched. The landing site is the one the claim expected. The producer is the emitter, not `filterComparandStorageForm`: ADR-0053 D-D1 keeps the storage-form conversion operator-blind and puts the operator-sensitive rewrite on the emitters. ## Before and after, measured at the published export Built `@objectstack/driver-memory` `dist/index.mjs` (`MemoryAnalyticsService`, `InMemoryDriver`). Before = `origin/main` at `fa0a4b6610` (this branch's base; `e4e5222b7` plus one commit that does not touch this package). After = this branch. Rows `r28` `created_at: 2026-07-28T10:00:00.000Z` and `r27` `2026-07-27T10:00:00.000Z`. "Declared" means `syncSchema` with `created_at: datetime` and `made_on: date`. | field | `where` | `find()` | cube `query()` before | echo before | cube `query()` after | echo after | |:--|:--|:--|:--|:--|:--|:--| | `created_at` undeclared | `$lte '2026-07-28'` | r27, r28 | r27, r28 | `created_at < '2026-07-29'` | r27, r28 | unchanged | | `created_at` declared `datetime` | `$lte '2026-07-28'` | r27, r28 | **r27** | **`created_at <= '2026-07-28T00:00:00.000Z'`** | r27, r28 | `created_at < '2026-07-29T00:00:00.000Z'` | | `made_on` declared `date` (control) | `$lte '2026-07-28'` | r27, r28 | r27, r28 | `made_on < '2026-07-29'` | r27, r28 | unchanged | | `created_at` declared `datetime` | `$lte` a `Date` at `2026-07-28T00:00Z` (control) | r27 | r27 | `created_at <= '2026-07-28T00:00:00.000Z'` | r27 | unchanged | | `created_at` declared `datetime` | `$lte '9999-12-31'`, rows `c26` `2026-07-15T14:00Z`, `mid` `9999-12-31T10:00Z`, `none` | c26, mid, none | **c26, none** | **`created_at <= '9999-12-31T00:00:00.000Z'`** | c26, mid, none | `created_at IS NOT NULL` | | `created_at` undeclared | `$lte '9999-12-31'`, same rows | c26, mid, none | c26, mid, none | `created_at IS NOT NULL` | same | unchanged | In the last-day rows, `none` was written with `created_at: null`, and the driver's `create` stamps its `created_at` default in its place, so that row has a value. The pinned test uses no such row. ## Dispatch hypotheses - **H1, held.** At `fa0a4b6610`, both `lte` rows called `nextUtcCalendarDay(comparands[0])` (`:273`, `:468`). `comparands` came from `comparandsFor` (`:1555`, called at `:886` and `:1367`), and the builder context also carried `raw`. The fix widens from `raw[0]` and converts only the widened bound, through the same conversion the comparands take. It is one function that both rows render, so the two exits cannot drift. - **H2, held.** `$between` is not in `MONGO_TO_CUBE_OPERATOR`. Measured on both exits with a declared `datetime` field and a bare-day maximum: `INVALID_FILTER` / 400 ("declared by the Filter Protocol but cannot be compiled by driver-memory's analytics (cube) face"). No row added. The new test pins that refusal on the date shape, so widening the table to take `$between` turns it red and points at `lteUpperBound`. - **H3, held.** A `timeDimensions[].dateRange` end widens the raw `end` string before it builds the string and `Date` bounds (`:1005`), so it already follows D-E3. On a declared `datetime` field, `['2026-07-01', '2026-07-28']` answers r27, r28 before and after, the same as `find()` with `$gte` / `$lte`. Pinned, not changed. - **H4, held.** On a declared `date` field the storage form of a bare day is that bare day, and the answer and echo are byte-identical before and after (table above). Pinned. - **H5, held.** #20643's `isUnboundedAbove` path still answers "has a value". Its existing pins in `memory-driver-calendar-day-upper-bound.test.ts` (undeclared field) are green. On a DECLARED `datetime` field the last day was also broken by this card's order (`<= '9999-12-31T00:00:00.000Z'`, dropping `mid`). It now answers `IS NOT NULL` / `$ne: null` and is pinned. ## Verification Tests: new `packages/drivers/driver-memory/src/memory-analytics-20661-lte-whole-day-first.test.ts`, 7 cases. Every case compares the cube's rows with `find()` and with a literal id list, and checks the echo's `WHERE`: - the card's pair, undeclared and declared `datetime`; - a full-timestamp `$lte` (stays inclusive, in storage form); - the declared `date` control; - the declared-`datetime` last day and the day before; - the `dateRange` sibling; - the `$between` refusal on both exits, asserting the envelope `code` and `status`. - `pnpm --filter @objectstack/driver-memory test`: 63 files, 1456 tests passed. - `pnpm --filter @objectstack/driver-memory typecheck` (`tsc --noEmit && tsc --noEmit -p tsconfig.typecheck.json`): exit 0. `--listFiles` on the first program lists the new test file. Reverse verification. The fix was committed first (HEAD `cb155edf6c`). The subject is loaded from `src` through the test's relative import, so no `dist` is involved. The mutation went through `scripts/ablation-replace.mjs` under a `trap` restoring `HEAD`: the anchor `nextUtcCalendarDay(authored)` was replaced with `nextUtcCalendarDay(comparand)`, which is the old order. On disk the anchor went 1 to 0, the replacement 0 to 1, and the blob `014497594cc6` became `a41c37fac411`. Run over the new file and `memory-driver-calendar-day-upper-bound.test.ts`: 2 failed, 15 passed. The two failures were the declared-`datetime` pair (`expected [ 'r27' ] to deeply equal [ 'r27', 'r28' ]`) and the declared last day (`expected [ 'c26', 'prev' ] …`). The undeclared, `date`, instant, `dateRange`, `$between` and #20600 cases stayed green, which is the expected direction. Restore: the blob equals the `HEAD` blob `014497594cc6` and `git diff HEAD` is empty. Gates, at `8cae2ae398`: - `node scripts/pm/dispatch-gates.mjs --commands`: 60 commands, all exit 0. `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt` first exited 3 (PREREQUISITE NOT MET, no built closure). They were re-run green after `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'` (71 of 71 tasks). - `--ran` reconciliation: "60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN". Lint, narrowed and not the repo-wide run: - `eslint --no-inline-config --format json` over the 2 touched `.ts` files: 2 files, 0 errors, 0 warnings. - Both files are in eslint's own population: `--print-config` resolves a config for each, with 6 and 5 rules. - `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`; its own header says so at `:327`), so this diff cannot move the verdict for any untouched file. The `Clause-②: no` line above is the claim's. ## Acceptance notes - **Reach unchanged.** No in-repo door constructs `MemoryAnalyticsService`: `git grep 'new MemoryAnalyticsService('` finds 0 non-test hits against 31 in test files (the control). `service-analytics` `plugin.ts:245` mentions it in a comment as a registered fallback "from dev-plugin", but no code in this repo registers it. - **No other analytics or preview face has this order, by reading.** - `service-analytics` `native-sql-strategy.ts:1244` widens `values[0]` and then coerces `nextDay`. - `objectql-strategy.ts:548` widens the raw `$lte` bound. - The preview evaluator `lteBound` is type-blind and converts nothing. - `driver-mongodb` `mongodb-filter.ts:1165` widens `value` and then stores `nextDay`. - None of these was measured at runtime. - **Behind `origin/main` by 2 commits.** They were `9b384f63ae` (service-storage docs) and `5757463712` (platform-objects translations), with no path in `driver-memory`, `spec` or `core`. The branch was not merged forward; CI's merge ref covers them. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5757463 commit d3f88fa

3 files changed

Lines changed: 271 additions & 19 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/driver-memory': patch
3+
---
4+
5+
fix(driver-memory): a cube `where` `$lte` on a bare day keeps the whole day on a declared `datetime` field (#20661)
6+
7+
Clause-②: no
8+
9+
`MemoryAnalyticsService` put a `$lte` comparand into the field's storage form before it applied the whole-day rule for a bare-day upper bound. On a field declared `datetime` (through `syncSchema`) the storage form of `'2026-07-28'` is the instant `'2026-07-28T00:00:00.000Z'`, and the whole-day rule does not widen an instant. So `where: { created_at: { $lte: '2026-07-28' } }` compiled an inclusive bound at that midnight and dropped every row later in the named day, while `find()` with the same filter kept them. `generateSql()` echoed the same narrowed bound.
10+
11+
Both exits now follow ADR-0053's order: the bare day is widened first, and only the resulting bound is converted to the storage form. On a declared `datetime` field the example compiles `created_at < '2026-07-29T00:00:00.000Z'` and answers the same rows as `find()`. On `9999-12-31`, the last supported day, a declared `datetime` field now asks only for a value (`IS NOT NULL` in the echo), as an undeclared field already did.
12+
13+
Unchanged: an undeclared field, a declared `date` field, a full timestamp or `Date` comparand (inclusive, as written), and a `timeDimensions[].dateRange` end, which already widened the day before building its bounds. `$between` stays refused on this face (`INVALID_FILTER`, 400). Nothing is removed or renamed, and there is nothing to migrate.
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+
});

‎packages/drivers/driver-memory/src/memory-analytics.ts‎

Lines changed: 93 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ interface NormalizedCubeFilter {
154154
operator: CubeOperator;
155155
/**
156156
* The comparands, as authored. Temporal values are put into the field's
157-
* storage form at the exits ({@link MemoryAnalyticsService.comparandsFor}),
157+
* storage form at the exits ({@link MemoryAnalyticsService.storageFormFor}),
158158
* never here — that rule needs the resolved field path, which only an exit has.
159159
*/
160160
values: unknown[];
@@ -204,10 +204,65 @@ interface MongoPredicateInput {
204204
* Unicode range and would answer `CAFÉ` for `café`.
205205
*/
206206
readonly asciiSubstring: (value: unknown) => RegExp;
207+
/**
208+
* [#20661] One value put into the storage form of the field this entry
209+
* constrains — the SAME conversion `comparands` came out of
210+
* ({@link MemoryAnalyticsService.storageFormFor}), handed over for the one
211+
* value that is not an authored operand: a bound the builder DERIVES.
212+
* See {@link lteUpperBound}.
213+
*/
214+
readonly storageForm: (value: unknown) => unknown;
207215
}
208216

209217
type MongoPredicateBuilder = (input: MongoPredicateInput) => Record<string, unknown>;
210218

219+
/**
220+
* [#20661] What an `lte` bound compiles to, decided ONCE for both exits — the
221+
* mingo `$match` ({@link CUBE_OPERATOR_TO_MONGO_PREDICATE}) and the SQL echo
222+
* ({@link CUBE_OPERATOR_TO_SQL_PREDICATE}) only render it, so the rows a chart
223+
* is drawn from and the statement shown beside it cannot disagree on it.
224+
*
225+
* - `before` — a bare `YYYY-MM-DD` means the WHOLE day (#4042; the SQL twin is
226+
* #3777): the bound is the next day's midnight, exclusive.
227+
* - `unbounded` — the same on `9999-12-31`, which has no next day (#20600):
228+
* every value is inside the bound, so what is left to ask is a value.
229+
* - `through` — anything else keeps instant semantics, inclusive, compared
230+
* against the authored comparand in its storage form.
231+
*
232+
* ## ⛔ The order is the fix: widen the AUTHORED string, then convert the bound
233+
*
234+
* ADR-0053 D-E3: the calendar-day rewrite is a *calendar* operation and runs
235+
* on the bare-day STRING first; only the resulting bound is converted to the
236+
* storage form. Both rows used to ask {@link nextUtcCalendarDay} about
237+
* `comparands[0]`, which is ALREADY in storage form — on a declared `datetime`
238+
* field that is the instant `2026-07-28T00:00:00.000Z`, which the helper
239+
* correctly refuses to widen, so `$lte: '2026-07-28'` compiled an inclusive
240+
* bound at that midnight and dropped the rest of the day, while `find()` on the
241+
* same filter (which widens `val` and converts `nextDay`, `memory-driver.ts`)
242+
* kept it. On an undeclared field the storage form IS the authored string,
243+
* which is why only the declared case was wrong. ⛔ Never teach
244+
* `nextUtcCalendarDay` to widen an instant instead: it refuses one because an
245+
* instant already says where it stops.
246+
*
247+
* `comparand` is the authored value's storage form, passed in rather than
248+
* recomputed: it already exists, and the `through` arm is exactly it.
249+
*/
250+
type LteUpperBound =
251+
| { readonly kind: 'before'; readonly bound: unknown }
252+
| { readonly kind: 'unbounded' }
253+
| { readonly kind: 'through'; readonly bound: unknown };
254+
255+
function lteUpperBound(
256+
authored: unknown,
257+
comparand: unknown,
258+
storageForm: (value: unknown) => unknown,
259+
): LteUpperBound {
260+
const nextDay = nextUtcCalendarDay(authored);
261+
if (isUnboundedAbove(nextDay)) return { kind: 'unbounded' };
262+
if (nextDay != null) return { kind: 'before', bound: storageForm(nextDay) };
263+
return { kind: 'through', bound: comparand };
264+
}
265+
211266
/**
212267
* [#5374] How each cube operator becomes a mingo field predicate — the whole
213268
* `{$op: …}` object, not the name of an operator.
@@ -269,10 +324,13 @@ const CUBE_OPERATOR_TO_MONGO_PREDICATE: Readonly<Record<CubeOperator, MongoPredi
269324
// Order-equivalent to `$lte` for plain `YYYY-MM-DD` values.
270325
// [#20600] On the last supported day there is no next day: every value is
271326
// inside the bound, so what `lte` still asks is a value (the `set` row below).
272-
lte: ({ comparands }) => {
273-
const nextDay = nextUtcCalendarDay(comparands[0]);
274-
if (isUnboundedAbove(nextDay)) return { $ne: null };
275-
return nextDay != null ? { $lt: nextDay } : { $lte: comparands[0] };
327+
// [#20661] Decided from the AUTHORED value by {@link lteUpperBound}, which
328+
// the SQL twin shares — widening `comparands[0]` read an instant on a
329+
// declared `datetime` field and never widened at all.
330+
lte: ({ raw, comparands, storageForm }) => {
331+
const upper = lteUpperBound(raw[0], comparands[0], storageForm);
332+
if (upper.kind === 'unbounded') return { $ne: null };
333+
return upper.kind === 'before' ? { $lt: upper.bound } : { $lte: upper.bound };
276334
},
277335
// The list operators take the WHOLE list. An empty one is a real predicate —
278336
// `$in: []` selects nothing, `$nin: []` selects everything — and saying so
@@ -326,6 +384,11 @@ interface SqlPredicateInput {
326384
* See {@link globSubstringPattern} for why GLOB and not LIKE.
327385
*/
328386
readonly globSubstring: (value: unknown) => string;
387+
/**
388+
* [#20661] The storage-form conversion `comparands` came out of, for a bound
389+
* the builder derives — the twin of {@link MongoPredicateInput.storageForm}.
390+
*/
391+
readonly storageForm: (value: unknown) => unknown;
329392
}
330393

331394
type SqlPredicateBuilder = (input: SqlPredicateInput) => string;
@@ -464,12 +527,15 @@ const CUBE_OPERATOR_TO_SQL_PREDICATE: Readonly<Record<CubeOperator, SqlPredicate
464527
// which is measurable as an echo one row NARROWER than the chart it describes.
465528
// [#20600] …and `IS NOT NULL` on the last supported day, as the mingo row
466529
// above answers `$ne: null` there.
467-
lte: ({ column, comparands, literal }) => {
468-
const nextDay = nextUtcCalendarDay(comparands[0]);
469-
if (isUnboundedAbove(nextDay)) return `${column} IS NOT NULL`;
470-
return nextDay != null
471-
? `${column} < ${literal(nextDay)}`
472-
: `${column} <= ${literal(comparands[0])}`;
530+
// [#20661] The same {@link lteUpperBound} decision the mingo row renders, so
531+
// on a declared `datetime` field the echo reads `< '2026-07-29T00:00:00.000Z'`
532+
// where it used to read `<= '2026-07-28T00:00:00.000Z'`.
533+
lte: ({ column, raw, comparands, storageForm, literal }) => {
534+
const upper = lteUpperBound(raw[0], comparands[0], storageForm);
535+
if (upper.kind === 'unbounded') return `${column} IS NOT NULL`;
536+
return upper.kind === 'before'
537+
? `${column} < ${literal(upper.bound)}`
538+
: `${column} <= ${literal(upper.bound)}`;
473539
},
474540
// The list operators take the WHOLE list, and an EMPTY one is a real
475541
// predicate on this side too — `$in: []` selects nothing, `$nin: []`
@@ -628,7 +694,7 @@ function numericAggregandExpr(path: string): Record<string, unknown> {
628694
*
629695
* Every other value on this path already renders faithfully, measured rather
630696
* than assumed: a `Date` comparand is canonicalized to an ISO string by
631-
* {@link MemoryAnalyticsService.comparandsFor} before it reaches here, and
697+
* {@link MemoryAnalyticsService.storageFormFor} before it reaches here, and
632698
* `toJSON` runs BEFORE a replacer in any case, so dates are unchanged. A
633699
* `BigInt` comparand does throw — but out of mingo's own `Query.compile` during
634700
* EXECUTION, before this dump is ever built, so no replacer here reaches it.
@@ -882,9 +948,11 @@ export class MemoryAnalyticsService implements IAnalyticsService {
882948
// FROM: a boolean reaches mingo as a boolean and `null` as `null`, so a
883949
// predicate over `is_active` or `closed_at` selects the same rows
884950
// `find()` selects instead of none / all of them.
951+
const storageForm = this.storageFormFor(cube, filter.member);
885952
const predicate = this.mongoPredicateBuilder(filter.operator)({
886-
comparands: this.comparandsFor(cube, filter.member, filter.values),
953+
comparands: filter.values.map(storageForm),
887954
raw: filter.values,
955+
storageForm,
888956
substring: (value) => this.driver.filterSubstringPattern(value),
889957
// [#6520] `$icontains`' fold, from the spec's shared definition rather
890958
// than from the driver's Unicode-folding `filterSubstringPattern`.
@@ -1362,10 +1430,12 @@ export class MemoryAnalyticsService implements IAnalyticsService {
13621430
const normalizedFilters = this.normalizeFilters(query);
13631431
for (const filter of normalizedFilters) {
13641432
const fieldPath = this.resolveFieldPath(cube, filter.member);
1433+
const storageForm = this.storageFormFor(cube, filter.member);
13651434
whereClauses.push(this.sqlPredicateBuilder(filter.operator)({
13661435
column: fieldPath,
1367-
comparands: this.comparandsFor(cube, filter.member, filter.values),
1436+
comparands: filter.values.map(storageForm),
13681437
raw: filter.values,
1438+
storageForm,
13691439
literal: (value) => this.toSqlLiteral(value),
13701440
globSubstring: (value) => this.toSqlLiteral(globSubstringPattern(value)),
13711441
}));
@@ -1537,9 +1607,13 @@ export class MemoryAnalyticsService implements IAnalyticsService {
15371607
}
15381608

15391609
/**
1540-
* [#5373] The comparands of one lowered entry, in the storage form of the
1541-
* field they are compared against — the ONE place either exit converts a
1542-
* value, so the two exits cannot drift apart.
1610+
* [#5373] The conversion that puts a value into the storage form of the field
1611+
* one lowered entry is compared against — the ONE place either exit converts
1612+
* a value, so the two exits cannot drift apart. Each exit maps the entry's
1613+
* comparands through it, and hands the same function to its predicate builder
1614+
* for the one value a builder derives rather than receives: the whole-day
1615+
* bound of an `lte` (#20661, {@link lteUpperBound}), which has to be widened
1616+
* from the AUTHORED day before it is converted (ADR-0053 D-E3).
15431617
*
15441618
* The only conversion left is the temporal one (#4047): a `datetime` column
15451619
* holds canonical UTC ISO text, so a `Date` comparand has to become that text
@@ -1552,10 +1626,10 @@ export class MemoryAnalyticsService implements IAnalyticsService {
15521626
* boolean stays a boolean, `null` stays `null`, and a text column's `'100'`
15531627
* stays the string `'100'` instead of becoming the number `100`.
15541628
*/
1555-
private comparandsFor(cube: Cube, member: string, values: unknown[]): unknown[] {
1629+
private storageFormFor(cube: Cube, member: string): (value: unknown) => unknown {
15561630
const table = this.extractTableName(cube.sql);
15571631
const fieldPath = this.resolveFieldPath(cube, member);
1558-
return values.map(v => this.driver.filterComparandStorageForm(table, fieldPath, v));
1632+
return (value) => this.driver.filterComparandStorageForm(table, fieldPath, value);
15591633
}
15601634

15611635
/**

0 commit comments

Comments
 (0)