diff --git a/.changeset/analytics-boolean-filter-groupby.md b/.changeset/analytics-boolean-filter-groupby.md new file mode 100644 index 0000000000..bf2a395c74 --- /dev/null +++ b/.changeset/analytics-boolean-filter-groupby.md @@ -0,0 +1,15 @@ +--- +"@objectstack/service-analytics": patch +--- + +fix(analytics): compare boolean filters/group-by against the 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 calling +`engine.aggregate`. Boolean fields hold a real `true`/`false`, so `1 !== true` +never matched: a metric widget filtered on a boolean field (e.g. +`{ is_critical: true }`) always returned 0, and pie/donut/bar charts grouped by +a boolean dimension failed to bucket. `stringifyForCube` now serializes booleans +as the tokens `'true'`/`'false'`, and a new `coerceFilterValueForObjectQL` +recovers a real boolean for the ObjectQL engine while the SQL path keeps binding +`1`/`0` (better-sqlite3 cannot bind a JS boolean). diff --git a/packages/services/service-analytics/src/__tests__/objectql-strategy-boolean-filter.test.ts b/packages/services/service-analytics/src/__tests__/objectql-strategy-boolean-filter.test.ts new file mode 100644 index 0000000000..0bd478a1c7 Binary files /dev/null and b/packages/services/service-analytics/src/__tests__/objectql-strategy-boolean-filter.test.ts differ diff --git a/packages/services/service-analytics/src/strategies/filter-normalizer.ts b/packages/services/service-analytics/src/strategies/filter-normalizer.ts index 0a9c3816f1..e1b2dda16d 100644 --- a/packages/services/service-analytics/src/strategies/filter-normalizer.ts +++ b/packages/services/service-analytics/src/strategies/filter-normalizer.ts @@ -38,10 +38,20 @@ const MONGO_TO_CUBE_OP: Record = { $exists: 'set', }; -/** Stringify a filter value as the internal pipeline requires `values: string[]`. */ +/** + * Stringify a filter value as the internal pipeline requires `values: string[]`. + * + * Booleans serialize as the tokens `'true'`/`'false'` (NOT `'1'`/`'0'`) so the + * boolean identity survives the string roundtrip: the consuming strategies can + * recover a real boolean for the ObjectQL engine (which compares against the + * stored boolean type) while still binding `1`/`0` for SQL. Stringifying to + * `'1'`/`'0'` was indistinguishable from a numeric 1/0 and made every boolean + * equality filter / boolean group-by compare a number against a boolean — and + * never match. + */ function stringifyForCube(v: unknown): string { if (v == null) return ''; - if (typeof v === 'boolean') return v ? '1' : '0'; + if (typeof v === 'boolean') return v ? 'true' : 'false'; if (v instanceof Date) return v.toISOString(); if (typeof v === 'object') return JSON.stringify(v); return String(v); @@ -118,23 +128,38 @@ export function normalizeAnalyticsFilters(query: { where?: unknown } | unknown): return out; } +/** Recover a finite number from a purely-numeric token, else undefined. */ +function recoverNumber(s: string): number | undefined { + if (/^-?\d+(\.\d+)?$/.test(s)) { + const n = Number(s); + if (Number.isFinite(n)) return n; + } + return undefined; +} + /** * Coerce a stringified filter value back into a runtime type for SQL - * parameter binding. Better-sqlite3 (and most drivers) bind JS - * booleans/numbers as their native SQL types, so we recover them here - * to avoid string-vs-number mismatches against typed columns. + * parameter binding. Better-sqlite3 (and most drivers) cannot bind a JS + * boolean, so booleans are recovered as `1`/`0` integers; numbers are + * recovered as numbers — avoiding string-vs-number mismatches against typed + * columns. */ export function coerceFilterValueForSql(s: string): unknown { if (s === 'true') return 1; if (s === 'false') return 0; if (s === 'null') return null; - if (/^-?\d+$/.test(s)) { - const n = Number(s); - if (Number.isFinite(n)) return n; - } - if (/^-?\d+\.\d+$/.test(s)) { - const n = Number(s); - if (Number.isFinite(n)) return n; - } - return s; + return recoverNumber(s) ?? s; +} + +/** + * Coerce a stringified filter value back into a runtime type for the ObjectQL + * aggregate engine. Unlike the SQL path, the engine compares against the + * *stored* runtime type, so a boolean field holds a real `true`/`false` — bind + * the boolean itself, NOT `1`/`0`, or the equality never matches. + */ +export function coerceFilterValueForObjectQL(s: string): unknown { + if (s === 'true') return true; + if (s === 'false') return false; + if (s === 'null') return null; + return recoverNumber(s) ?? s; } diff --git a/packages/services/service-analytics/src/strategies/objectql-strategy.ts b/packages/services/service-analytics/src/strategies/objectql-strategy.ts index 7d57233cfc..cd19102be2 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -3,7 +3,7 @@ import type { AnalyticsQuery, AnalyticsResult } from '@objectstack/spec/contracts'; import type { Cube } from '@objectstack/spec/data'; import type { AnalyticsStrategy, StrategyContext } from './types.js'; -import { normalizeAnalyticsFilters, coerceFilterValueForSql } from './filter-normalizer.js'; +import { normalizeAnalyticsFilters, coerceFilterValueForObjectQL } from './filter-normalizer.js'; /** * ObjectQLStrategy — Priority 2 @@ -232,8 +232,8 @@ export class ObjectQLStrategy implements AnalyticsStrategy { if (operator === 'notSet') return null; if (!values || values.length === 0) return undefined; - const v0 = coerceFilterValueForSql(values[0]); - const all = values.map(coerceFilterValueForSql); + const v0 = coerceFilterValueForObjectQL(values[0]); + const all = values.map(coerceFilterValueForObjectQL); switch (operator) { case 'equals': return v0; case 'notEquals': return { $ne: v0 };