Skip to content

Commit 90d9dc7

Browse files
committed
fix(driver-memory): the cube lte row widens the authored bare day before storage form (#20661)
The mingo and SQL-echo lte rows asked nextUtcCalendarDay about the comparand already in storage form, which on a declared datetime field is the midnight instant the helper refuses to widen. Both rows now render one lteUpperBound decision taken from the authored value, and only the widened bound is converted (ADR-0053 D-E3). Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
1 parent fa0a4b6 commit 90d9dc7

1 file changed

Lines changed: 93 additions & 19 deletions

File tree

‎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)