Skip to content

fix(service-analytics): read scope 的 { $not: {} } 不再整表放行,$not 改为 NULL-safe (#5297) - #5326

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5297-read-scope-not-null-safe-and-empty
Aug 4, 2026
Merged

fix(service-analytics): read scope 的 { $not: {} } 不再整表放行,$not 改为 NULL-safe (#5297)#5326
os-zhuang merged 1 commit into
mainfrom
claude/issue-5297-read-scope-not-null-safe-and-empty

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5297

packages/services/service-analytics/src/read-scope-sql.ts 是 RLS / 租户 read scope 降解成
SQL 的唯一通道(ADR-0021 D-C),被 NativeSQLStrategy.applyReadScopeObjectQLStrategy
用来给分析查询加可见性约束。它以空字符串表示布尔常量 TRUE。两处忘掉「空串是一个值」的地方,
正是本单的两条分叉。

现场核对(STALE-PREMISE)

issue 今天 16:07 写成,origin/main 此后又前进了 8 个 commit。worktree 基于
26e1029f5。逐条核对 issue 引用的现场:

issue 引用的现场 核对结果
compileNode$not 分支(const inner = compileNode(value, …); if (inner) …) 仍成立,原文一字不差
applyReadScopeif (!sql) return; 仍成立(native-sql-strategy.ts:284)
第 54-56 行 $and / $or 空数组 fail-closed 抛错 仍成立,正是 54-56 行
$or.filter((s) => s.length > 0) 丢掉空析取项 仍成立(第 59 行)

read-scope-sql.ts 上一次被改动是 #5007,今天的 8 个 commit 一个都没碰它。今天改过本包的
PR #5287(#5115,跨 datasource JOIN 编译期拒绝)动的是 dataset-compiler.ts /
analytics-service.ts / index.ts,与本单文件零交叠,合并后无冲突。

分叉二(要害):{ $not: {} } 的 read scope 完全不加 WHERE

compileNode({}) → ''  →  if (inner) 为假  →  $not 不产出任何子句
                      →  compileScopedFilterToSql 返回 ''
                      →  applyReadScope 的 `if (!sql) return;` 接手
                      →  生成的 SQL 里没有 WHERE

一条语义为 NOT TRUE ≡ FALSE(什么都不给看)的 read scope,实际是整张表都给看
同一段循环里 $and / $or 的空数组一直是 fail-closed 抛错的,只漏了 $not 这一格

修法:引入 FALSE_CLAUSE = '1 = 0'(与本文件 $in: [] 已有的写法、以及 driver-sql 在
#5134 / PR #5243 上的口径一致),$not 的操作数编译为空串时压入恒假子句。applyReadScope
因此照常拼进 WHERE,返回零行。

同源、方向相反的一处一并修正:{} 是 TRUE 析取项,而 TRUE 吸收整个析取,所以
{ $or: [{}, { a: 1 }] } 整条为 TRUE,不再被 .filter((s) => s.length > 0) 收紧成 a = 1
$and 那一侧不变 —— TRUE 是合取的单位元,丢掉本来就对。

顺带修掉一处只有在这次改动之后才会发作的绑定错位:一个分组是否为布尔单位元,只有等它的
子节点都编译完才知道,而编译子节点会往 params 里追加值。如果照着旧写法直接绑进父数组、再把
子句丢掉,那些值就留在 params 里没有 ? 消费,把后面每一个占位符错位到别人的值上 ——
一条绑错租户 id 的 read scope 比一条过宽的更糟。所以每个子节点先编译进自己的 params 缓冲
(compileSub),存活了才提交。测试里有一条专钉这个。

分叉一:$not 改为 NULL-safe

语义已由维护者在 #5146 拍板,PR #5296(5aae79096)已在 driver-sql 落地 —— 本文件此前是
仓内最后一个按三值逻辑回答 $not 的 SQL 家族实现

-- 之前
NOT ("t"."stage" = ?)
-- 现在
NOT (("t"."stage" IS NOT NULL AND "t"."stage" = ?))

sql-driver.tsnullSafeNegationOperand 口径实现:纯函数、逐算子极性表、守卫
下推到叶子而不是挂在 NOT 旁边(操作数一嵌套两者就不等价 —— $not 里套 $or 时,
顶层的 OR col IS NULL 会把 JS 家族排除的行重新放进来)。极性按算子逐个判定而不是一刀切:
{ $not: { a: { $ne: 5 } } } 语义是「a 就是 5」,无条件加 OR a IS NULL 会把 scope 排除的
行交回去。

与 driver-sql 极性表的差异(逐条列出)

本编译器支持的算子集与 driver-sql 不完全相同,按 issue 的要求以本文件实际支持的为准:

差异 本文件 driver-sql 理由
$null / $exists 的读法 真值性(val ? … : …) 按与 false恒等 各自匹配自己的 emitter。本文件 compileOperator 写的是 val ? IS NULL : IS NOT NULL,守卫必须和它读法一致,否则两者会在非布尔值上分歧。不变量是「守卫跟着自己的 emitter」,不是那行字面量。
$between 在本文件的词表内,走正向比较的默认值(值缺失不落在两个边界之间) 不在那张表里 $gt / $in / $contains 一族同类,取同一个默认。
{ field: {} } 本文件抛错(fail-closed),守卫返回 'none' 原样透传,以保留原错误 编译成空 SQL,#5240 刻意不裁定 本文件根本不接受这个形状,所以 #5240 在这里不存在,也不会被本 PR 顺手裁定。

$not 路径以外一个字符未动,fail-closed 的全部保证原封不动:未知算子、嵌套关系值、
裸数组、不安全标识符、非 filter 节点的 $not 操作数,以及 $and: [] / $or: [] 的空组合子
(那一格是 #5322 的独立裁定,本 PR 刻意不碰)统统照旧抛错 —— 每一条都有用例钉着。

测试

read-scope-sql-conformance.test.ts 已在跑共享的 FILTER_LOGIC_CASES,但那张表刻意不含
null 行、也不含布尔单位元
(归 spec 车道 #5239 / #5146 的 spec 半边),所以两条缺陷在现有
门禁下都是绿的。本 PR 自带 pin:
packages/services/service-analytics/src/__tests__/read-scope-not-null-safe.test.ts(36 例,
sql.js执行取行,不是断言字符串)。

关键的一条断言到 NativeSQLStrategy.generateSql 这一层 —— 缺陷正在编译器与
applyReadScope 的接缝处
,只测 compileNode 抓不到。

参照答案是实测得到的:fixture 与 driver-sqlsql-driver-not-null-safe.test.ts 逐行
相同,每一组 id 都与该文件及两个 JS 后端的 pin
(formula/src/matches-filter-not-null-safe.test.ts
driver-memory/src/memory-matcher-not-null-safe.test.ts)对同一 filter 的答案一致,写用例时
三套都跑过。

Test Files  43 passed (43)
      Tests  591 passed (591)

反向验证(stash 掉 read-scope-sql.ts,新用例必须红)

36 例中 21 例失败,其余 15 例是 fail-closed / 未改动行为的用例,本就该保持绿。失败原文节选:

 ❯ src/__tests__/read-scope-not-null-safe.test.ts (36 tests | 21 failed) 96ms

 FAIL  … > `{$not: {}}` is FALSE, and the strategy actually applies it
       > reaches the generated analytics SQL as a real WHERE — the bypass seam
AssertionError: expected 'SELECT stage AS "stage", SUM(amount) …' to contain 'WHERE'

Expected: "WHERE"
Received: "SELECT stage AS "stage", SUM(amount) AS "revenue" FROM "deal" GROUP BY stage"

 FAIL  … > admits zero rows — NOT TRUE ≡ FALSE
AssertionError: expected [ '1', '2', '3', '4' ] to deeply equal []

 FAIL  … > compiles to a constant-false clause instead of the empty string
AssertionError: expected { sql: '', params: [] } to deeply equal { sql: '1 = 0', params: [] }

 FAIL  … > a `{}` disjunct makes the whole `$or` TRUE > compiles to no constraint, and binds nothing
AssertionError: expected { sql: '("t"."owner" = ?)', …(1) } to deeply equal { sql: '', params: [] }

第二条 Received 就是这一单的要害:一条本该零行的 read scope,生成的 SQL 里没有 WHERE

覆盖到的行为

用例组 内容
{$not:{}} 编译成 1 = 0;取行为零;generateSql 确实产出 WHERE;与真实谓词同层嵌套仍为 FALSE;{$not:{$not:{}}} 回到 TRUE
$or{} 析取项 整条为 TRUE、绑定为空;取行为全部;丢弃分支的绑定值一并丢弃(无孤儿 param);$and{} 成员行为不变
$not NULL-safe 隐式等值 / 多列 / CEL !(stage == 'won') 形状;经 generateSql 的形状与绑定
嵌套 $not of $or(守卫必须在叶子才对的那一例)、$not of $and、双重否定、$not 与兄弟键 AND、$or 分支里的 $not
逐算子极性 $ne / $nin 不被放宽;$in / $gt / $between / $contains / $startsWith / $endsWith 返回 NULL 行;$notContains 的镜像case;$null / $exists / $eq: null / $ne: null 一个字节不加;空 $in / $nin 的常量值
未改动 普通比较的 SQL 与行数逐条不变($ne 在否定之外仍是三值逻辑)
fail-closed $and / $or($not 之内)、未知算子、嵌套关系值、裸数组、零算子 spec、非 filter 节点操作数、不安全标识符 —— 全部仍抛错

验证命令

结果
pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2 43 files / 591 tests passed
npx tsc --noEmit -p tsconfig.json(该包无 typecheck script) 7 处报错,与改前 stash 的基线逐字节相同(全在 analytics-service.test.ts / measure-source-field-gate.test.ts / objectql-timedimension-projection.test.ts,均为既有问题,不在本 PR 文件面内)
npx eslint(两个改动文件) 干净,0

changeset

.changeset/read-scope-not-null-safe-and-empty.md(@objectstack/service-analytics: patch)。
正文明确写明这是一次安全相关的行为变更:原本 { $not: {} } 的 read scope 不加任何
WHERE、整表可见,修复后为零行;以及 $not 下 NULL 行的去留变化会改变分析查询的行数与图表
数值。没有轻描淡写。

范围外


🤖 Generated with Claude Code

https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7


Generated by Claude Code

…NULL-safe (#5297)

`read-scope-sql.ts` 是 RLS / 租户 read scope 降解成 SQL 的唯一通道(ADR-0021
D-C),它以空字符串表示布尔常量 TRUE。两处忘掉「空串是一个值」的地方,正是本单
要修的两条分叉。

分叉二(要害):`compileNode({})` 返回空串 → `if (inner)` 为假 → 整条 `$not` 不
产出子句 → `compileScopedFilterToSql` 返回 '' → `applyReadScope` 的
`if (!sql) return;` 接手 → 生成的 SQL 里没有 WHERE。语义为 `NOT TRUE ≡ FALSE`
(什么都不给看)的 read scope,实际整表放行。同一段循环里 `$and` / `$or` 的空数组
一直是 fail-closed 抛错的,只漏了 `$not` 这一格。现在编译为恒假子句 `1 = 0`,与
driver-sql 在 #5134 / PR #5243 上的口径一致。

同源、方向相反的一处一并修正:`$or` 的空析取项 `{}` 以前被 `.filter(s.length > 0)`
丢掉,`{ $or: [{}, { a: 1 }] }` 收紧成 `a = 1`。`{}` 是 TRUE 析取项,TRUE 吸收整个
析取,现在整条 `$or` 为 TRUE。被丢弃分支的绑定值随之丢弃 —— 每个子节点先编译进自己
的 params 缓冲、存活了才提交,否则 `params` 里会留下没有 `?` 消费的值,把后面每一个
占位符错位到别人的值上(一条绑错租户 id 的 read scope 比一条过宽的更糟)。

分叉一:`$not` 改为 NULL-safe。语义由 #5146 拍板,PR #5296 已在 driver-sql 落地;
本文件此前是仓内最后一个按三值逻辑回答 `$not` 的 SQL 家族实现。操作数在取反前先被
改写成全域谓词(`nullSafeNegationOperand`),守卫按算子极性下推到叶子而不是挂在
`NOT` 旁边 —— 操作数一嵌套,顶层的 `OR col IS NULL` 会把 JS 家族排除的行重新放进来。
极性表与 driver-sql 逐条对齐,两处刻意的差异写在 TSDoc 里:`$null` / `$exists` 按本
文件 emitter 的真值性读取(driver-sql 的 emitter 按与 false 的恒等读取),以及本文件
多出的 `$between` 走正向比较的默认值。

`$not` 路径以外一个字符未动,fail-closed 的全部保证原封不动 —— 未知算子、嵌套关系
值、裸数组、不安全标识符、非 filter 节点的操作数,以及 `$and: []` / `$or: []` 的空
组合子(#5322 的独立裁定)统统照旧抛错。

测试:`read-scope-not-null-safe.test.ts` 是本单自带的 pin。既有的
`read-scope-sql-conformance.test.ts` 跑的 `FILTER_LOGIC_CASES` 刻意不含 null 行、
也不含布尔单位元,两条缺陷因此都在现有门禁下绿着。新用例在 sql.js 上执行取行,
`{$not:{}}` 一条断言到 `NativeSQLStrategy.generateSql` 这一层 —— 缺陷正在编译器与
`applyReadScope` 的接缝处,只测 `compileNode` 抓不到。

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

vercel Bot commented Aug 4, 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 4, 2026 8:41pm

Request Review

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

github-actions Bot commented Aug 4, 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.

@os-zhuang
os-zhuang marked this pull request as ready for review August 4, 2026 20:47
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 2cca98b Aug 4, 2026
24 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5297-read-scope-not-null-safe-and-empty branch August 4, 2026 20:53
os-zhuang pushed a commit that referenced this pull request Aug 4, 2026
Second relay merge: #5300 / #5304 / #5306 / #5308 / #5318 / #5326 / #5327.
Textually clean, but the os-regen driver defers generated artifacts rather than
text-merging them, so `json-schema.manifest.json` again came out holding this
branch's pre-merge side — this time still listing `ui/EmbedConfig` and
`ui/NotificationAction`, both retired by #5300. Reset the deferred artifacts to
`origin/main`, rebuilt from the merged tree, regenerated wholesale.

Post-regen assertions (a silent one-side drop is exactly what this catches):
api-surface delta vs `origin/main` is exactly this PR's four additions and ZERO
removals; manifest delta is one addition (`ui/ViewItemWire`) and zero removals;
every sibling retirement stays removed (`ui/EmbedConfig`, `ui/NotificationAction`,
`system/HttpServerConfig`, `ui/Animation`, `ui/ZIndex`) and every sibling
addition stays present (`FilterArray` ×7, `EmailProvider` ×2).
`check:authorable-surface` (+ its #5304 `.base.json` anchor) is green and the
anchor file is byte-identical to `origin/main` — not hand-edited.

`metadata-form-zod-reconciliation.test.ts` co-edited with #5280/#5318 and merged
SEMANTICALLY, not by taking a side: #5318 rewrote the docblock, imports, helpers
and test bodies, while this PR's only edit is `unwrap`'s `pipe` case, so the two
did not overlap textually — but they do interact, and in the direction that
matters. #5318's `isRetiredAt` / `authorableKeysOf` both route through
`unwrap`/`keysOf`, and `view`'s root is now a `z.preprocess` pipe. Measured both
ways: without this PR's #4488-style fix `unwrap(view root)` resolves to
`transform` and `keysOf` returns NULL, so #5318's brand-new tombstone assertions
would be VACUOUS on `view` (and the pre-existing key-bearing assertion would
fail outright); with it, 89 keys. Both PRs' assertions are live on every type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB
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

Development

Successfully merging this pull request may close these issues.

read-scope-sql 的 $not 有两处与 SQL 驱动分叉:非 NULL-safe(#5146 后的最后一个异类),且 { $not: {} } 编译成空 → RLS 整表放行

2 participants