Skip to content

Commit 95fed33

Browse files
fix(driver-memory): the cube face lowers the FilterArray spelling of where instead of answering every row (#20944)
Fixes #20859 Clause-②: no (narrowing) ## What changed `MemoryAnalyticsService.normalizeFilters` (`packages/drivers/driver-memory/src/memory-analytics.ts`) read only a non-array object `where`. A `FilterArray` `where` therefore skipped the shared comparand doors, the shared lowering, the vocabulary gate and the flatten, so its predicate vanished. The array is now lowered at the method's entry by a module-private `lowerWhereFilterArray`, which calls the spec's own pair: `isFilterAST` gates the shape and `parseFilterAST` lowers it. That is the pair the analytics `where` door (`lowerAnalyticsWhere`, see #5334) and the engine's `lowerWhereFilterArray` call. The face adds no parser of its own. The lowered condition then enters the step-3 door (#20857) exactly as an authored object does. So both spellings meet the same comparand doors, the same `lowerFilterCondition` and the same gate, and both exits (`query()` and `generateSql()`) read the same normalized list. The three arrival answers are the analytics `where` door's: | arrival | answer | |---|---| | `[]` | no filter (unchanged: every row, no WHERE) | | an array `isFilterAST` accepts | lowered by `parseFilterAST`, then the door, the lowering and the gate, like the object spelling | | any other array (infix join, unknown operator, list of scalars, a cube-style entry list) | refused `INVALID_FILTER` / 400 on both exits | ## Measured On the fixture from the card (`d` is `'v1'`, `'v2'`, `null`, absent): | `where` | before (base `d78a0bda07`) | after (`ca8448c005`) | |---|---|---| | `{ d: 'v1' }` | rows 1; echo `d = 'v1'` | rows 1; echo `d = 'v1'` (control, unchanged) | | `[['d', '=', 'v1']]` | rows 1, 2, 3, 4; echo has no WHERE | rows 1; echo `d = 'v1'` | | `[]` | rows 1–4; no WHERE | rows 1–4; no WHERE (unchanged) | | `[['d', '=', 'v1'], 'or', ['d', '=', 'v2']]` (infix) | rows 1–4 | `INVALID_FILTER` / 400 | | `[['d', 'in', 'v1']]` (scalar `in`) | rows 1–4 | `INVALID_FILTER` / 400, as `{ d: { $in: 'v1' } }` is | ## Tests New pin file `packages/drivers/driver-memory/src/memory-analytics-filter-array-where.test.ts`, 16 cases: - the card's row, rows and echo; - seven spellings, each answering the rows and the echo of the `FilterCondition` it lowers to (a comparison, a flat list, an `and` group, an `or` group, a negative-polarity leaf, `is_null`, `is_not_null`); - the `[]` control; - four non-lowerable arrays refused on both exits; - three lowerable arrays that are refused exactly as their object spelling is refused. Refusals assert the envelope (`code` + `status`), not the prose. - Red first: at `62eff2a8f9` (pins only, source at the base), the file gave 15 failed, 1 passed. The one pass is the `[]` control. The card's row read rows `['1','2','3','4']` and echo `undefined`. - Green: 16 of 16 passed at `f2be8432cd`. - Ablation on the parse: `scripts/ablation-replace.mjs` replaced `const where = lowerWhereFilterArray((query as { where?: unknown }).where);` with the pre-fix read. The anchor went from 1 to 0, and the blob moved `cc0aaa764022` to `3445aece6953`. Result: 15 failed, 1 passed (the `[]` control). The restore was proven: the blob equals HEAD `cc0aaa764022`, and `git diff HEAD` is empty. The pins import `./memory-analytics.js` by relative path, and the package's vitest config aliases `@objectstack/*` to source, so no build sat between the mutation and the reading. - At `ca8448c005`, after `pnpm --filter '@objectstack/driver-memory...' build`, the `driver-memory` suite gave 66 files and 1429 tests passed. `typecheck` exited 0, and `tsc --noEmit --listFiles` includes the new test file. - Gates at `ca8448c005`: `dispatch-gates --commands` derived 60 families, and all 60 were run. - Three first answered PREREQUISITE NOT MET (exit 3): `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt`. After a full `turbo run build` (72 tasks) they were re-run, and all three passed. - `dispatch-gates --ran` reported 60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN. - The six roster families under touched directories were also run, and all passed: `check-changeset-fixed`, `authz-resolver`, `error-code-casing`, `filter-alias-parity`, `object-def-param-keys`, `tenant-chokepoint`. - Lint, narrowed and measured: - Population: `eslint.config.mjs`'s `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` block. - `eslint --no-inline-config --format json` over the two changed TypeScript files: 2 files, 0 errors, 0 warnings, at `ca8448c005`. - The config enables no type-aware linting, so no untouched file's verdict can move. - NOT MEASURED, left to CI: the path-scheduled CI jobs (Test Core shards, Dogfood, Temporal Conformance, Build Core) and the workspace type-check lanes. ## Acceptance notes - **Accept-set reading.** A well-formed array that answered every row now answers its predicate's rows. An array whose lowered condition the doors refuse is refused as that object spelling already is. One row goes beyond "a dropped predicate now filters": an array `isFilterAST` refuses (the infix join, an unknown operator, a list of scalars, a cube-style entry list) used to answer every row and now answers `INVALID_FILTER` / 400. None of those shapes is admitted by `AnalyticsQuerySchema.where` (`FilterConditionSchema`) or by `FilterArraySchema`, and the triage direction rules out dropping. Answered by the seat (5920030329, B, open to veto): the face's accept set narrows, as it did on PR #20857 (#5930 step 3) on this same face, so this PR takes `Clause-②: no (narrowing)`. The changeset is `minor` with a **BREAKING** paragraph and one ADR-0087 marker; the claim's "nothing is newly refused" was the seat's error. - On an array, the shared comparand-shape and comparand-type faces run twice: inside `parseFilterAST`, then at the door. Both are idempotent (an assertion, and a copy-on-write narrowing), so the verdict is the same one the first pass gives. - `normalizeFilters`' object branch still carries its `!Array.isArray(where)` test. After the lowering it is always true for anything that reaches it, and it is kept as the object branch's own statement of what it reads. - Unmeasured reading, not filed (carrier: none): the analytics `where` door's and the engine's non-lowerable-array refusals render the value with a bare `JSON.stringify`. A `bigint` inside such an array would make that call throw a raw `TypeError` instead of the envelope. This face's message renders through a non-throwing preview. No JSON wire can carry a `bigint`. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 75519e1 commit 95fed33

3 files changed

Lines changed: 289 additions & 4 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/driver-memory': minor
3+
---
4+
5+
`MemoryAnalyticsService` lowers the `FilterArray` spelling of `where` instead of dropping it
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable changes spelling or type: no spec key, export name or stored row moves. What changes is which `where` values one runtime face of this package answers: a `where` array that `isFilterAST` rejects, which `AnalyticsQuerySchema.where` (a `FilterConditionSchema`) never admitted, is now refused as the analytics `where` door and the query engine already refuse it, where it used to answer every row. Which filter the caller meant by such an array is not something a ledger entry can decide. The package publishes (not `unpublished`); no ADR-0087 id covers a runtime filter-shape refusal (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
10+
11+
An array `where` such as `[['stage', '=', 'won']]` used to skip every filter step of the
12+
analytics (cube) face: `query()` aggregated every row and `generateSql()` echoed no `WHERE`,
13+
while the object spelling `{ stage: 'won' }` answered its rows. The array is now lowered by
14+
`@objectstack/spec`'s `isFilterAST` / `parseFilterAST` — the lowering the analytics `where` door
15+
and the engine already apply — so both spellings answer the same rows and echo the same `WHERE`
16+
on both exits. `[]` still means no filter.
17+
18+
**BREAKING**: `query()` and `generateSql()` now refuse, with `INVALID_FILTER` / 400, a `where`
19+
array that is not a filter — one `isFilterAST` rejects. Each such array used to answer EVERY row
20+
and echo no `WHERE`; the analytics `where` door and the query engine refuse the same shapes.
21+
Measured on a fixture where `d` is `'v1'`, `'v2'`, `null` and absent, each of these answered all
22+
four rows:
23+
24+
- an infix join, `[['d', '=', 'v1'], 'or', ['d', '=', 'v2']]`;
25+
- an operator outside the filter-array vocabulary, `[['d', 'sounds_like', 'v1']]`;
26+
- a list of scalars, `[1, 2, 3]`;
27+
- a cube-style entry list, `[{ member: 'd', operator: 'equals', values: ['v1'] }]`.
28+
29+
An array that does lower but carries a comparand or operator the face refuses in its object
30+
spelling (`[['d', 'in', 'v1']]`, `[['d', 'starts_with', 'v']]`) is now refused as that object
31+
spelling is; it too used to answer every row.
32+
33+
The fix is to write the filter you mean: the prefix form `['or', condA, condB]` for an infix
34+
join, an operator from the filter-array vocabulary, or the `FilterCondition` object.
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The analytics (cube) face LOWERS the `FilterArray` spelling of `where`, as the
5+
* analytics `where` door does, through the same `@objectstack/spec` pair —
6+
* `isFilterAST` gates the shape, `parseFilterAST` lowers it — at the entry of
7+
* `normalizeFilters`, before the shared comparand doors and the shared lowering.
8+
* So both spellings of one filter meet the same doors and answer the same rows.
9+
*
10+
* Measured on `main` before this door, on the fixture below (`d` holds a value
11+
* on rows 1 and 2, is null on row 3, and is absent on row 4):
12+
*
13+
* | cube `where` | `query()` rows | `generateSql()` echo |
14+
* |-------------------------------|----------------|----------------------|
15+
* | `{d: 'v1'}` | 1 | `WHERE d = 'v1'` |
16+
* | `[['d', '=', 'v1']]` | 1, 2, 3, 4 | no `WHERE` at all |
17+
*
18+
* `normalizeFilters` read only a non-array object `where`, so the array skipped
19+
* the doors, the gate and the flatten, and its predicate vanished. Fewer
20+
* predicates means MORE rows, and a widened aggregate looks exactly like a
21+
* working one — the silent-widening class this face's gate already refuses for
22+
* the object spelling. The echo is the control that caught it: an aggregate
23+
* cannot tell you it read every row, but the echoed SQL can.
24+
*
25+
* The three arrival answers are the analytics `where` door's and the engine's:
26+
* `[]` is no filter; an array `isFilterAST` accepts is lowered; any other array
27+
* is REFUSED `INVALID_FILTER` / 400 on both exits — never every row.
28+
*/
29+
30+
import { describe, it, expect } from 'vitest';
31+
import type { Cube } from '@objectstack/spec/data';
32+
import { InMemoryDriver } from './memory-driver.js';
33+
import { MemoryAnalyticsService } from './memory-analytics.js';
34+
35+
const ids = (rows: ReadonlyArray<Record<string, unknown>>) => rows.map((r) => String(r.id)).sort();
36+
37+
/** The echo's WHERE clause alone; `undefined` when the echo carries none. */
38+
const whereOf = (sql: string) => /WHERE (.*?)(?: GROUP BY|$)/.exec(sql)?.[1];
39+
40+
const CUBE = {
41+
name: 'arr',
42+
title: 'Arr',
43+
sql: 'arr_row',
44+
measures: { count: { label: 'Count', type: 'count', sql: 'id' } },
45+
dimensions: Object.fromEntries(['id', 'd'].map((n) => [n, { label: n, type: 'string', sql: n }])),
46+
} as unknown as Cube;
47+
48+
/** `d`: a value on 1 and 2, null on 3, absent on 4. */
49+
const ROWS = [{ id: '1', d: 'v1' }, { id: '2', d: 'v2' }, { id: '3', d: null }, { id: '4' }];
50+
51+
async function setup() {
52+
const driver = new InMemoryDriver({});
53+
await driver.connect();
54+
for (const row of ROWS) await driver.create('arr_row', { ...(row as Record<string, unknown>) });
55+
return new MemoryAnalyticsService({ driver, cubes: [CUBE] });
56+
}
57+
58+
const cubeQuery = (where?: unknown) =>
59+
({ cube: 'arr', measures: ['count'], dimensions: ['id'], ...(where === undefined ? {} : { where }) }) as any;
60+
61+
/** The cube's rows and the echo's WHERE, for one `where`. */
62+
async function answer(where?: unknown) {
63+
const service = await setup();
64+
return {
65+
rows: ids((await service.query(cubeQuery(where))).rows),
66+
echo: whereOf((await service.generateSql(cubeQuery(where))).sql),
67+
};
68+
}
69+
70+
/** Both exits refuse `where` in the ADR-0112 `INVALID_FILTER` / 400 envelope. */
71+
async function expectRefusedOnBothExits(where: unknown) {
72+
const service = await setup();
73+
for (const [exit, run] of [
74+
['query', () => service.query(cubeQuery(where))],
75+
['generateSql', () => service.generateSql(cubeQuery(where))],
76+
] as const) {
77+
await expect(run(), `${exit}: ${JSON.stringify(where)} must be refused`).rejects.toMatchObject({
78+
code: 'INVALID_FILTER',
79+
status: 400,
80+
});
81+
}
82+
}
83+
84+
describe('the cube face lowers the FilterArray spelling of `where`', () => {
85+
it("the card's row: [['d','=','v1']] answers the object spelling's rows, and the echo carries its WHERE", async () => {
86+
const array = await answer([['d', '=', 'v1']]);
87+
const object = await answer({ d: 'v1' });
88+
// Soft, so a red run reports BOTH halves of the card's reading — the rows
89+
// and the echo — rather than stopping at the first.
90+
expect.soft(array.rows).toEqual(['1']);
91+
expect.soft(array.rows).toEqual(object.rows);
92+
expect.soft(array.echo).toBe("d = 'v1'");
93+
expect.soft(array.echo).toBe(object.echo);
94+
});
95+
96+
// Each row is one FilterArray spelling next to the FilterCondition it lowers
97+
// to. Both are fed to the face; they must answer the same rows and echo the
98+
// same WHERE — the array meets the comparand doors, the shared lowering and
99+
// the vocabulary gate exactly as the object spelling does.
100+
const SPELLINGS: Array<[string, unknown, unknown, string[]]> = [
101+
['a bare comparison', ['d', '=', 'v1'], { d: 'v1' }, ['1']],
102+
['a flat list (implicit AND)', [['d', '!=', 'v1'], ['d', '!=', 'v2']], { $and: [{ d: { $ne: 'v1' } }, { d: { $ne: 'v2' } }] }, ['3', '4']],
103+
['an "and" group', ['and', ['id', '!=', '1'], ['d', 'in', ['v1', 'v2']]], { $and: [{ id: { $ne: '1' } }, { d: { $in: ['v1', 'v2'] } }] }, ['2']],
104+
['an "or" group', ['or', ['d', '=', 'v1'], ['d', '=', 'v2']], { $or: [{ d: 'v1' }, { d: 'v2' }] }, ['1', '2']],
105+
// A negative-polarity leaf: the shared lowering puts the NULL escape
106+
// around it on this spelling as on the other.
107+
['a negative-polarity leaf', [['d', '!=', 'v1']], { d: { $ne: 'v1' } }, ['2', '3', '4']],
108+
// The null predicate takes its direction from the operator NAME.
109+
['is_null', [['d', 'is_null']], { d: { $null: true } }, ['3', '4']],
110+
['is_not_null', [['d', 'is_not_null']], { d: { $null: false } }, ['1', '2']],
111+
];
112+
for (const [label, array, object, expected] of SPELLINGS) {
113+
it(`${label}: ${JSON.stringify(array)} answers ${JSON.stringify(object)}'s rows and echo`, async () => {
114+
const a = await answer(array);
115+
const o = await answer(object);
116+
expect(a.rows).toEqual(expected);
117+
expect(a.rows).toEqual(o.rows);
118+
expect(a.echo).toBeDefined();
119+
expect(a.echo).toBe(o.echo);
120+
});
121+
}
122+
123+
it('CONTROL: `[]` is "no filter" — every row and no WHERE, as with no `where` at all', async () => {
124+
const empty = await answer([]);
125+
const none = await answer();
126+
expect(empty.rows).toEqual(['1', '2', '3', '4']);
127+
expect(empty.rows).toEqual(none.rows);
128+
expect(empty.echo).toBeUndefined();
129+
expect(none.echo).toBeUndefined();
130+
});
131+
});
132+
133+
describe('an array the face cannot lower is refused on both exits — never every row', () => {
134+
// Not a FilterArray at all: `isFilterAST` refuses the shape before
135+
// `parseFilterAST`'s lenient fallback can read it.
136+
const NOT_LOWERABLE: Array<[string, unknown]> = [
137+
['the infix join', [['d', '=', 'v1'], 'or', ['d', '=', 'v2']]],
138+
['an operator outside the vocabulary', [['d', 'sounds_like', 'v1']]],
139+
['a list of scalars', [1, 2, 3]],
140+
['a cube-style entry list', [{ member: 'd', operator: 'equals', values: ['v1'] }]],
141+
];
142+
for (const [label, where] of NOT_LOWERABLE) {
143+
it(`${label}: ${JSON.stringify(where)}`, async () => {
144+
await expectRefusedOnBothExits(where);
145+
});
146+
}
147+
148+
// Lowerable, but the lowered condition carries what the shared doors or this
149+
// face's gate refuse — refused as the object spelling of the same condition
150+
// is (the CONTROL half of each row).
151+
const REFUSED_AS_OBJECT: Array<[string, unknown, unknown]> = [
152+
['a scalar "in" comparand', [['d', 'in', 'v1']], { d: { $in: 'v1' } }],
153+
['an undefined comparand', [['d', '=', undefined]], { d: undefined }],
154+
['an operator this face does not compile', [['d', 'starts_with', 'v']], { d: { $startsWith: 'v' } }],
155+
];
156+
for (const [label, array, object] of REFUSED_AS_OBJECT) {
157+
it(`${label}: ${JSON.stringify(array)} is refused, as ${JSON.stringify(object)} is`, async () => {
158+
await expectRefusedOnBothExits(array);
159+
await expectRefusedOnBothExits(object);
160+
});
161+
}
162+
});

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

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ import {
1515
lowerFilterCondition,
1616
normalizeFilterComparandTypes,
1717
} from '@objectstack/spec/data';
18+
// The `FilterArray` spelling of `where`, lowered in front of that door by the
19+
// spec's ONE lowering of it — the pair the analytics `where` door and the engine
20+
// call: `isFilterAST` gates the shape, `parseFilterAST` lowers it. See
21+
// {@link lowerWhereFilterArray}. ⛔ No second FilterArray parser in this face.
22+
import { isFilterAST, parseFilterAST, VALID_AST_OPERATORS } from '@objectstack/spec/data';
1823
import type { InMemoryDriver } from './memory-driver.js';
1924
import {
2025
Logger,
@@ -51,6 +56,7 @@ import {
5156
assertFilterConditionShape,
5257
uncompilableCombinatorError,
5358
uncompilableFieldOperatorError,
59+
unsupportedFilterError,
5460
unsupportedTimeGranularityError,
5561
type FilterFaceCapabilities,
5662
} from './filter-refusal.js';
@@ -938,9 +944,86 @@ function explicitDateRangeWindow(dateRange: readonly unknown[]): [string, string
938944
return [start as string, end as string];
939945
}
940946

947+
/**
948+
* Lower the `FilterArray` spelling of a cube `where` to the `FilterCondition`
949+
* it spells, at the entry of {@link MemoryAnalyticsService.normalizeFilters} —
950+
* so both spellings of one filter meet the same comparand doors, the same
951+
* shared lowering and the same vocabulary gate after it.
952+
*
953+
* `AnalyticsQuery.where` is declared a `FilterCondition`; `FilterArray` is
954+
* input-only authoring sugar (`spec/data/filter.zod.ts`). This face used to
955+
* read only a non-array object `where`, so an array skipped every door and the
956+
* flatten, and its predicate vanished: `[['d', '=', 'v1']]` aggregated EVERY
957+
* row and the `generateSql()` echo carried no `WHERE`, while `{d: 'v1'}`
958+
* answered its row. Fewer predicates means more rows — the widening class the
959+
* vocabulary gate below already refuses for the object spelling.
960+
*
961+
* The analytics `where` door lowers the array spelling, and this face is an
962+
* analytics face, so it LOWERS rather than refuses — through the same spec pair
963+
* that door and the engine call, never a parser of its own. The three arrival
964+
* answers are theirs:
965+
*
966+
* 1. `[]` is "no filter", not a failed filter — `parseFilterAST([])` is
967+
* `undefined`, and every door reads it that way.
968+
* 2. An array `isFilterAST` accepts is lowered by `parseFilterAST`, which runs
969+
* the shared comparand-shape and comparand-type faces on what it returns;
970+
* `normalizeFilters` then runs its door on that condition as on any object.
971+
* 3. Any other array is REFUSED `INVALID_FILTER` / 400 — `isFilterAST` gates
972+
* first so `parseFilterAST`'s lenient `$${op}` fallback cannot turn a
973+
* misspelled operator into a condition nothing compiles, and so a shape it
974+
* has no lowering for (the infix join, a list of scalars, a cube-style
975+
* `{member, operator, values}` list) is never read as no filter at all.
976+
*
977+
* Anything that is not an array is returned as it came.
978+
*/
979+
function lowerWhereFilterArray(where: unknown): unknown {
980+
if (!Array.isArray(where)) return where;
981+
// (1) `[]` is "no filter".
982+
if (where.length === 0) return undefined;
983+
// (3) Not a shape `parseFilterAST` can express.
984+
if (!isFilterAST(where)) throw filterArrayNotLowerableError(where);
985+
// (2) The declared path.
986+
const condition = parseFilterAST(where);
987+
if (!condition || typeof condition !== 'object' || Array.isArray(condition)) {
988+
// Unreachable by construction — `isFilterAST` accepted the shape, so
989+
// `parseFilterAST` has a lowering for it. Loud rather than silent: the
990+
// failure mode of the two spec functions disagreeing is a dropped
991+
// predicate, i.e. every row.
992+
throw unsupportedFilterError(
993+
`The analytics (cube) face received the filter array ${previewFilterArray(where)}, which ` +
994+
`isFilterAST() accepted and parseFilterAST() lowered to ${previewFilterArray(condition)}. ` +
995+
`It is refused rather than aggregating the UNFILTERED rows.`,
996+
);
997+
}
998+
return condition;
999+
}
1000+
1001+
/** A `where` array this face cannot lower — the analytics `where` door's refusal, in this face's words. */
1002+
function filterArrayNotLowerableError(where: readonly unknown[]): Error {
1003+
return unsupportedFilterError(
1004+
`The analytics (cube) face received a 'where' array that is not a filter: ` +
1005+
`${previewFilterArray(where)}. A filter array is a comparison [field, operator, value], a ` +
1006+
`logical node ["and"|"or", ...conditions], or a list of those — it is INPUT-ONLY sugar (spec ` +
1007+
`'FilterArray'), lowered to a FilterCondition by @objectstack/spec parseFilterAST() at every ` +
1008+
`door, this one included. This value cannot be lowered, and an unapplied filter would have ` +
1009+
`aggregated the UNFILTERED rows. Recognised operators: ` +
1010+
`${[...VALID_AST_OPERATORS].sort().join(', ')}. Infix joins ([condA, "or", condB]) are NOT ` +
1011+
`one of the shapes — write the prefix form ["or", condA, condB].`,
1012+
);
1013+
}
1014+
1015+
/** The refused value for a message — never a second throw (a `bigint` has no JSON). */
1016+
function previewFilterArray(value: unknown): string {
1017+
try {
1018+
return JSON.stringify(value) ?? String(value);
1019+
} catch {
1020+
return Array.isArray(value) ? 'an array' : typeof value;
1021+
}
1022+
}
1023+
9411024
/**
9421025
* Memory-Based Analytics Service
943-
*
1026+
*
9441027
* Implements IAnalyticsService using InMemoryDriver's aggregation capabilities.
9451028
* Provides a semantic layer (Cubes, Metrics, Dimensions) on top of in-memory data.
9461029
*
@@ -1646,14 +1729,20 @@ export class MemoryAnalyticsService implements IAnalyticsService {
16461729
* lowered filter it receives. So a `$between` reaches the gate as the two
16471730
* bounds it lowers to, and answers `find()`'s rows here too.
16481731
*
1649-
* Not an array: `where` is declared a `FilterCondition`, and an array here is
1650-
* left to the reading this method has always given it.
1732+
* ## In front of the door: the `FilterArray` spelling
1733+
*
1734+
* An array `where` is lowered FIRST, by {@link lowerWhereFilterArray} — the
1735+
* spec's `isFilterAST` / `parseFilterAST` pair the analytics `where` door
1736+
* calls — so it reaches steps 1–3 as the `FilterCondition` it spells and
1737+
* answers what that object answers; `[]` is no filter, and an array with no
1738+
* lowering is refused `INVALID_FILTER` / 400. It used to skip all three steps
1739+
* and the flatten, and so answered every row.
16511740
*/
16521741
private normalizeFilters(query: unknown): NormalizedCubeEntry[] {
16531742
if (!query || typeof query !== 'object') return [];
16541743

16551744
const out: NormalizedCubeEntry[] = [];
1656-
const where = (query as { where?: unknown }).where;
1745+
const where = lowerWhereFilterArray((query as { where?: unknown }).where);
16571746

16581747
if (where && typeof where === 'object' && !Array.isArray(where)) {
16591748
assertListComparandShapes(where);

0 commit comments

Comments
 (0)