Skip to content

fix(service-analytics): 过滤值按作者的类型传递,不再降级成 string[] (#5526) - #5634

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5526-unknown-values
Aug 5, 2026
Merged

fix(service-analytics): 过滤值按作者的类型传递,不再降级成 string[] (#5526)#5634
os-zhuang merged 1 commit into
mainfrom
claude/issue-5526-unknown-values

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5526

维护者已拍板的 B 案:NormalizedFilterNode 的 leaf valuesstring[] 改为 unknown[],值不再降级成字符串;coerceFilterValueForSql / coerceFilterValueForObjectQL / recoverNumber / stringifyForCube 一并删除。

前提重验(对 origin/main a7b854f 逐条核实)

issue 的说法 核实结果
stringifyForCube 约 :233 是出口 在(行号已随 #5525/#5528 的文档增长漂到 :263)
两个 coercer 在文件末尾、都是 export
recoverNumber 是还原数字的那条正则 在,且已被 #5547 收窄为规范数字形
消费者 native-sql-strategy.ts:542(SQL 绑定) 在,coerceTemporal 的兜底,现 :543
消费者 objectql-strategy.ts:626/638/956/957 四处都在(buildFilterClauseSql 的 in / 标量、dateRangeBoundsconvertFilter),行号漂到 :656/:705/:1010-1011/:1023-1024
'007'/'1.50' 族已由 #5547 止血 已止血,本 PR 之前该族确实已不再被误还原
'null'/'true'/'false' 的 token 撞车仍在 仍在,是本 PR 消灭的剩余面

PM 的派发前置核实也复核成立:NormalizedFilterNode 与两个 coercer 只在包内被消费 —— 全仓 grep 到的引用点除本文件外仅 native-sql-strategy.ts / objectql-strategy.ts 与本包测试;packages/services/service-analytics/src/index.ts 不导出它们中的任何一个,packages/spec/api-surface.json 也不含 service-analytics(该产物只覆盖 packages/spec)。所以没有包公开面收缩,单 PR 包内完成,不需要 needs_decision。

driver-memoryInMemoryStrategy 完全不走这个 normalizer(grep normalizeAnalyticsFilter 零命中),所以 #5499 的冻结面没有被碰到。

实测(修前 / 修后,{code: {$eq: v}},code 是 TEXT 列)

作者的 v 修前 SQL 绑定 修前引擎绑定 修后(两者)
'null' 真 NULL null 'null'
'true' 1 true 'true'
'false' 0 false 'false'
'007' '007'(#5547 已止血) 同左 '007'
7 7 7 7
true 1 true 1 / true(唯一保留的分叉,见下)

'null' 那一行比"取不到行"更糟:code = NULL 对任何行都是 UNKNOWN,图表永远画不出东西;'true' 则是取错行 —— 绑定 1,SQLite 按 TEXT 列的亲和性比成 '1',于是存着 '1' 的那行被取回来了。行集证据在 filter-value-type-fidelity.test.ts 的 decoy fixture 里(同时存 'null' 文本与真 NULL、'true' 文本与 '1')。

关键取舍(每条给代码证据)

1. 唯一留下的字符串化是单向的,而且不看字符串。 派发说明写的是"只在真正需要 SQL 字面量的地方(generateSql 回显)才字符串化" —— 如实汇报一处细节修正:generateSql 两条路径都是 $n 占位符 + params,从不内联字面量,所以那里其实不需要任何字符串化(改后 objectql-strategy 的回显直接 params.push(values[0]),反而更诚实:回显值现在字面上就是交给引擎的值)。真正需要转换的是 NativeSQLStrategy实际参数绑定 —— 驱动绑不了某些 JS 类型:

export function toSqlBindValue(v: unknown): unknown {
  if (typeof v === 'boolean') return v ? 1 : 0;
  if (v instanceof Date) return v.toISOString();
  if (v !== null && typeof v === 'object') return JSON.stringify(v);
  return v;
}

string 永远原样返回,函数体里没有任何检查字符串内容的分支 —— 这是它和被删掉的解码器的全部区别。boolean 保留 1/0 是因为 better-sqlite3 拒绝 JS 布尔;引擎路径刻意不做这一步(objectql-strategy-boolean-filter.test.ts 守着的回归:1 永远匹配不上存储的 true)。方向重要:它是"值 → 绑定形态"的编码器,不是"字符串 → 类型"的解码器。

2. LIKE 族维持 String(),不单面收紧 —— 并另立 finding。 filter.zod.ts:143/149/152$contains/$startsWith/$endsWith 声明为 z.string(),所以非字符串是脱离契约的输入。按 #4128/#3948 的 no-silent-drop 精神,它既不能被丢弃(丢谓词是放宽),也不该单面拒绝:driver-sqlapplyLike 做的是同一个 String(value),只在 analytics 收紧就会让 {$contains: 5} 变成"哪个面接的就是哪个答案" —— 正是 #5146/#5332/#5567 各花一轮消掉的分叉。like-pattern.tsescapeLikePattern(value: unknown) 本来就 String(),所以 SQL 侧两个发射器零改动#5567 的 29 例锚定测试(含与 applyLike 的逐字符比对)原样通过;引擎侧把 convertFilter 的四条 LIKE 臂显式写成 String(v0),让这个 PRODUCER 交给引擎的 FilterCondition 仍符合 spec 的 z.string() 声明。共享的宽容已追评到既有单 #5234(该单第 2 条就是 LIKE 族对象比较值被 String()[object Object]),不另开孪生单。

副作用两条,都朝 fail-closed,且都已钉住:

3. undefined 在 leaf 处归一为 null,但不动 #5332 的裁决。 JSON 没有 undefined,{$eq: undefined} 不是可授权形状;而 values 里真放一个 undefined 在 better-sqlite3 上是绑定错误、不是谓词。所以 comparand() 只做这一件事。刻意#5332=== null 判定放宽成 == null:那条判定决定算子的含义(空值谓词),归一只让可绑定,含义的改动是 #5332 的事,不能当成删编码器的副作用。{$eq: undefined} 因此仍编译成 equals leaf(values: [null]),不是 notSet

4. dateRange 按 spec 声明的类型转发。 原先两个边界过 coerceFilterValueForObjectQL,其 TSDoc 明写"epoch-ms 边界会还原成数字" —— 那是消费方在宽容地兜一个 AnalyticsQuerySchema 并未声明的形状(timeDimensions[].dateRange: string[]),和把 '007' 读成 7 是同一个猜测。按 Prime Directive #12,epoch-ms 窗口要么在生产者、要么在 spec 里声明,不在这里猜。现在 bounds: { $gte: start, $lte: end }。全仓无任何 epoch-ms dateRange 的既有覆盖(grep 过 1[0-9]{12}epoch),所以这一条没有踩掉任何既有断言,只是删掉一个未被使用的宽容分支;两条新用例把它钉成"声明是 string 就绑 string"。

5. $in 的操作数改成拷贝。 原先 values.map(coerce…) 顺手做了拷贝;删掉转换不能顺手把拷贝也删掉,否则 leaf 自己的数组会流进交给引擎的 filter 对象里(本树的节点从不共享 —— 见 falseNode 的注释)。所以 const all = [...values]

fixture 三分处置(逐个判,不批量重写)

fixture 处置 理由
filter-refusal-envelope.test.ts$gte: 10 / $between: [10, 20] 重写拼法 只是在编码旧表示(values: ['10'] 改成 [10]);#5352 的拒绝集合本身未动
native-sql-datetime-filter.test.ts 的 does NOT coerce a non-temporal numeric column 改判并写明 它钉的正是被删掉的解码器({score: {$gte: '80'}}[80]['80'])。该用例仍在证明"温度钩子不碰非时间列"这件不变的事,所以留在原处、断言改判 + 注释说明,完整类型表指向新文件
objectql-echo-operator-coverage.test.ts 的私有签名镜像 重写拼法 string[]unknown[](镜像要跟着真签名)
filter-value-canonical-number.test.ts 整体升级并改名 见下
objectql-strategy-boolean-filter.test.ts / like-metacharacter-escape.test.ts / filter-normalizer-not-null-safe.test.ts 零改动 布尔与 LIKE 的比较数本来就按新语义正确;{$eq: ''}values: [''] 本来就是作者写的字符串

#5528 的测试资产:整体保留、升级、改名

filter-value-canonical-number.test.ts 改名为 filter-value-type-fidelity.test.ts(git mv,内容重写)。改名是因为它原本的前提("canonical number" 解码规则)已经不存在;行为覆盖没有缩水,是扩了:

  • 原来的 DECODE 表(21 行)全部保留,但从"coerceFilterValueForSql('007') 返回什么"升级为端到端三问:leaf 携带什么 / SQL 绑什么 / 引擎绑什么。测的是真编码器,而不是手写的中间字符串。
  • 原来的 "analytics 的 string[] 值往返对字符串比较数也有损:{code: {$eq: '007'}} 绑成数字 7'null' 绑成真 NULL、'true' 绑成 1 —— 文本列静默取到错行 #5526 root cause NOT fixed here — the token collision is unchanged" 那一块改判为已修,并补上行集证据。
  • 原来的 sql.js 行集用例全部保留,fixture 加了 decoy 行:存文本 'null' 的行紧挨着存真 NULL 的行,存文本 'true' 的行紧挨着存 '1' 的行 —— 精确行集断言(toEqual,不是 toContain)。
  • 新增:LIKE 三个发射器的归一一致性、dateRange 的声明类型、undefined 归一、$gt: null 的绑定与行集、Date 比较数的同一引用(证明没有任何东西重编码过它)。

反向验证(先预测方向,再跑)

预测:把删掉的往返装回去(编码器 + SQL 解码器),新钉子应当修前红 / 修后绿 —— 这里没有计数型闸门,规范键也不在任何 ?? 链首位,所以不预期 #5009 那种反转。

实测,与预测一致。comparand() 的函数体换回 stringifyForCube、把 toSqlBindValue 的函数体换回 coerceFilterValueForSql(scratch patch,已还原),filter-value-type-fidelity.test.ts 转红,包含全部头条用例:

FAIL  {code: {$eq: 'null'}} finds the row storing the TEXT 'null' — and only it
AssertionError: expected [] to deeply equal [ 'r_nulltext' ]
- [ "r_nulltext", ]
+ []

FAIL  {code: {$eq: 'true'}} finds the row storing the TEXT 'true', not the row storing '1'
FAIL  a null comparand in an ORDERING position binds NULL, so the widget draws nothing
FAIL  a null comparand becomes '%null%' — narrower than the '%%' it used to be
FAIL  binds 'null' / 'true' / '007' as TEXT, and a real boolean as 1

一处如实说明:那个 scratch patch 是部分还原(装回了编码器和 SQL 侧解码器,没有重装引擎侧解码器),所以同轮还有 objectql-strategy-boolean-filter / objectql-strategy-range-filter / execution-context-bridge 转红 —— 这三个是部分还原的产物,不属于预测信号。预测的红是上面那些 filter-value-type-fidelity 用例,以及本 PR 主动改判的三个 fixture。还原后 51 文件 / 905 用例全绿。

测试与闸门

命令 结果
pnpm --filter @objectstack/service-analytics test(改前基线) 51 files / 854 passed
同上(改后) 51 files / 905 passed(+51,全绿)
pnpm --filter @objectstack/rest test(消费半径) 49 files / 737 passed
tsc --noEmit(本包无 typecheck 脚本,在 #4311 DEBT 账上) 7 处 —— 与改前基线逐字相同,未新增
pnpm --filter @objectstack/service-analytics build ESM/CJS/DTS 全部成功
node scripts/check-nul-bytes.mjs OK(5531 个受跟踪文本文件,零裸控制字节)
grep -naP 控制字节自扫改动文件 零命中(闸门盲区自查)
pnpm check:adr-anchors OK(29 个锚点文件)
pnpm check:type-check-coverage OK(62/77 + root)

packages/spec 未改动,故无 os-regen 生成物需要重跑。一处需要 PM 知道的观察:途中 pnpm install / spec build 的 gen:schema 两次把 packages/spec/authorable-surface.base.jsonbaseRev 重写成我的 HEAD 并补了两个 EmailServiceConfig 键(那两个键在 main 的 authorable-surface.json 里本来就有)。这与本 PR 无关,已 git restore 掉、未进提交(按 AGENTS.md 多代理纪律第 1 条),PR diff 里没有它。

未变更的相邻契约


Generated by Claude Code

…t as string[] (#5526)

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BWS4heBoAitLmzCLhcYdbK
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 5, 2026 9:03pm

Request Review

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics.

8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/data-api.mdx (via @objectstack/service-analytics)
  • content/docs/api/index.mdx (via @objectstack/service-analytics)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/service-analytics)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/service-analytics)
  • content/docs/plugins/packages.mdx (via @objectstack/service-analytics)
  • content/docs/releases/implementation-status.mdx (via @objectstack/service-analytics)
  • content/docs/releases/v17.mdx (via @objectstack/service-analytics)
  • content/docs/releases/v9.mdx (via @objectstack/service-analytics)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants