fix(service-analytics): 过滤值按作者的类型传递,不再降级成 string[] (#5526) - #5634
Merged
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 5, 2026 21:15
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5526
维护者已拍板的 B 案:
NormalizedFilterNode的 leafvalues由string[]改为unknown[],值不再降级成字符串;coerceFilterValueForSql/coerceFilterValueForObjectQL/recoverNumber/stringifyForCube一并删除。前提重验(对 origin/main a7b854f 逐条核实)
stringifyForCube约 :233 是出口exportrecoverNumber是还原数字的那条正则native-sql-strategy.ts:542(SQL 绑定)coerceTemporal的兜底,现 :543objectql-strategy.ts:626/638/956/957buildFilterClauseSql的 in / 标量、dateRangeBounds、convertFilter),行号漂到 :656/:705/:1010-1011/:1023-1024'007'/'1.50'族已由 #5547 止血'null'/'true'/'false'的 token 撞车仍在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-memory的InMemoryStrategy完全不走这个 normalizer(grepnormalizeAnalyticsFilter零命中),所以 #5499 的冻结面没有被碰到。实测(修前 / 修后,
{code: {$eq: v}},code是 TEXT 列)v'null'null'null''true'1true'true''false'0false'false''007''007'(#5547 已止血)'007'7777true1true1/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 类型: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-sql的applyLike做的是同一个String(value),只在 analytics 收紧就会让{$contains: 5}变成"哪个面接的就是哪个答案" —— 正是 #5146/#5332/#5567 各花一轮消掉的分叉。like-pattern.ts的escapeLikePattern(value: unknown)本来就String(),所以 SQL 侧两个发射器零改动、#5567 的 29 例锚定测试(含与applyLike的逐字符比对)原样通过;引擎侧把convertFilter的四条 LIKE 臂显式写成String(v0),让这个 PRODUCER 交给引擎的FilterCondition仍符合 spec 的z.string()声明。共享的宽容已追评到既有单 #5234(该单第 2 条就是 LIKE 族对象比较值被String()成[object Object]),不另开孪生单。副作用两条,都朝 fail-closed,且都已钉住:
{name: {$contains: null}}原先是LIKE '%%'—— 匹配每一个非 NULL 行,一个真实的静默放宽(根因是stringifyForCube(null)返回'');现在是LIKE '%null%',与driver-sql一直以来的编译结果收敛。{amount: {$gt: null}}原先是amount > ''(针对空字符串的真实比较);现在绑 NULL,谓词 UNKNOWN、零行。分析查询的{field: {$eq: null}}/{$ne: null}编译成col = ''/col != '',与同文件里{field: null}的IS NULL自相矛盾 #5332 明确写过这个比较数位置"没有任何裁决覆盖、''只是占位符",删掉编码器等于按构造把它定了 —— 定在driver-memory/formula给出的那个答案上。3.
undefined在 leaf 处归一为null,但不动 #5332 的裁决。 JSON 没有undefined,{$eq: undefined}不是可授权形状;而values里真放一个undefined在 better-sqlite3 上是绑定错误、不是谓词。所以comparand()只做这一件事。刻意不把 #5332 的=== null判定放宽成== null:那条判定决定算子的含义(空值谓词),归一只让值可绑定,含义的改动是 #5332 的事,不能当成删编码器的副作用。{$eq: undefined}因此仍编译成equalsleaf(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 三分处置(逐个判,不批量重写)
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.tsobjectql-strategy-boolean-filter.test.ts/like-metacharacter-escape.test.ts/filter-normalizer-not-null-safe.test.ts{$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 绑什么 / 引擎绑什么。测的是真编码器,而不是手写的中间字符串。string[]值往返对字符串比较数也有损:{code: {$eq: '007'}}绑成数字7、'null'绑成真 NULL、'true'绑成1—— 文本列静默取到错行 #5526 root cause NOT fixed here — the token collision is unchanged" 那一块改判为已修,并补上行集证据。'null'的行紧挨着存真 NULL 的行,存文本'true'的行紧挨着存'1'的行 —— 精确行集断言(toEqual,不是toContain)。dateRange的声明类型、undefined归一、$gt: null的绑定与行集、Date 比较数的同一引用(证明没有任何东西重编码过它)。反向验证(先预测方向,再跑)
预测:把删掉的往返装回去(编码器 + SQL 解码器),新钉子应当修前红 / 修后绿 —— 这里没有计数型闸门,规范键也不在任何
??链首位,所以不预期 #5009 那种反转。实测,与预测一致。 把
comparand()的函数体换回stringifyForCube、把toSqlBindValue的函数体换回coerceFilterValueForSql(scratch patch,已还原),filter-value-type-fidelity.test.ts转红,包含全部头条用例:一处如实说明:那个 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(改前基线)pnpm --filter @objectstack/rest test(消费半径)tsc --noEmit(本包无 typecheck 脚本,在 #4311 DEBT 账上)pnpm --filter @objectstack/service-analytics buildnode scripts/check-nul-bytes.mjsgrep -naP控制字节自扫改动文件pnpm check:adr-anchorspnpm check:type-check-coveragepackages/spec未改动,故无 os-regen 生成物需要重跑。一处需要 PM 知道的观察:途中pnpm install/ spec build 的gen:schema两次把packages/spec/authorable-surface.base.json的baseRev重写成我的 HEAD 并补了两个EmailServiceConfig键(那两个键在 main 的authorable-surface.json里本来就有)。这与本 PR 无关,已git restore掉、未进提交(按 AGENTS.md 多代理纪律第 1 条),PR diff 里没有它。未变更的相邻契约
{field: {$eq: null}}/{$ne: null}编译成col = ''/col != '',与同文件里{field: null}的IS NULL自相矛盾 #5332 / fix(service-analytics): 分析查询里的null比较数是 null 谓词,不是= ''(#5332) #5525):真null比较数仍编译成notSet/set(空values),{stage: null}/{$eq: null}/{$ne: null}/{$null:}/{$exists:}五种拼法仍同一谓词;$not的语义在 driver-sql 与 driver-memory / formula 之间分叉:NULL 行的去留相反,$not: {}一个是 TRUE 一个是 FALSE #5146 的 guard table 未动。行集用例三条。$contains: '_admin'命中xyadmin、$contains: '50%'命中off 5012 now—— driver-sql 自己把这条旁路标为 P0 —— 实测 #5567):like-metacharacter-escape.test.ts29 例零改动通过,含与driver-sqlapplyLike的逐字符比对;两个 SQL 发射器的 LIKE 分支一行未改。filter-refusal-envelope.test.ts的拒绝侧零改动 —— 本 PR 不新增也不撤销任何拒绝。Generated by Claude Code