Skip to content

Commit 910a8f0

Browse files
authored
fix(analytics): boolean filters & group-by compare against real boolean, not stringified '1' (#2287)
* fix(analytics): compare boolean filters/group-by against real boolean, not stringified '1' The analytics filter normalizer stringified boolean `true` → `'1'`, which the ObjectQL strategy then coerced back to the number `1` before passing it into `engine.aggregate`. Stored boolean fields hold a real `true`/`false`, so `1 !== true` never matched: boolean metric widgets always returned 0 and boolean group-by dimensions failed to bucket. `'1'`/`'0'` was indistinguishable from a numeric 1/0, so the boolean identity could not be recovered. Fix the roundtrip: - `stringifyForCube` now serializes booleans as the tokens `'true'`/`'false'`, preserving the boolean identity through the `string[]` pipeline form. - New `coerceFilterValueForObjectQL` recovers a real `true`/`false` for the ObjectQL engine (compares against the stored runtime type); the SQL path keeps recovering `1`/`0` since better-sqlite3 cannot bind a JS boolean. Shared numeric recovery extracted into `recoverNumber`. - ObjectQLStrategy.convertFilter uses the new ObjectQL coercer. Adds a regression test whose mock engine filters/buckets in-memory rows by STRICT `===`: a `{ is_critical: true }` filter yields a non-zero count, `false` matches its rows, and a boolean group-by produces both true/false buckets. * chore(changeset): add changeset for analytics boolean filter fix
1 parent 19efabb commit 910a8f0

4 files changed

Lines changed: 57 additions & 17 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(analytics): compare boolean filters/group-by against the real boolean, not stringified '1'
6+
7+
The analytics filter normalizer stringified boolean `true``'1'`, which the
8+
ObjectQL strategy then coerced back to the number `1` before calling
9+
`engine.aggregate`. Boolean fields hold a real `true`/`false`, so `1 !== true`
10+
never matched: a metric widget filtered on a boolean field (e.g.
11+
`{ is_critical: true }`) always returned 0, and pie/donut/bar charts grouped by
12+
a boolean dimension failed to bucket. `stringifyForCube` now serializes booleans
13+
as the tokens `'true'`/`'false'`, and a new `coerceFilterValueForObjectQL`
14+
recovers a real boolean for the ObjectQL engine while the SQL path keeps binding
15+
`1`/`0` (better-sqlite3 cannot bind a JS boolean).
Binary file not shown.

packages/services/service-analytics/src/strategies/filter-normalizer.ts

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,20 @@ const MONGO_TO_CUBE_OP: Record<string, string> = {
3838
$exists: 'set',
3939
};
4040

41-
/** Stringify a filter value as the internal pipeline requires `values: string[]`. */
41+
/**
42+
* Stringify a filter value as the internal pipeline requires `values: string[]`.
43+
*
44+
* Booleans serialize as the tokens `'true'`/`'false'` (NOT `'1'`/`'0'`) so the
45+
* boolean identity survives the string roundtrip: the consuming strategies can
46+
* recover a real boolean for the ObjectQL engine (which compares against the
47+
* stored boolean type) while still binding `1`/`0` for SQL. Stringifying to
48+
* `'1'`/`'0'` was indistinguishable from a numeric 1/0 and made every boolean
49+
* equality filter / boolean group-by compare a number against a boolean — and
50+
* never match.
51+
*/
4252
function stringifyForCube(v: unknown): string {
4353
if (v == null) return '';
44-
if (typeof v === 'boolean') return v ? '1' : '0';
54+
if (typeof v === 'boolean') return v ? 'true' : 'false';
4555
if (v instanceof Date) return v.toISOString();
4656
if (typeof v === 'object') return JSON.stringify(v);
4757
return String(v);
@@ -118,23 +128,38 @@ export function normalizeAnalyticsFilters(query: { where?: unknown } | unknown):
118128
return out;
119129
}
120130

131+
/** Recover a finite number from a purely-numeric token, else undefined. */
132+
function recoverNumber(s: string): number | undefined {
133+
if (/^-?\d+(\.\d+)?$/.test(s)) {
134+
const n = Number(s);
135+
if (Number.isFinite(n)) return n;
136+
}
137+
return undefined;
138+
}
139+
121140
/**
122141
* Coerce a stringified filter value back into a runtime type for SQL
123-
* parameter binding. Better-sqlite3 (and most drivers) bind JS
124-
* booleans/numbers as their native SQL types, so we recover them here
125-
* to avoid string-vs-number mismatches against typed columns.
142+
* parameter binding. Better-sqlite3 (and most drivers) cannot bind a JS
143+
* boolean, so booleans are recovered as `1`/`0` integers; numbers are
144+
* recovered as numbers — avoiding string-vs-number mismatches against typed
145+
* columns.
126146
*/
127147
export function coerceFilterValueForSql(s: string): unknown {
128148
if (s === 'true') return 1;
129149
if (s === 'false') return 0;
130150
if (s === 'null') return null;
131-
if (/^-?\d+$/.test(s)) {
132-
const n = Number(s);
133-
if (Number.isFinite(n)) return n;
134-
}
135-
if (/^-?\d+\.\d+$/.test(s)) {
136-
const n = Number(s);
137-
if (Number.isFinite(n)) return n;
138-
}
139-
return s;
151+
return recoverNumber(s) ?? s;
152+
}
153+
154+
/**
155+
* Coerce a stringified filter value back into a runtime type for the ObjectQL
156+
* aggregate engine. Unlike the SQL path, the engine compares against the
157+
* *stored* runtime type, so a boolean field holds a real `true`/`false` — bind
158+
* the boolean itself, NOT `1`/`0`, or the equality never matches.
159+
*/
160+
export function coerceFilterValueForObjectQL(s: string): unknown {
161+
if (s === 'true') return true;
162+
if (s === 'false') return false;
163+
if (s === 'null') return null;
164+
return recoverNumber(s) ?? s;
140165
}

packages/services/service-analytics/src/strategies/objectql-strategy.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import type { AnalyticsQuery, AnalyticsResult } from '@objectstack/spec/contracts';
44
import type { Cube } from '@objectstack/spec/data';
55
import type { AnalyticsStrategy, StrategyContext } from './types.js';
6-
import { normalizeAnalyticsFilters, coerceFilterValueForSql } from './filter-normalizer.js';
6+
import { normalizeAnalyticsFilters, coerceFilterValueForObjectQL } from './filter-normalizer.js';
77

88
/**
99
* ObjectQLStrategy — Priority 2
@@ -232,8 +232,8 @@ export class ObjectQLStrategy implements AnalyticsStrategy {
232232
if (operator === 'notSet') return null;
233233
if (!values || values.length === 0) return undefined;
234234

235-
const v0 = coerceFilterValueForSql(values[0]);
236-
const all = values.map(coerceFilterValueForSql);
235+
const v0 = coerceFilterValueForObjectQL(values[0]);
236+
const all = values.map(coerceFilterValueForObjectQL);
237237
switch (operator) {
238238
case 'equals': return v0;
239239
case 'notEquals': return { $ne: v0 };

0 commit comments

Comments
 (0)