Skip to content

fix(driver-memory): analytics 面不再把比较数往返成 string[](布尔取 0 行、null 取全表)(#5373) - #5431

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5373-cube-comparand-roundtrip
Aug 5, 2026
Merged

fix(driver-memory): analytics 面不再把比较数往返成 string[](布尔取 0 行、null 取全表)(#5373)#5431
os-zhuang merged 1 commit into
mainfrom
claude/issue-5373-cube-comparand-roundtrip

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5373

根因:一个编码,两个出口

MemoryAnalyticsServiceAnalyticsQuery.where 降级成 cube 风格的
{member, operator, values} 列表,而 valuesstring[] —— 所以每个比较数都要
走一遍 JS 值 → 字符串 → JS 值的往返。这个往返对任何本来就不是字符串的类型都是有损的,
issue 里的两个症状是同一个根因:

关键在于这个编码修不好:注释里给布尔转 '1'/'0' 的理由
(「downstream consumers expecting SQLite-style numeric booleans」)对生成 SQL 的那一半
成立,对 in-memory 那一半不成立
,而两半共用同一个编码。不存在一种 true 的字符串写法
能同时满足 WHERE is_active = ? 和 mingo 对存储布尔值的 $eq

采用 Route B:不再往返

values 内部改成 unknown[],比较数保持作者写下的样子,只在 generateSql 这个
真正需要 SQL 字面量的出口才字符串化。

不选 A/C 的理由:A 是在 issue 自己称为可疑的编码之上再加一层标记;C 会拒掉
{is_active: true, stage: {$nin: ['lost']}} —— 那正是 AnalyticsQuerySchema.where
自己的 docstring 示例。

前提已实测验证,不是假设

B 成立的前提是这个三元组现在是纯内部中间表示。动手前先验证了这一点:

检查项 结果
normalizeFilters / flattenFilterCondition / stringifyForCube / coerceFilterValue / toSqlLiteral 全部 private,仅在本文件内引用
index.ts 导出 只有 MemoryAnalyticsService 类和 MemoryAnalyticsConfig
IAnalyticsService 契约 只有 AnalyticsQuery / AnalyticsResult,不含该三元组
packages/spec 无任何声明
线上格式 反向证据:spec/src/api/analytics.test.tsruntime/src/http-dispatcher.test.ts 各有一条测试,断言 cube 风格的 filters 数组会被拒收

所以本 PR 未触碰一个 spec 字节service-analytics 里同名的 filter-normalizer.ts
是另一个包的独立实现,按 scope 围栏未动。

两个出口都改了

修法如果只让 mingo 满意、却让 SQL 表达另一件事,那只是把 bug 挪了个地方,所以两个出口
一起钉:

  • toSqlLiteral 现在拿到真值,不必再从文本里猜类型 —— TEXT 列的 '100' 会加引号,
    以前它和数字 100 长得一样,两者都不加引号地输出成 code = 100;
  • null 比较数编译成 IS NULL / IS NOT NULL,而不是 SQL 里恒不为真的 = NULL
    (本次修复前是根本不输出这个子句,也就是放大方向);
  • 布尔保留 SQLite 风格的 1/0 —— 那个理由对这一半一直是对的,只是不该被另一半共用。

时间类比较数仍然转换,但改为借用 driver 自己的存储形式规则(新增窄接口
filterComparandStorageForm,按声明的字段种类分派,#4047),而不是就地再写一个
toISOString()。在本面内重新推导一遍该规则,正是 #5240 裁定要关掉的分叉。

实测(issue 的 3 行固定数据,analytics 面 vs 同一 driver 的 find())

where 修复前 修复后 find()
{is_active: true} 0 2 2
{is_active: false} 0 1 1
{closed_at: null} 3 2 2
{code: '100'}(TEXT 列) 0 2 2
{is_active: {$ne: true}} 3 1 1
{closed_at: {$ne: null}} 3 1 1

issue 标注为未验证的第三个症状:已实测,是真的,且是同一根因、同一处修复覆盖 ——
TEXT 列存 '100'、比较数写 '100',往返成数字 100 后取到 0 行;generateSql 同时
输出了不加引号的 code = 100。顺带测出 issue 未列出的另外两条同根因放大:
{is_active: {$ne: true}}{closed_at: {$ne: null}} 都返回全表。

测试放在共享一致性覆盖里

新用例进的是 #5375 刚扩到 analytics 面的那个共享文件,而不是独立测试文件。原因:
FILTER_LOGIC_CASES 变化的是过滤器形状,而它的固定数据每一列都是字符串
(有意为之,好让其中没有一条是关于类型强转的)—— 这恰恰是它在本缺陷下全绿的原因。
新增的块变化的是比较数类型,守同一条不变量:与 find() 给出相同的行集,或者拒收。

回归证明(按 #5375 的做法,只回退源码改动、保留测试):

× boolean true selects the true rows
× a null comparand is a predicate, not an absent one
× a numeric-looking STRING stays a string
× negated boolean excludes only the matching rows
× a null comparand becomes a nullness test, not a comparison
× a numeric-looking string is quoted and a real number is not
  ...
Tests  11 failed | 56 passed (67)

失败方向与 issue 描述一致:布尔/字符串是空集,null/$ne 是全表;两个出口都失败。
恢复源码后 Tests 67 passed (67)

验证

  • vitest run(driver-memory 全包):491 passed (17 files)
  • pnpm --filter @objectstack/driver-memory typecheck:干净通过
  • eslint --no-inline-config(三个改动文件):无输出

范围外


Generated by Claude Code

…s through string[] (#5373)

`MemoryAnalyticsService` lowered `AnalyticsQuery.where` into a cube-style
`{member, operator, values}` list whose `values` was `string[]`, so every
comparand made a JS value → string → JS value round trip. That round trip is
lossy for anything not already a string, and both of the issue's symptoms are
the one root cause:

- `stringifyForCube(true)` → `'1'` → `coerceFilterValue('1')` → the NUMBER 1
  (the `/^-?\d+$/` arm wins), compared against a stored `true`. mingo compares
  cross-type as never-equal, so `{is_active: true}` matched zero rows.
- `flattenFilterCondition` opened with `if (raw == null) continue`, so
  `{closed_at: null}` produced no cube entry at all and the predicate vanished.
  Fewer constraints means MORE rows: the query widened to the full table. This
  is the #3948 direction and the more dangerous half — a widened chart looks
  exactly like a working chart.

Route B of the issue's three: stop round-tripping. `values` is `unknown[]`;
stringification happens only at the `generateSql` exit, where a SQL literal is
genuinely needed. A/C were rejected because A layers a tag on an encoding the
issue already calls suspect, and C would refuse
`{is_active: true, stage: {$nin: ['lost']}}` — `AnalyticsQuerySchema.where`'s
own docstring example.

B is affordable because the triple is a purely INTERNAL intermediate. Verified
rather than assumed before building on it: `normalizeFilters`,
`flattenFilterCondition`, `stringifyForCube`, `coerceFilterValue` and
`toSqlLiteral` are all private and referenced only in this file; `index.ts`
exports only the class; `IAnalyticsService` exposes no such shape; and the API
layer actively REJECTS a `{member, operator, values}` array on the wire
(`spec/src/api/analytics.test.ts`, `runtime/src/http-dispatcher.test.ts` both
assert the rejection). Zero spec bytes touched.

The encoding could not simply be made lossless: its own justification for
`'1'`/`'0'` ("downstream consumers expecting SQLite-style numeric booleans") is
true for the SQL exit and false for the in-memory one, and both exits shared it.

Both exits are fixed, because a fix that satisfied mingo while emitting SQL
meaning something else would only move the loss. `toSqlLiteral` now takes the
real value instead of guessing a type back out of text — a TEXT `'100'` is
quoted where it used to emit `code = 100` — and a null comparand becomes
`IS NULL` / `IS NOT NULL` rather than SQL's never-true `= NULL`.

Temporal comparands still convert, now via the driver's own storage-form rule
(new narrow `filterComparandStorageForm`, keyed on the declared field kind,
#4047) instead of an ad-hoc `toISOString()`, so a `Date` still meets a declared
`datetime` column and no second derivation of that rule appears in this face
(#5240).

The issue's unverified third symptom is REAL and fixed by the same change:
`{code: '100'}` against a TEXT column storing `'100'` round-tripped to the
number 100 and matched zero rows. Measured, along with two more of the same
root cause the issue did not list — `{is_active: {$ne: true}}` and
`{closed_at: {$ne: null}}` each returned the whole table.

Measured on the issue's 3-row fixture, analytics vs `find()`:

| where | before | after | find() |
|---|---|---|---|
| `{is_active: true}` | 0 | 2 | 2 |
| `{is_active: false}` | 0 | 1 | 1 |
| `{closed_at: null}` | 3 | 2 | 2 |
| `{code: '100'}` | 0 | 2 | 2 |
| `{is_active: {$ne: true}}` | 3 | 1 | 1 |
| `{closed_at: {$ne: null}}` | 3 | 1 | 1 |

Tests go in the shared conformance file beside the #5324/#5345 shape table
rather than a suite of their own. `FILTER_LOGIC_CASES` varies filter SHAPE over
an all-string fixture — deliberately, so nothing in it is about coercion —
which is exactly why every case stayed green through this defect. The new block
varies comparand TYPE and holds the same invariant: agree with `find()`, or
refuse. Reverting only the source change fails 11 of the new assertions, across
both exits.

Out of scope, not fixed here: #5374 (`$notContains` → bare mingo `{$not: 'x'}`),
held to follow serially since its call site is the value-compilation point this
PR changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7
@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 11:10am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/l labels Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-memory.

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

  • content/docs/data-modeling/drivers.mdx (via @objectstack/driver-memory)
  • content/docs/deployment/vercel.mdx (via @objectstack/driver-memory)
  • content/docs/getting-started/glossary.mdx (via @objectstack/driver-memory)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/driver-memory)
  • content/docs/permissions/authentication.mdx (via @objectstack/driver-memory)
  • content/docs/plugins/index.mdx (via @objectstack/driver-memory)
  • content/docs/plugins/packages.mdx (via @objectstack/driver-memory)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/driver-memory)
  • content/docs/releases/implementation-status.mdx (via @objectstack/driver-memory)

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.

@os-zhuang
os-zhuang marked this pull request as ready for review August 5, 2026 11:23
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 5, 2026
Merged via the queue into main with commit f7df82c Aug 5, 2026
24 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5373-cube-comparand-roundtrip branch August 5, 2026 11:31
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 6, 2026
…dicate that excludes rows (objectstack-ai#5374) (objectstack-ai#5445)

`MemoryAnalyticsService` mapped each cube operator to the NAME of a mingo
operator, and the call site filled that name in as
`matchStage[field] = {[name]: comparand}`. That shape can express "compare this
field to this value" and nothing else, so the two operators that need to WRAP
their comparand were pushed through it anyway.

`notContains` -> `'$not'` became `{name: {$not: 'et'}}`. mingo's `$not` takes a
regex or an operator expression; handed a bare scalar it constrains nothing, so
the predicate was emitted, appeared in the pipeline, and passed the whole table:
3 rows where `find()` returns 2. A predicate that is emitted and inert is
indistinguishable from a working one at the author's end — the same amplifying
direction as objectstack-ai#3948, arrived at a third way. objectstack-ai#5345 ruled on operators with NO
mapping, objectstack-ai#5373 on the comparand ENCODING; this is a mapping pointing at the
wrong target, and objectstack-ai#5431 did not shrink it (that call site now receives a real
value, which is orthogonal to what the operator layer does with it).

Route: let the map return a STRUCTURE rather than an operator name, which the
issue prefers and the code supports cleanly. `CUBE_OPERATOR_TO_MONGO_PREDICATE`
holds a builder per operator that returns the whole `{$op: …}` object, so
`notContains` can say `{$not: {$regex: …}}` and the CLASS of "this operator
needs a structure and the table can only hold a name" is gone rather than this
one instance. `$in`/`$nin`/`$lte`/`$exists`, which the call site had grown an
`if` chain for, are ordinary rows in that table now.

Measured on the issue's 3-row fixture, analytics vs `find()`:

| where                            | before | after | find() |
|----------------------------------|--------|-------|--------|
| {name:{$notContains:'et'}}       |      3 |     2 |      2 |
| {name:{$notContains:'a'}}        |      3 |     0 |      0 |
| {name:{$contains:'a.p'}}         |      1 |     0 |      0 |
| {name:{$contains:'ALPHA'}}       |      0 |     1 |      1 |
| {name:{$notContains:'ALPHA'}}    |      3 |     2 |      2 |
| {made_at:{$contains:'<full ISO>'}}|     1 |     0 |      0 |
| {code:{$in:[]}}                  |      3 |     0 |      0 |
| {code:[]}                        |      3 |     0 |      0 |

Three more defects at the same call site fall inside this fix and are closed
with it, because writing a correct `notContains` requires settling each:

- `contains` was the right operator with the comparand handed in RAW, so it was
  neither escaped (`.` matched any character) nor case-folded, while the live
  path escapes and matches `/…/i`. Leaving that would have made the two
  non-complementary in a new way — `alpha` would be in BOTH answers. The rule is
  now borrowed from the driver (new narrow `filterSubstringPattern`, alongside
  `filterComparandStorageForm`) rather than re-derived, per objectstack-ai#5240.
- An operand that is NOT a comparand went through the storage-form conversion
  anyway, so on a declared `datetime` column a `$contains` PATTERN was rewritten
  into canonical form and then matched rows `find()` does not match. The builder
  input carries both lists, the same split `normalizeFieldOperators` makes
  (objectstack-ai#4047).
- The call site's `values.length > 0` guard meant an empty `$in` emitted no
  predicate at all and widened to the whole table. A list operator taking the
  whole list has nothing to guard.

The two items the issue flagged as unmeasured, settled:

- `'inDateRange': '$gte'` compiles to NOTHING today — no `MONGO_TO_CUBE_OPERATOR`
  entry lowers to that name, `timeDimensions` never reaches this function, and
  both exits consume only `normalizeFilters` output. Dead, and wrong if it ever
  had been reached (a one-ended `>=` for a two-ended range, which its own
  comment conceded). Deleted, with the dead-and-inverted `'notSet': '$exists'`
  beside it.
- `opMap[operator] || '$eq'` is unreachable for the same reason — but only until
  someone widens the vocabulary, which objectstack-ai#5345 deliberately made a one-line edit
  to `MONGO_TO_CUBE_OPERATOR`. So it is not merely deleted: that table is `as
  const`, the predicate table is keyed by the operator union derived from it,
  and the widening edit now FAILS TO COMPILE until the predicate exists. The
  remaining throw is a totality floor, not a fallback.

Tests go in the shared conformance file beside the objectstack-ai#5345 shape table and the
objectstack-ai#5373 comparand-type table, as a third axis with the same invariant: agree with
`find()`, or refuse. Plus the "declared = enforced" half — every operator
`ANALYTICS_FILTER_CAPABILITIES` declares is driven through both faces and must
agree, with a probe that must exclude at least one row, so an operator added to
the vocabulary without a working lowering fails here instead of shipping a
quietly wrong number. Reverting only the source change fails 14 of the new
assertions.

Out of scope, filed not fixed: objectstack-ai#5440 (two operators on one field clobber each
other — the `$match` assembly layer, still broken after this), objectstack-ai#5442
(`flattenFilterCondition` spreads an array comparand for every operator), objectstack-ai#5444
(the `generateSql` exit emits `LIKE 'et'` with no `%` wildcards — filed as a
sub-issue of objectstack-ai#5433, whose completion scope it falls inside). `operatorToSql` and
`generateSql` are untouched.


Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 6, 2026
…in 滞后、死代码删除复核 (objectstack-ai#5513) (objectstack-ai#5645)

2026-08-05 跑完一整条 filter 缺陷链(objectstack-ai#5363 / objectstack-ai#5366 / objectstack-ai#5368 / objectstack-ai#5375 / objectstack-ai#5431 / objectstack-ai#5445,
cloud#1117)后回看,六处在那一轮真实咬过人或真实救过场的规程,SKILL 里没有对应条目。
六条各落在 issue 指定的节内,**纯增补**:111 行插入、0 行删除,既有条目(objectstack-ai#5501 的接力
模式、objectstack-ai#5522 的座位模型、objectstack-ai#5630 的 assertEngineDeleteDispatch 条款)一字未动。

落点与要点:

1. **Multi-repo,rule 2 之后**「pin 滞后」——`Blocked-by:` 只保证上游已合并,姊妹仓还有
   第二个读数:本仓 pin 是否覆盖那个 commit。cloud#1116 的裁决落于 framework objectstack-ai#5368
   (`9c5abf4e9`),而 cloud 的 `.objectstack-sha` 未覆盖它,于是 `TursoDriver` 有一个
   方向反了的分叉窗口(fail-closed 一侧先到)。规程:派发前核祖先关系;未覆盖则 dev 在
   PR 正文留档窗口与方向,⛔ pin bump 不做 rider。
2. **step 3** 末「阻塞解除后重新定价」—— 前一单合入会改变后一单的成本模型,方向不止一个
   (本轮变便宜、没变、成本估计过期各有实例)。两个动作配对:派发前一单时带必答项
   「你的改动是否让 #X 变简单 / 变难 / 不必要 / 无影响」,派发被延后那单前用该回答重读
   其选项与成本估计。
3. **step 5** 派发令「多面组件的测试落点」—— 同一契约 ≥2 实现面时,新用例进共享一致性
   覆盖而非独立文件(原话照录)。附 objectstack-ai#5375 / objectstack-ai#5431 / objectstack-ai#5445 三条正交轴共用一条不变量。
4. **step 7 清单**「收益穿过它必经的那道边界之后还在吗」—— 判据是价值主张是否依赖下游
   如实转发;实例即 objectstack-ai#5423(4xx 直通曾整条替换 ≥500 字符正文,`code` 到了正文没到)。
5. **step 7 清单**「死代码删除的复核」——「这是死代码」是断言而非能从 diff 读出的事实,
   PM 在 origin/main 独立核一次引用面再 ACCEPT(查法用 Operational notes 6:notes 6 说
   怎么查不假阴性,本条说什么时候必须查)。
6. **step 8** 升级门槛之后「带前提的裁决」—— 分歧关键是可被代码证伪的事实时,第三档 =
   裁决 + 前提验证要求 + 「前提不成立报 fork,不许硬做也不许悄悄改选」禁令,三件缺一
   不可;缺第 3 条即退化为无人裁决且无读数显示。

实施时两处核实结果与 issue 正文不同,成文按核实后的事实写:

- issue 的附带论断「没有任何闸门在量这个 pin 滞后」**不成立** —— cloud 的
  `scripts/check-pin-staleness.sh`(test.yml 以 `continue-on-error` 跑)每次 CI 都报两个
  pin 各落后 main 多少 commit,advisory 是**有意设计**(`--max-behind` 需显式传)。它答
  的是「落后多少」,不是「是否覆盖我这条裁决 commit」;成文因此指向该脚本,并只把后一个
  问题留给派发前的祖先判断。据此**未**另立「无闸门」的发现单。
- 第 4 条的 rest-server 缺陷本身已由 objectstack-ai#5423 按「截断而非替换」修掉,成文改用过去时并注明,
  以免后来的读者去找一个已不存在的活 bug;该条要补的是**复核清单的缺口**,与代码是否已修
  无关。

第 1 / 3 条按 issue「未验证的部分」的克制写入适用判据(前后单共用同一契约或数据表示;
组件对同一契约有 ≥2 实现面),形态迥异的批次(纯 UI、纯文档)明确不强加。

验证:`node scripts/check-nul-bytes.mjs --self-test` + 全仓扫描绿(48 断言 / 5537 文件);
改动文件自扫 `grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'` 零命中,并用邻近词反查证伪
「扫描器坏了」;`check:docs-audit-scope` 绿;markdown 结构核对(强调标记成对、代码围栏
16 个偶数、嵌套围栏缩进对齐)。

Claude-Session: https://claude.ai/code/session_01GX3sL71LFq8m2usg6VqTSE

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: os-zhuang <hr@objectstack.ai>
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