Skip to content

Commit 01c0bae

Browse files
os-zhuangclaude
andauthored
fix(driver-memory): the analytics face compiles $notContains to a predicate that excludes rows (#5374) (#5445)
`MemoryAnalyticsService` mapped each cube operator to the NAME of a mingo operator, and the call site filled that name in as `matchStage[field] = {[name]: comparand}`. That shape can express "compare this field to this value" and nothing else, so the two operators that need to WRAP their comparand were pushed through it anyway. `notContains` -> `'$not'` became `{name: {$not: 'et'}}`. mingo's `$not` takes a regex or an operator expression; handed a bare scalar it constrains nothing, so the predicate was emitted, appeared in the pipeline, and passed the whole table: 3 rows where `find()` returns 2. A predicate that is emitted and inert is indistinguishable from a working one at the author's end — the same amplifying direction as #3948, arrived at a third way. #5345 ruled on operators with NO mapping, #5373 on the comparand ENCODING; this is a mapping pointing at the wrong target, and #5431 did not shrink it (that call site now receives a real value, which is orthogonal to what the operator layer does with it). Route: let the map return a STRUCTURE rather than an operator name, which the issue prefers and the code supports cleanly. `CUBE_OPERATOR_TO_MONGO_PREDICATE` holds a builder per operator that returns the whole `{$op: …}` object, so `notContains` can say `{$not: {$regex: …}}` and the CLASS of "this operator needs a structure and the table can only hold a name" is gone rather than this one instance. `$in`/`$nin`/`$lte`/`$exists`, which the call site had grown an `if` chain for, are ordinary rows in that table now. Measured on the issue's 3-row fixture, analytics vs `find()`: | where | before | after | find() | |----------------------------------|--------|-------|--------| | {name:{$notContains:'et'}} | 3 | 2 | 2 | | {name:{$notContains:'a'}} | 3 | 0 | 0 | | {name:{$contains:'a.p'}} | 1 | 0 | 0 | | {name:{$contains:'ALPHA'}} | 0 | 1 | 1 | | {name:{$notContains:'ALPHA'}} | 3 | 2 | 2 | | {made_at:{$contains:'<full ISO>'}}| 1 | 0 | 0 | | {code:{$in:[]}} | 3 | 0 | 0 | | {code:[]} | 3 | 0 | 0 | Three more defects at the same call site fall inside this fix and are closed with it, because writing a correct `notContains` requires settling each: - `contains` was the right operator with the comparand handed in RAW, so it was neither escaped (`.` matched any character) nor case-folded, while the live path escapes and matches `/…/i`. Leaving that would have made the two non-complementary in a new way — `alpha` would be in BOTH answers. The rule is now borrowed from the driver (new narrow `filterSubstringPattern`, alongside `filterComparandStorageForm`) rather than re-derived, per #5240. - An operand that is NOT a comparand went through the storage-form conversion anyway, so on a declared `datetime` column a `$contains` PATTERN was rewritten into canonical form and then matched rows `find()` does not match. The builder input carries both lists, the same split `normalizeFieldOperators` makes (#4047). - The call site's `values.length > 0` guard meant an empty `$in` emitted no predicate at all and widened to the whole table. A list operator taking the whole list has nothing to guard. The two items the issue flagged as unmeasured, settled: - `'inDateRange': '$gte'` compiles to NOTHING today — no `MONGO_TO_CUBE_OPERATOR` entry lowers to that name, `timeDimensions` never reaches this function, and both exits consume only `normalizeFilters` output. Dead, and wrong if it ever had been reached (a one-ended `>=` for a two-ended range, which its own comment conceded). Deleted, with the dead-and-inverted `'notSet': '$exists'` beside it. - `opMap[operator] || '$eq'` is unreachable for the same reason — but only until someone widens the vocabulary, which #5345 deliberately made a one-line edit to `MONGO_TO_CUBE_OPERATOR`. So it is not merely deleted: that table is `as const`, the predicate table is keyed by the operator union derived from it, and the widening edit now FAILS TO COMPILE until the predicate exists. The remaining throw is a totality floor, not a fallback. Tests go in the shared conformance file beside the #5345 shape table and the #5373 comparand-type table, as a third axis with the same invariant: agree with `find()`, or refuse. Plus the "declared = enforced" half — every operator `ANALYTICS_FILTER_CAPABILITIES` declares is driven through both faces and must agree, with a probe that must exclude at least one row, so an operator added to the vocabulary without a working lowering fails here instead of shipping a quietly wrong number. Reverting only the source change fails 14 of the new assertions. Out of scope, filed not fixed: #5440 (two operators on one field clobber each other — the `$match` assembly layer, still broken after this), #5442 (`flattenFilterCondition` spreads an array comparand for every operator), #5444 (the `generateSql` exit emits `LIKE 'et'` with no `%` wildcards — filed as a sub-issue of #5433, whose completion scope it falls inside). `operatorToSql` and `generateSql` are untouched. Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7 Co-authored-by: Claude <noreply@anthropic.com>
1 parent ed0d2aa commit 01c0bae

4 files changed

Lines changed: 494 additions & 57 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
"@objectstack/driver-memory": minor
3+
---
4+
5+
fix(driver-memory): the analytics (cube) face compiles `$notContains` to a predicate that actually excludes rows, instead of a bare mingo `{$not: 'x'}` that constrains nothing (#5374)
6+
7+
**This is an observable behaviour change on a shipped surface: widgets whose
8+
`where` carries `$notContains`, `$contains`, or an empty `$in` will show
9+
different — correct — numbers.** Every one of them moves in the same direction,
10+
from a wider row set to the rows actually asked for, because each of these
11+
defects made a predicate mean less than it says.
12+
13+
## What was happening
14+
15+
`MemoryAnalyticsService` mapped each cube operator to the NAME of a mingo
16+
operator, and the call site filled that name in as
17+
`matchStage[field] = {[name]: comparand}`. That shape can express "compare this
18+
field to this value" and nothing else, so the two operators that need to WRAP
19+
their comparand were pushed through it anyway:
20+
21+
| `where` | compiled `$match` | analytics | `find()` |
22+
|---|---|---|---|
23+
| `{name: {$notContains: 'et'}}` | `{name: {$not: 'et'}}` | **3** | 2 |
24+
| `{name: {$notContains: 'a'}}` | `{name: {$not: 'a'}}` | **3** | 0 |
25+
| `{name: {$contains: 'a.p'}}` | `{name: {$regex: 'a.p'}}` | **1** | 0 |
26+
| `{name: {$contains: 'ALPHA'}}` | `{name: {$regex: 'ALPHA'}}` | **0** | 1 |
27+
| `{code: {$in: []}}` | *(no predicate emitted)* | **3** | 0 |
28+
29+
- **`notContains``'$not'`.** mingo's `$not` takes a regex or an operator
30+
expression; handed a bare scalar it constrains nothing. The predicate was
31+
emitted, appeared in the pipeline, and passed the whole table. A predicate
32+
that is emitted and inert is indistinguishable from a working one at the
33+
author's end — the same amplifying direction as #3948, reached a third way.
34+
- **`contains``'$regex'`** was the right operator with the comparand handed
35+
in raw, so it was neither escaped (a `.` matched any character) nor
36+
case-folded, while the live query path escapes and matches `/…/i`. One
37+
`where`, two meanings, depending on which face read it (#5240).
38+
- **an empty `$in`** hit the call site's `values.length > 0` guard and emitted
39+
no predicate at all, so the query widened to the whole table where `find()`
40+
returned nothing.
41+
- **an operand that is not a comparand** — a `$contains` pattern, a `$exists`
42+
flag — went through the field's storage-form conversion anyway, so on a
43+
declared `datetime` column the PATTERN itself was rewritten into canonical
44+
form and then matched rows `find()` does not match (#4047).
45+
46+
## What changed
47+
48+
The operator table now holds a **predicate builder** per operator rather than an
49+
operator name, so `notContains` can say `{$not: {$regex: …}}` and the class of
50+
"this operator needs a structure and the table can only hold a name" is gone
51+
rather than this one instance of it. `$in` / `$nin` / `$lte` / `$exists`, which
52+
the call site had grown an `if` chain for, are ordinary rows in that table now.
53+
54+
The substring rule itself is **borrowed from the driver** (new narrow
55+
`InMemoryDriver.filterSubstringPattern`, alongside `filterComparandStorageForm`)
56+
instead of re-derived, so `contains` on the analytics face escapes and case-folds
57+
exactly as `find()` does and the two cannot drift apart again.
58+
59+
The `opMap[operator] || '$eq'` fallback — under which a misspelled or unmapped
60+
operator silently became an EQUALITY comparison — is gone. It was already
61+
unreachable after #5345 gated the vocabulary upstream, but only until someone
62+
widened that vocabulary, which #5345 deliberately made a one-line edit. The
63+
predicate table is keyed by the operator union derived from that same table, so
64+
the widening edit now **fails to compile** until the predicate exists.
65+
66+
Two dead entries were deleted with it: `'notSet': '$exists'` (unreachable, and
67+
inverted if it ever had been reached) and `'inDateRange': '$gte'` (unreachable,
68+
and a one-ended `>=` answer to a two-ended range — its own comment conceded
69+
"Will need special handling" and nothing implemented it).
70+
71+
## Not changed
72+
73+
The `generateSql()` exit is untouched. Its operator-layer defects are #5433,
74+
filed and deliberately not bundled.

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

Lines changed: 173 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,22 @@ import {
2626
* here the only way to widen what this face accepts, and makes forgetting to
2727
* add one a loud refusal rather than a wrong number.
2828
*
29-
* A row here means the face ATTEMPTS the operator, not that the predicate it
30-
* builds is correct — `$notContains` lowers to a bare mingo `{$not: 'x'}` that
31-
* constrains nothing (#5374). That one is out of #5345's scope (which ruled on
32-
* operators with NO mapping) and is filed rather than fixed here; do not read
33-
* this list as eleven operators known to work. The comparand half of that
34-
* caveat is closed: #5373 removed the `string[]` round-trip that lost booleans
35-
* and `null` (see {@link NormalizedCubeFilter}).
29+
* A row here used to mean only that the face ATTEMPTS the operator, not that the
30+
* predicate it builds is correct. Both halves of that caveat are now closed:
31+
* #5373 removed the `string[]` comparand round-trip that lost booleans and
32+
* `null` (see {@link NormalizedCubeFilter}), and #5374 replaced the
33+
* operator-name→operator-name mapping — under which `notContains` compiled to a
34+
* bare mingo `{$not: 'x'}` that constrains nothing — with
35+
* {@link CUBE_OPERATOR_TO_MONGO_PREDICATE}, which builds the whole predicate.
36+
*
37+
* The literal `as const` is load-bearing, not style: it makes
38+
* {@link CubeOperator} the exact union of this table's values, and that union is
39+
* the key type of the predicate table. Adding a row here without teaching the
40+
* compiler how to build its predicate is therefore a TYPE ERROR rather than a
41+
* wrong number — which is the whole point of #5345 keeping the gate's vocabulary
42+
* and the compiler's table as one statement.
3643
*/
37-
const MONGO_TO_CUBE_OPERATOR: Readonly<Record<string, string>> = Object.freeze({
44+
const MONGO_TO_CUBE_OPERATOR = Object.freeze({
3845
$eq: 'equals',
3946
$ne: 'notEquals',
4047
$gt: 'gt',
@@ -46,7 +53,14 @@ const MONGO_TO_CUBE_OPERATOR: Readonly<Record<string, string>> = Object.freeze({
4653
$contains: 'contains',
4754
$notContains: 'notContains',
4855
$exists: 'set',
49-
});
56+
} as const);
57+
58+
/**
59+
* [#5374] The cube-style operator names this face lowers into — exactly the
60+
* values of {@link MONGO_TO_CUBE_OPERATOR}, derived rather than restated so the
61+
* two cannot drift.
62+
*/
63+
type CubeOperator = (typeof MONGO_TO_CUBE_OPERATOR)[keyof typeof MONGO_TO_CUBE_OPERATOR];
5064

5165
/**
5266
* [#5345] What the analytics (cube) face compiles, for the shared filter walk.
@@ -99,7 +113,7 @@ export const ANALYTICS_FILTER_CAPABILITIES: FilterFaceCapabilities = Object.free
99113
*/
100114
interface NormalizedCubeFilter {
101115
member: string;
102-
operator: string;
116+
operator: CubeOperator;
103117
/**
104118
* The comparands, as authored. Temporal values are put into the field's
105119
* storage form at the exits ({@link MemoryAnalyticsService.comparandsFor}),
@@ -108,6 +122,117 @@ interface NormalizedCubeFilter {
108122
values: unknown[];
109123
}
110124

125+
/**
126+
* [#5374] What one lowered entry gives its predicate builder.
127+
*
128+
* Two comparand lists, not one, because the driver's own translation makes the
129+
* same split and for the same reason (#4047, `normalizeFieldOperators`): a
130+
* VALUE COMPARISON must be put into the field's storage form or mingo's
131+
* cross-type comparison drops every row, while an operand that is not a
132+
* comparand — a `$exists` flag, a `$regex` pattern — must NOT be, because
133+
* "storage form" is meaningless for it and applying it corrupts the operand.
134+
*
135+
* That was not hypothetical here. This face ran every operand through the
136+
* comparand conversion, so on a declared `datetime` column
137+
* `{made_at: {$contains: '2026-01-01T00:00:00Z'}}` had its PATTERN rewritten to
138+
* canonical `'2026-01-01T00:00:00.000Z'` and then matched the row, where
139+
* `find()` — which never rewrites a pattern — matched nothing.
140+
*/
141+
interface MongoPredicateInput {
142+
/** Comparands in the field's storage form (#4047). For value comparisons. */
143+
readonly comparands: readonly unknown[];
144+
/** The operands as authored. For operands that are not comparands. */
145+
readonly raw: readonly unknown[];
146+
/**
147+
* A comparand as a case-insensitive literal-substring pattern, built by the
148+
* DRIVER's own rule (`filterSubstringPattern`) rather than re-derived here.
149+
*/
150+
readonly substring: (value: unknown) => RegExp;
151+
}
152+
153+
type MongoPredicateBuilder = (input: MongoPredicateInput) => Record<string, unknown>;
154+
155+
/**
156+
* [#5374] How each cube operator becomes a mingo field predicate — the whole
157+
* `{$op: …}` object, not the name of an operator.
158+
*
159+
* # Why the shape changed
160+
*
161+
* This was `convertOperatorToMongo(operator): string`, a name→name map, and the
162+
* call site filled the name in as `matchStage[field] = {[name]: comparand}`.
163+
* That shape can express "compare this field to this value" and NOTHING else,
164+
* so the two entries that need to WRAP their comparand were forced through it
165+
* anyway:
166+
*
167+
* - `notContains` → `'$not'` became `{name: {$not: 'et'}}`. mingo's `$not`
168+
* takes a regex or an operator expression; given a bare scalar it
169+
* constrains nothing, so the predicate was emitted, looked present in the
170+
* pipeline, and passed the whole table (#5374: 3 rows where `find()`
171+
* returns 2). A predicate that is emitted and inert is indistinguishable
172+
* from a correct one at the author's end, and widens in the #3948
173+
* direction.
174+
* - `contains` → `'$regex'` became `{name: {$regex: 'a.p'}}` — the right
175+
* operator, but the comparand went in raw, so it was neither escaped nor
176+
* case-folded and meant something other than what `find()` means by it.
177+
*
178+
* A builder can say `{$not: {$regex: …}}`, so the class of "this operator needs
179+
* a structure and the table can only hold a name" is gone rather than this one
180+
* instance of it. `$in`/`$nin`/`$lte`/`$exists`, which the call site had grown
181+
* an `if` chain for, are ordinary rows here for the same reason.
182+
*
183+
* # Why it is a `Record<CubeOperator, …>`
184+
*
185+
* Because the missing-entry case had a `|| '$eq'` fallback, and a misspelled or
186+
* unmapped operator silently became an EQUALITY comparison — the exact
187+
* silent-wrong-answer shape #5345, #5373 and this issue have each been closing.
188+
* After #5345 that fallback was unreachable (`mongoOperatorToCubeOperator`
189+
* refuses anything not in {@link MONGO_TO_CUBE_OPERATOR}, and both exits consume
190+
* only `normalizeFilters` output), but only until someone widened the vocabulary
191+
* — which #5345 deliberately made a ONE-LINE edit to that table. Keying this
192+
* table by {@link CubeOperator} makes that edit fail to compile until the
193+
* predicate exists, so the fallback is not merely unreachable, it is
194+
* unnecessary: the totality is proven, not defended.
195+
*
196+
* Two entries were deleted rather than kept. `'notSet': '$exists'` and
197+
* `'inDateRange': '$gte'` were both unreachable (nothing lowers to either name)
198+
* and both wrong if they ever had been: the first inverts — the call site would
199+
* have compiled `notSet` to `{$exists: true}` — and the second answers a
200+
* two-ended range with a one-ended `>=`, which its own comment conceded ("Will
201+
* need special handling") and which nothing implemented. Dead code that is
202+
* ALSO wrong is a trap primed for whoever widens the vocabulary next; the type
203+
* error they now get instead says so at the only moment it helps.
204+
*/
205+
const CUBE_OPERATOR_TO_MONGO_PREDICATE: Readonly<Record<CubeOperator, MongoPredicateBuilder>> = Object.freeze({
206+
equals: ({ comparands }) => ({ $eq: comparands[0] }),
207+
notEquals: ({ comparands }) => ({ $ne: comparands[0] }),
208+
gt: ({ comparands }) => ({ $gt: comparands[0] }),
209+
gte: ({ comparands }) => ({ $gte: comparands[0] }),
210+
lt: ({ comparands }) => ({ $lt: comparands[0] }),
211+
// A bare-day `lte` bound means "through that whole day" (#4042; the SQL twin
212+
// is #3777): compile half-open so timestamp values on the final day stay in.
213+
// Order-equivalent to `$lte` for plain `YYYY-MM-DD` values.
214+
lte: ({ comparands }) => {
215+
const nextDay = nextUtcCalendarDay(comparands[0]);
216+
return nextDay != null ? { $lt: nextDay } : { $lte: comparands[0] };
217+
},
218+
// The list operators take the WHOLE list. An empty one is a real predicate —
219+
// `$in: []` selects nothing, `$nin: []` selects everything — and saying so
220+
// here is what retires the call site's `values.length > 0` guard, under which
221+
// `{code: {$in: []}}` emitted no predicate at all and answered with the whole
222+
// table while `find()` answered with none of it.
223+
in: ({ comparands }) => ({ $in: [...comparands] }),
224+
notIn: ({ comparands }) => ({ $nin: [...comparands] }),
225+
// A pattern, not a comparand: `raw`, and the driver's own substring rule.
226+
contains: ({ raw, substring }) => ({ $regex: substring(raw[0]) }),
227+
// The fix this issue is about. `{$not: <scalar>}` constrains nothing; the
228+
// negation has to wrap a pattern, which is exactly what the live query path
229+
// builds for `$notContains` (`memory-driver.ts` `normalizeFieldOperators`).
230+
notContains: ({ raw, substring }) => ({ $not: { $regex: substring(raw[0]) } }),
231+
// A presence flag, not a comparand. The `raw.length === 0` arm keeps the old
232+
// call site's reading of a valueless `set` ("does it exist" → true).
233+
set: ({ raw }) => ({ $exists: raw.length > 0 ? Boolean(raw[0]) : true }),
234+
});
235+
111236
/**
112237
* Configuration for MemoryAnalyticsService
113238
*/
@@ -180,34 +305,22 @@ export class MemoryAnalyticsService implements IAnalyticsService {
180305
if (normalizedFilters.length > 0) {
181306
const matchStage: Record<string, any> = {};
182307
for (const filter of normalizedFilters) {
183-
const mongoOp = this.convertOperatorToMongo(filter.operator);
184308
const fieldPath = this.resolveFieldPath(cube, filter.member);
185-
186-
if (filter.values && filter.values.length > 0) {
187-
// [#5373] The comparands as authored, in the storage form of the field
188-
// they are compared against. There is no type recovery step any more,
189-
// because there is no longer a stringification to recover FROM: a
190-
// boolean reaches mingo as a boolean and `null` as `null`, so a
191-
// predicate over `is_active` or `closed_at` selects the same rows
192-
// `find()` selects instead of none / all of them.
193-
const coerced = this.comparandsFor(cube, filter.member, filter.values);
194-
if (mongoOp === '$in') {
195-
matchStage[fieldPath] = { $in: coerced };
196-
} else if (mongoOp === '$nin') {
197-
matchStage[fieldPath] = { $nin: coerced };
198-
} else if (mongoOp === '$lte') {
199-
// A bare-day `lte` bound means "through that whole day" (#4042;
200-
// the SQL twin is #3777): compile half-open so timestamp values on
201-
// the final day stay in. Order-equivalent to `$lte` for plain
202-
// `YYYY-MM-DD` values.
203-
const nextDay = nextUtcCalendarDay(coerced[0]);
204-
matchStage[fieldPath] = nextDay != null ? { $lt: nextDay } : { $lte: coerced[0] };
205-
} else {
206-
matchStage[fieldPath] = { [mongoOp]: coerced[0] };
207-
}
208-
} else if (mongoOp === '$exists') {
209-
matchStage[fieldPath] = { $exists: filter.operator === 'set' };
210-
}
309+
// [#5374] The operator decides the WHOLE predicate, not just its name —
310+
// so `notContains` can say `{$not: {$regex: …}}` instead of being forced
311+
// into `{$not: <comparand>}`, which mingo reads as no constraint at all.
312+
//
313+
// [#5373] `comparands` are the values as authored, in the storage form
314+
// of the field they are compared against. There is no type recovery step
315+
// any more, because there is no longer a stringification to recover
316+
// FROM: a boolean reaches mingo as a boolean and `null` as `null`, so a
317+
// predicate over `is_active` or `closed_at` selects the same rows
318+
// `find()` selects instead of none / all of them.
319+
matchStage[fieldPath] = this.mongoPredicateBuilder(filter.operator)({
320+
comparands: this.comparandsFor(cube, filter.member, filter.values),
321+
raw: filter.values,
322+
substring: (value) => this.driver.filterSubstringPattern(value),
323+
});
211324
}
212325
if (Object.keys(matchStage).length > 0) {
213326
pipeline.push({ $match: matchStage });
@@ -624,8 +737,8 @@ export class MemoryAnalyticsService implements IAnalyticsService {
624737
* function with a synthesised `{'a.b': spec}` node the gate never saw, and
625738
* that is a real path to an unmapped operator. It used to `continue`.
626739
*/
627-
private mongoOperatorToCubeOperator(op: string, field: string, path: string): string {
628-
const cubeOp = MONGO_TO_CUBE_OPERATOR[op];
740+
private mongoOperatorToCubeOperator(op: string, field: string, path: string): CubeOperator {
741+
const cubeOp = (MONGO_TO_CUBE_OPERATOR as Record<string, CubeOperator | undefined>)[op];
629742
if (!cubeOp) throw uncompilableFieldOperatorError(op, field, path, ANALYTICS_FILTER_CAPABILITIES);
630743
return cubeOp;
631744
}
@@ -776,23 +889,27 @@ export class MemoryAnalyticsService implements IAnalyticsService {
776889
}
777890
}
778891

779-
private convertOperatorToMongo(operator: string): string {
780-
const opMap: Record<string, string> = {
781-
'equals': '$eq',
782-
'notEquals': '$ne',
783-
'contains': '$regex',
784-
'notContains': '$not',
785-
'gt': '$gt',
786-
'gte': '$gte',
787-
'lt': '$lt',
788-
'lte': '$lte',
789-
'in': '$in',
790-
'notIn': '$nin',
791-
'set': '$exists',
792-
'notSet': '$exists',
793-
'inDateRange': '$gte', // Will need special handling
794-
};
795-
return opMap[operator] || '$eq';
892+
/**
893+
* [#5374] The mingo predicate builder for one lowered operator.
894+
*
895+
* Total by construction: {@link CUBE_OPERATOR_TO_MONGO_PREDICATE} is keyed by
896+
* {@link CubeOperator}, and `filter.operator` IS a `CubeOperator`, so the
897+
* lookup cannot miss without a type error somewhere first. The throw is the
898+
* totality floor that keeps the old `|| '$eq'` from coming back — the two
899+
* tables drifting must fail loudly, never compile a filter into an equality
900+
* comparison nobody wrote. It is not a user-input path: everything the author
901+
* can get wrong was already refused by {@link ANALYTICS_FILTER_CAPABILITIES}.
902+
*/
903+
private mongoPredicateBuilder(operator: CubeOperator): MongoPredicateBuilder {
904+
const build = (CUBE_OPERATOR_TO_MONGO_PREDICATE as Record<string, MongoPredicateBuilder | undefined>)[operator];
905+
if (!build) {
906+
throw new Error(
907+
`[driver-memory] analytics face: no mingo predicate for cube operator '${operator}'. ` +
908+
`MONGO_TO_CUBE_OPERATOR and CUBE_OPERATOR_TO_MONGO_PREDICATE have drifted — ` +
909+
`add the missing builder rather than letting the operator compile to something else.`,
910+
);
911+
}
912+
return build;
796913
}
797914

798915
private operatorToSql(operator: string): string {

0 commit comments

Comments
 (0)