Skip to content

Commit e6b1bb0

Browse files
os-zhuangclaude
andauthored
fix(service-analytics): carry filter comparands at their own type, not as string[] (#5526) (#5634)
The analytics filter normalizer flattened every comparand into `values: string[]` and had its consumers GUESS the type back out. An all-strings encoding has no escape, so author strings collided with the tokens the encoder wrote for other types: `{code: {$eq: 'null'}}` bound real NULL (UNKNOWN for every row), `'true'` bound 1 / true, and `'007'` / `'1.50'` bound 7 / 1.5 until #5528 narrowed that half as an explicit stopgap. `NormalizedFilterNode`'s leaf `values` is now `unknown[]`. The author's value travels through the tree untouched and nothing decodes it. `stringifyForCube`, `recoverNumber`, `coerceFilterValueForSql` and `coerceFilterValueForObjectQL` are deleted. Conversion survives only where a boundary demands it: - `toSqlBindValue` — one-way, and it inspects no string: it converts only the JS types a driver cannot bind (boolean -> 1/0, Date -> ISO, object -> JSON). - the LIKE family, whose comparand `filter.zod.ts` declares a `string`, so `like-pattern.ts` and `convertFilter` stringify at the emitter — the same `String(value)` `driver-sql`'s `applyLike` applies, keeping one `$contains` meaning one thing on both faces. The ObjectQL engine path now converts nothing at all: the engine compares against the stored runtime type and receives the author's own value. Two readings changed as a consequence, both toward fail-closed: `{$contains: null}` was `LIKE '%%'` (matching every non-NULL row) and is now `LIKE '%null%'`, which is what driver-sql has always compiled; `{$gt: null}` was `> ''` (a real comparison against the empty string) and now binds NULL, so the predicate is UNKNOWN. `timeDimensions[].dateRange` bounds forward at the `string` type the spec declares them with, instead of being re-read as epoch-ms numbers by a lenient consumer. The null-predicate semantics of #5332 / #5525 and the LIKE escaping contract of #5567 are untouched, both pinned by row-set tests. #5528's test asset is carried forward whole: filter-value-canonical-number.test.ts becomes filter-value-type-fidelity.test.ts, with every case upgraded from "what does the decoder return" to the end-to-end leaf/SQL-bind/engine-bind question, plus decoy rows storing the text 'null' and 'true' beside a real NULL. Reverse-verified: re-inserting the encoder turns those pins red. Fixes #5526 Claude-Session: https://claude.ai/code/session_01BWS4heBoAitLmzCLhcYdbK Co-authored-by: Claude <noreply@anthropic.com>
1 parent d4edb5d commit e6b1bb0

9 files changed

Lines changed: 949 additions & 559 deletions
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): 过滤值不再被降级成字符串 —— `{code: {$eq: '007'}}` / `'null'` / `'true'` 按作者写的字面值绑定 (#5526)
6+
7+
analytics 的 `filter-normalizer` 内部把每个比较数(comparand)压成 `values: string[]`
8+
再由消费方****回类型:出口是 `stringifyForCube`,入口是 `recoverNumber`
9+
`coerceFilterValueForSql` / `coerceFilterValueForObjectQL`。字母表是"全体字符串"、
10+
解码规则是"这串看起来像不像数字/布尔/null"的编码没有任何转义机制,于是作者写的字符串
11+
和编码器为其他类型写下的 token 撞车。`{code: {$eq: v}}``main` 上实测:
12+
13+
| 作者的 `v` | SQL 绑定 | 引擎绑定 |
14+
|---|---|---|
15+
| `'007'` | `7`(#5528 已修) | `7`(#5528 已修) |
16+
| `'1.50'` | `1.5`(#5528 已修) | `1.5`(#5528 已修) |
17+
| `'null'` | 真 NULL |`null` |
18+
| `'true'` | `1` | `true` |
19+
20+
每一行都是一个缺陷:存着作者那种写法的 TEXT 列不再匹配。`'007'` 在 SQLite 上是
21+
整数与 TEXT 列的跨类型比较、恒不相等,在 Postgres 上 `text = integer` 直接报类型错;
22+
`'null'` 那一行比"空"更糟 —— 与真 NULL 的比较对任何行都是 UNKNOWN,图表永远画不出东西。
23+
零填充串、当枚举码用的 `'true'`/`'false'`、当字面标签用的 `'null'` 都是真实业务形状
24+
(订单号、SKU、邮编、国际长途区号)。
25+
26+
**修法**:`NormalizedFilterNode` 的 leaf `values``string[]` 改为 `unknown[]`,
27+
作者写的值原样穿过整棵树,不再有任何东西去解码它。仅在边界真正要求时才转换:
28+
29+
- `toSqlBindValue`(唯一留下的转换,且是**单向**的:值 → 它的 SQL 绑定形态,不是解码器)
30+
——只处理驱动绑不了的 JS 类型:`boolean``1`/`0`(better-sqlite3 拒绝 JS 布尔)、
31+
`Date` → ISO 文本、其他对象 → JSON 文本。它不检查任何字符串。
32+
- LIKE 族的比较数被 `filter.zod.ts` 声明为 `z.string()`,所以在发射点字符串化 ——
33+
`driver-sql``applyLike` 同一个 `String(value)`,两个面上 `$contains` 仍是一件事。
34+
35+
ObjectQL 引擎路径现在不需要任何转换:引擎按**存储**的运行时类型比较,而它拿到的就是
36+
作者写的值。`stringifyForCube` / `recoverNumber` / `coerceFilterValueForSql` /
37+
`coerceFilterValueForObjectQL` 一并删除。
38+
39+
两处读法作为直接后果改变了,方向都是 fail-closed:
40+
41+
- `{name: {$contains: null}}` 原先编译成 `LIKE '%%'` —— 匹配**每一个**非 NULL 行,
42+
因为 `stringifyForCube(null)``''`;现在是 `LIKE '%null%'`,与 `driver-sql`
43+
一直以来的编译结果一致。
44+
- `{amount: {$gt: null}}` 原先编译成 `amount > ''`(一次针对空字符串的真实比较);
45+
现在绑定 NULL,谓词为 UNKNOWN、图表画不出行 —— 无序比较数的诚实答案,也是
46+
`driver-memory` / `formula` 给出的答案。(#5332 明确指出这个比较数位置没有任何裁决
47+
覆盖、`''` 只是占位符;删掉编码器就按构造把它定了。)
48+
49+
`timeDimensions[].dateRange` 的两个边界现在按 spec 声明的类型(`string[]`)原样传递:
50+
原先它们也过 `coerceFilterValueForObjectQL`,其文档宣称"epoch-ms 边界会还原成数字"——
51+
那是消费方在宽容地兜一个契约并未声明的形状,和把 `'007'` 读成 `7` 是同一个猜测
52+
(Prime Directive #12:epoch-ms 窗口要么在生产者、要么在 spec 里声明,不在这里猜)。
53+
54+
`{stage: null}` / `{$eq: null}` / `{$ne: null}` / `{$null:}` / `{$exists:}` 的空值
55+
谓词语义(#5332 / #5525)不变:真 `null` 比较数编译成 `notSet` / `set`,从不进入
56+
`values`#5567 的 LIKE 转义契约不变。

packages/services/service-analytics/src/__tests__/filter-refusal-envelope.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,16 +146,18 @@ const ACCEPTED: Array<{ name: string; where: unknown; tree: unknown }> = [
146146
{
147147
name: 'an explicit operator',
148148
where: { amount: { $gte: 10 } },
149-
tree: { kind: 'leaf', member: 'amount', operator: 'gte', values: ['10'] },
149+
// [#5526] `values` is `unknown[]`, so the number the author wrote stays a
150+
// number instead of being encoded to `'10'` and guessed back.
151+
tree: { kind: 'leaf', member: 'amount', operator: 'gte', values: [10] },
150152
},
151153
{
152154
name: '$between lowered to its two bounds',
153155
where: { amount: { $between: [10, 20] } },
154156
tree: {
155157
kind: 'and',
156158
children: [
157-
{ kind: 'leaf', member: 'amount', operator: 'gte', values: ['10'] },
158-
{ kind: 'leaf', member: 'amount', operator: 'lte', values: ['20'] },
159+
{ kind: 'leaf', member: 'amount', operator: 'gte', values: [10] },
160+
{ kind: 'leaf', member: 'amount', operator: 'lte', values: [20] },
159161
],
160162
},
161163
},

0 commit comments

Comments
 (0)