Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/analytics-boolean-filter-groupby.md
Original file line number Diff line number Diff line change
@@ -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).
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,20 @@ const MONGO_TO_CUBE_OP: Record<string, string> = {
$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);
Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 };
Expand Down