Skip to content

fix(service-analytics): recoverNumber 只还原规范数字串 —— '007' / '1.50' 保持字符串 (#5528) - #5547

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5528-recover-number-canonical
Aug 5, 2026
Merged

fix(service-analytics): recoverNumber 只还原规范数字串 —— '007' / '1.50' 保持字符串 (#5528)#5547
os-zhuang merged 2 commits into
mainfrom
claude/issue-5528-recover-number-canonical

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5528

#5526 的止血单(其路线 C)。本单不裁根因 —— values: string[] 往返编码本身的有损性(以及 'null'/'true'/'false' 的 token 撞车)仍归 #5526,这里只收窄解码侧,挡住已实测的真实业务形状。

前提复核(切分支后按 rule 6 实测)

#5525(#5332)已在基上;它改的是编码侧(fieldLeavesnull 移出值数组),本单改的是解码侧,两者不相交。在 origin/main f2a1c0be1 上跑 normalizeAnalyticsFilterTree + 两个 coercer,#5526 表格逐行复现:

作者写的 v leaf values SQL 绑定 引擎比较数
'007' ["007"] 7 7
'0912' ["0912"] 912 912
'1.50' ["1.50"] 1.5 1.5
'7' ["7"] 7 7
'1e3' ["1e3"] "1e3" "1e3"

前提成立。顺手实测到两条 issue 未列的同族形状:'-0'0(丢符号)、'12345678901234567890'12345678901234567000(丢精度),同样是往返有损,一并纳入。

改法

recoverNumber 在原有形状正则之后加一条往返判据 String(Number(s)) === s:

  • 真数字比较数出去时就是 String(n),天然规范,照旧还原(7'7'7);
  • Number() 会改写的串(前导零、尾零、'-0'、超出双精度位数)不可能来自数字,只能是作者写的字符串,保持原样。

形状正则留在前面是刻意的:这样本改动只可能减少还原,不会新增。'1e3''1e+21''+7'' 7''0x10''Infinity''NaN' 改前是字符串、改后仍是字符串 —— 其中 '1e+21' 本身是 String(1e21) 的规范形,仅靠往返判据会被还原,正则挡住了它。

方向上也是 ADR-0053 D-A2 早已定的:这个函数是 driver 侧 coerceTemporalFilterValue 钩子背后的最后兜底,按形状猜类型的面越小越好。TSDoc 里已留下 ADR 编号与 issue 号。

coerceFilterValueForSql / coerceFilterValueForObjectQL 两个 coercer 同步(两者共用同一个 recoverNumber;它们只在布尔写法上刻意不同,"哪些串算数字"必须是同一条规则),测试里有一条专门守这个一致性。

反向验证(方向预测,后运行)

预测:非规范形的 decode 行转红;行集用例转红且取到错行(不是零行 —— SQLite 会把 TEXT 列的 affinity 套到整数绑定上);引擎用例转红在比较数上;规范形 / 真数字往返 / token 撞车三组保持绿。

recoverNumber 退回旧实现后实测,13 条红,方向逐条命中:

× "007" → SQL "007" / engine "007"
  → leading zeros — order number / SKU (was 7): expected 7 to deeply equal '007'
× "-0" → SQL "-0" / engine "-0"
  → the sign is lost: expected -0 to deeply equal '-0'
× {code: {$eq: '007'}} finds the row storing '007'
  → Before #5528 the bind was the integer 7 and this returned ['r_7'] — the wrong row, silently.:
    expected [ 'r_7' ] to deeply equal [ 'r_007' ]
× {code: {$in: ['007', '1.50']}} keeps both spellings
  → expected [ 'r_15', 'r_7' ] to deeply equal [ 'r_007', 'r_150' ]
× binds '007' as TEXT, not as the integer 7  → expected [ 7 ] to include '007'
× passes the string "007" through …          → expected 7 to be '007'
✓ "7" / "1.5" / "-3" / "0" / "1000"(规范形,不受影响)
✓ 7 → values → 7(真数字往返守卫,不受影响)
✓ {code: {$eq: '7'}} still finds the row storing '7'
✓ {score: {$eq: 7}} still matches on the numeric column
✓ the author strings null / true / false still decode to non-strings

一处如实说明:引擎用例的红停在比较数断言上,后面那句"行数为 0"没跑到。所以我用 tsx 在退回态下单独量了一次,'007' → 比较数 7order_count: 0,'1.50'1.50;测试注释里的"Was 0 before #5528"是量出来的,不是推出来的。

行集用例特意放了 r_7(存 '7')和 r_15(存 '1.5')两行:改前作者查 '007' 拿到的是 '7' 那行 —— 比零行更难发现,这才是现场会报的症状。

测试

新增 src/__tests__/filter-value-canonical-number.test.ts(39 例,复用本包既有的两套 harness):

  1. decode 表:21 个值 × 两个 coercer,含 analytics 的 string[] 值往返对字符串比较数也有损:{code: {$eq: '007'}} 绑成数字 7'null' 绑成真 NULL、'true' 绑成 1 —— 文本列静默取到错行 #5526 三行、规范形、以及"改前后都是字符串"的 7 种形状;
  2. 真数字往返守卫:经真编码器 normalizeAnalyticsFilterTree 出去再解回来(不是手写字符串),防过度收窄;
  3. SQL 路径行集:sql.js 真 SQLite,TEXT 列 + $eq / $in,外加两条直接断言绑定类型的用例(数字列那条 SQLite 的 numeric affinity 会"救"回来,行集看不出差别,只有绑定类型能);
  4. 引擎路径:AnalyticsService + 严格相等的 executeAggregate 替身,比较数与行数一起断言;
  5. analytics 的 string[] 值往返对字符串比较数也有损:{code: {$eq: '007'}} 绑成数字 7'null' 绑成真 NULL、'true' 绑成 1 —— 文本列静默取到错行 #5526 撞车原样钉住(pinned, not endorsed),避免后来的读者误以为本单修掉了它。

命令与结果:

pnpm --filter @objectstack/service-analytics test
  Test Files  48 passed (48)
       Tests  792 passed (792)

npx vitest run src/__tests__/filter-value-canonical-number.test.ts
  Test Files  1 passed (1)
       Tests  39 passed (39)

npx eslint <两个改动文件>            → exit 0
node scripts/check-nul-bytes.mjs    → OK (5499 files, no control bytes)

typecheck:本包无 typecheck script(在 check-type-check-coverage.mjs 里带 DEBT 台账条目),故 CI 不对它做类型检查。仍手工跑了 npx tsc --noEmit -p packages/services/service-analytics/tsconfig.json,报的 7 条全是既有测试文件的旧账,filter-normalizer.ts 与新测试文件均无报错。

消费半径已按"规则被谁消费"扫过:两个 coercer 只被本包 native-sql-strategy.ts / objectql-strategy.ts 引用;仓库内既有的数字串比较数 fixture($gte: '80'{ v: '9' })都是规范形,不受影响。

范围红线


🤖 Generated with Claude Code

https://claude.ai/code/session_01BWS4heBoAitLmzCLhcYdbK


Generated by Claude Code

…, so '007' / '1.50' stay strings (#5528)

An analytics `where` round-trips every comparand through `values: string[]` —
`stringifyForCube` out, `coerceFilterValueForSql` / `coerceFilterValueForObjectQL`
back — and the decoder decided "this is a number" from the string's SHAPE alone
(`/^-?\d+(\.\d+)?$/`), which cannot tell a stringified number from a string the
author wrote. Measured on main, cube `orders` / TEXT column `code`: `'007'` bound
`7`, `'0912'` bound `912`, `'1.50'` bound `1.5` — on BOTH consumers, the raw-SQL
bind and the engine comparand.

The failure was silent and MIS-TARGETED rather than empty: SQLite applies the
text column's affinity to the integer bind, so a widget filtered on order number
`'007'` returned the row storing `'7'`. On Postgres the same query is a
`text = integer` type error; on the engine path the strict comparison matched
nothing (measured: 0 rows). Zero-padded and trailing-zero strings are ordinary
business shapes — order numbers, SKUs, dialling codes, postcodes, prices.

Recovery is now limited to a number's own canonical spelling
(`String(Number(s)) === s`): a comparand that really was a number arrives as
`String(n)` by construction and still round-trips (`7` → `'7'` → `7`), while a
string `Number()` would rewrite cannot have come from a number and stays the
author's string. The shape regex still runs FIRST, so the change can only remove
recoveries — `'1e3'`, `'1e+21'`, `'+7'`, `' 7'`, `'0x10'`, `'Infinity'`, `'NaN'`
were strings before and are strings after. This is the direction ADR-0053 D-A2
already set for this function: last-resort textual recovery behind the
driver-backed `coerceTemporalFilterValue` hook.

Stopgap by design, and named as one in the TSDoc and the tests: `values: string[]`
still has no escape, so the author strings `'null'` / `'true'` / `'false'` still
collide with the tokens the encoder writes for the real values. That is #5526's
root cause (tagged values, or an `unknown[]` internal representation), pinned here
as UNCHANGED so it is not mistaken for fixed.

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 4:59pm

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.

Left over from dropping three `eslint-disable-next-line
@typescript-eslint/no-explicit-any` comments: that rule is not defined in this
repo's eslint config, so the disables themselves were errors ("Definition for
rule ... was not found"). The sibling `filter-operator-coverage.test.ts` uses the
same `let db: any` harness with no disable comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BWS4heBoAitLmzCLhcYdbK
@os-zhuang
os-zhuang marked this pull request as ready for review August 5, 2026 17:16
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 5, 2026
Merged via the queue into main with commit c637387 Aug 5, 2026
24 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5528-recover-number-canonical branch August 5, 2026 17:29
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/l tests tooling

Projects

None yet

2 participants