From fa4d3cab7c9d11502a01ac10632dd4bc8a3d18a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Wed, 24 Jun 2026 21:50:24 +0800 Subject: [PATCH 1/2] fix(analytics): compare boolean filters/group-by against real boolean, not stringified '1' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../objectql-strategy-boolean-filter.test.ts | Bin 0 -> 5468 bytes .../src/strategies/filter-normalizer.ts | 53 +++++++++++++----- .../src/strategies/objectql-strategy.ts | 6 +- 3 files changed, 42 insertions(+), 17 deletions(-) create mode 100644 packages/services/service-analytics/src/__tests__/objectql-strategy-boolean-filter.test.ts 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 0000000000000000000000000000000000000000..0bd478a1c7c34532dc5f66c57025219996d7553f GIT binary patch literal 5468 zcmeHL%W~UB7R_2;ac4!qLxGY~l?7$VksQ0?iV`KJYi6NRnN6@swgoh3bVD?vVpUf2 z0aN+He94^K5AZRzW2dsnA}(7c1Kqdpx*{>h zF>_%hErnfBUX@ExD?P^xp)~dg-c`_~stPGI{r1bRq$|rN*?(i*;&Jb|H>LsUrko(M z@$opLS9tB+#fG{Rh58w<8jjyqVWXP0Y-!A%j_J4m{Fj`iKCKoic#0F?-Mr=FH2jlw z?k(`~oAXXLt~-mz<1Pa4XT@2IQG^4vyoXDru&BYbPo^S%(00p8iSTl^S4-Q}8WxU7 z=#8CuT{X+YNlQ~zqO>TMp_x#H_i)nWchY7wlG6F)vvIh|MrO?zW%EfyE}mEQ(`JH9 zASFK1=dRxW5=A5NGFEMzW(NnVTvoNE2bxNQnone(lvxU@svn z(decjdVSw0ynE-VZI^t007sA3eEyaV!>CZU-7>KCDD3t{44ZG+LMer5(8KU)<4%a? zbzU{v!eG~baKJyKHFmF47Ezf6nMu|F?6hZb5&rd?%kd5GJ`;f;8oHgE&usPlgz7 zc~wszd%ydbhj+SK>2q42r^Jll=@y`Fbxw)v#@Dg+!zTBzTJoXjum@j=%NEA{f}7jZ z5cbE;<33G?^dq=Z8Tr_^#s|DRMBc)g5#({LQACdz z0Kp!12sDrfqGp7bPz>M@3{`{=#(=g!LBpR=1?M05jgMoGGd6cP) zE&3d|hPiB!f*4wSg4j?rw-lI5!sDz!J3yJR^&^iCHWqvXv&OOEDRmf10bquW>tJ)e z?S&uB23}~nqVL7B!{P^`JWmh?AI!9n_)xfR)vP;kWtioWloxQ1)PI_PT_EyPv%7D&r7KKo=xwHwkbqhJiaoNUo0vi0* zY&Ws6)GgnC1#oyJmVyzFyZVy^Lx+ap)cLe{e?H6sXK3c#qla`%>u7bS0&~uPNNyS; zy_o}w^a+{WSd1xx!sZn9gQYH2Ygc*yGXgKmvbgP@<(WxR2Q)Yeq(x1Z^rB`%#l4P= zL>ed}f&30aKZPDhx;9oe=`DpZ((G&Spk5Q^VX%A3dU}6QIhpDNg&$6i@fi>-C0KWO zYVA&o)rrLRS)`48$IkUNHupK6ack}CMkn|f3~w7K?WbZXCJIv#R5uKRyiC zLjQ#+@5%pdaS^X8{&oY&tG{{*cI*enPH&$-#c+YGK6t&0Gs7F&+L?Bi|Swb|K zb2kXse+rx$7HW zE$}F41a#i+1+DL<^R6Vo~o#s+^(f`V50>rZ^!DW+}%z2!lx^Jh>Kwbc0hh8Ss|n&nn>`hbdk=~ zJ6v}-fx#bNos5CZXFp?ImAq#Vd(r+)&=yWzxN2tLa5VUnPi<8U`vg+mf6w914?Yjh z^xs^*VX-~(3?l8bxx7m~-`saT6?9bo`*h!-6I^)7#fv4zCxLqmH(Dt?-1)Qyq1^1uH9 Dlgtp3 literal 0 HcmV?d00001 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 }; From 012d9705b5b1174608810fbf9b9c6082b306d6f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Wed, 24 Jun 2026 21:51:46 +0800 Subject: [PATCH 2/2] chore(changeset): add changeset for analytics boolean filter fix --- .changeset/analytics-boolean-filter-groupby.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/analytics-boolean-filter-groupby.md 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).