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.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/xl labels Aug 5, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 5, 2026 21:15
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 5, 2026
Merged via the queue into main with commit e6b1bb0 Aug 5, 2026
24 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5526-unknown-values branch August 5, 2026 21:27
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