Skip to content

fix(service-analytics): analytics 三个 SQL 编译器转义 LIKE 比较值,并绑显式 ESCAPE (#5567) - #5587

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5567-like-escape
Aug 5, 2026
Merged

fix(service-analytics): analytics 三个 SQL 编译器转义 LIKE 比较值,并绑显式 ESCAPE (#5567)#5587
os-zhuang merged 2 commits into
mainfrom
claude/issue-5567-like-escape

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5567

问题

LIKE 族四个算子($contains / $notContains / $startsWith / $endsWith)把作者写的字面量包进通配符。本包三个 SQL 编译器都是直接字符串拼接,零转义、无 ESCAPE 子句,于是 _(单字符通配)和 %(多字符通配)不再是字面量。

正文实测行集在 origin/main 上逐字复现(真 SQLite / sql.js,4 行 x_admin / xyadmin / off 50% now / off 5012 now):

AssertionError: expected [ '1', '2' ] to deeply equal [ '1' ]   ← {$contains: '_admin'}
AssertionError: expected [ '3', '4' ] to deeply equal [ '3' ]   ← {$contains: '50%'}

三处都是放大方向;read-scope-sql.ts 编的是 ADR-0021 D-C 读作用域(tenant + RLS),放大即越权而非降级的筛选(#5347 / #5324 在同一文件立的规矩)。PD #3 强制 machine name 用 snake_case,所以几乎每个机器名比较值都带 _,而且完全无声。

修法

新增包内 src/like-pattern.ts(escapeLikePattern / likePattern / LIKE_ESCAPE_CHAR),三个编译器全部走它,各自绑 LIKE ? ESCAPE ?:

文件 改动点 角色
src/read-scope-sql.ts compileOperator 四臂 + 新增 bindLike() 读作用域降级
src/strategies/native-sql-strategy.ts buildFilterClause(likePattern 表 → likeShape) 实际执行
src/strategies/objectql-strategy.ts LIKE_SQL_OPS /analytics/sql 回显

回显一起改是必须的,不是顺手:它描述的那次执行经引擎走到 driver-sql,而 applyLike 一直是转义的 —— 只修执行侧会让回显重新与执行分叉(#3601 / #3602 / #3650 那一族,#5333 刚为这张表付过一次代价)。

helper 落点:包内自建

service-analyticspackage.json 只依赖 @objectstack/core@objectstack/spec,不依赖任何驱动;而 applyLike 是 knex builder 上的私有方法,签名收 builder + field 而不是字符串 —— 即使有依赖也没有可导入的东西。为三个同包调用点向 @objectstack/core 新开一个公开面不值当,所以按派发要点选包内自建,未新增任何包外公开面(index.ts 未导出)。

防第三份手抄的责任没有落在注释上:like-pattern.tssql-driver.tsapplyLike 两处 TSDoc 互指(driver-sql 侧本 PR 只加注释,不改实现),真正的约束是新测试里那条断言 —— 它把 escapeLikePatternapplyLike 的表达式逐字符比对,任一侧漂移即红。

方言确认

ESCAPE 子句加在哪一层:完全在谓词层read-scope-sql.ts 发的是 ?,它的两个消费者(NativeSQLStrategy.applyReadScopeObjectQLStrategy.generateSql)都把 ? 重编号成 $N 并同步 push 取值 —— 因为转义符是绑定值而不是 SQL 字面量,它被这套重写免费带过去,上层两个消费者一行没改。

转义符为什么绑而不写成字面量:MySQL 在字符串字面量里套用 C 转义语法(官方手册:"If you want a LIKE string to contain a literal \, you must double it"),所以反斜杠转义符在 MySQL 要写 '\\'、在 SQLite / Postgres 写 '\' —— 这三个编译器不知道自己的输出会被哪个方言执行。绑定值由驱动按自己的方言转义,这里就只有一种写法。这也正是 applyLike 的做法(ESCAPE ? + '\\'),对齐即零新增方言风险。

三方言口径(引各家官方手册,PG / MySQL 未在本仓跑到真实引擎,以文档为准并如实标注):

方言 默认转义符 显式 ESCAPE
PostgreSQL 反斜杠("The default escape character is the backslash but a different one can be selected by using the ESCAPE clause") 支持
MySQL \("If you do not specify the ESCAPE character, \ is assumed, unless the NO_BACKSLASH_ESCAPES SQL mode is enabled") 支持;参数"must evaluate as a constant at execution time" —— 绑定占位符满足该条件
SQLite 默认转义符 支持,且必需

即:显式子句在 PG / MySQL 上是把默认值写明,在 SQLite 上是唯一能让转义生效的办法。

两半是一个修复,不是两个改进 —— 这一点我没有只写在注释里,而是直接对引擎实测并固化成用例:

A. 原始 pattern,无 ESCAPE(修前)     : [ '1', '2' ]   ← issue 的实测行
B. 已转义 pattern,无 ESCAPE 子句      : []            ← 只转义 = 零行,比放大更糟
C. 已转义 pattern + 绑定 ESCAPE(修后) : [ '1' ]        ← 正确
D. 原始 pattern + 只加 ESCAPE 子句    : [ '1', '2' ]   ← 只加子句 = 什么都没变
H. 绑定的 ESCAPE 实参被接受           : [ '3' ]        ← 绑定而非字面量的前提

验证

pnpm --filter @objectstack/service-analytics test:51 files / 854 tests 全绿(修前:新测试文件 18 failed / 8 passed)。driver-sql tsc --noEmit 干净。另跑 rest 的 analytics 用例(33 绿)、driver-sqlsql-driver-like-escape.test.ts(3 绿)、check:nul-bytes / check:type-check-coverage / check:wildcard-fallthrough / check:driver-conformance / check:adr-anchors / check:role-word、以及 spec 的 check:generated(10/10,因合并带入 spec 改动)。

新增 src/__tests__/like-metacharacter-escape.test.ts(29 例)覆盖:

  • 正文两张表修前红 / 修后绿,行集全部 exact-set(toEqual),每个元字符行都配一个只在通配读法下才会被命中的诱饵行,断言不可能靠巧合通过;
  • 正文只测到绑定层的 $startsWith / $endsWith / $notContains 各补齐行集:{$startsWith: 'x_'} ['1','2']['1']{$endsWith: '0% now'} ['3','4']['3']{$notContains: '_admin'} ['3','4','5','6']['2','3','4','5','6'](缩小方向,多排除了作者留下的那一行);
  • 三个编译器的绑定层同表驱动,同一个算子/比较值断言三处绑出同一 [pattern, 转义符];
  • 回显与执行同 fixture 行集一致:回显 params 与 NativeSQLStrategy params 相等,且把回显语句真跑一遍取行集;
  • 零回归:plain 这类无元字符比较值,三处 pattern 与改动前逐字节相同(唯一变化是追加的 ESCAPE 绑定);filter-operator-coverage.test.ts 那套比较值(one / alpha / -one)本就不含元字符,未受影响。

反向验证的方向:字面 \ 那一例是例外,如实记录

其余四族都是标准的修前红 / 修后绿。但字面反斜杠比较值那一例,在本套件唯一能跑的引擎(SQLite)上不是修前红,把它写成红会是编造:

E. 原始 pattern,无 ESCAPE(SQLite 修前)   : [ '5' ]   ← `\` 在这里本就是字面量
F. 原始 pattern + ESCAPE(= PG/MySQL 默认) : [ '6' ]   ← `\b` 被读成字面 `b`:既漏又误命中
G. 已转义 pattern + ESCAPE(修后)          : [ '5' ]   ← 各方言统一

\ 只有在已有转义符生效时才是元字符。SQLite 没有默认转义符,所以修前 %a\b% 在那里碰巧是对的;同一个修前 pattern 在 PG / MySQL(默认转义符就是 \)上读成字面 b,匹配到 p ab q 而漏掉 p a\b q —— 一个比较值同时造成漏与误命中。因此这一例钉在能在本引擎上验证的层面(绑出的 pattern 字符串,修前红:['%a\b%'] vs ['%a\\b%', '\'])+ 行集非回归,方言那半用上表(在 SQLite 上模拟 PG/MySQL 的文档化默认)与手册引文论证,并在测试注释里明确标注这是模拟、不是在那两个引擎上跑的。

范围

三个编译器 + 同包测试 + changeset(patch)。driver-sql 仅加 TSDoc 互指,不动实现(#5499 明确 driver-sql 不在冻结范围)。未碰 filter-normalizer.ts(#5526-B)。无 out-of-scope 发现需另立单。

🤖 Generated with Claude Code

https://claude.ai/code/session_01BWS4heBoAitLmzCLhcYdbK


Generated by Claude Code

claude added 2 commits August 5, 2026 19:45
…lers (#5567)

The four LIKE-family operators wrap the author's comparand in wildcards. All
three SQL compilers in this package concatenated it straight into that pattern
position with no escaping and no ESCAPE clause, so `_` (single-character
wildcard) and `%` (multi-character) stopped being literals:

  {name: {$contains: '_admin'}}  returned ['1','2'], should be ['1']
  {name: {$contains: '50%'}}     returned ['3','4'], should be ['3']

Every direction is a WIDENING, and read-scope-sql.ts is the ADR-0021 D-C
read-scope lowering, where that is over-reach rather than a loose filter
(#5347 / #5324 on the same file). Prime Directive #3 forces machine names to
snake_case, so essentially every machine-name comparand carries a `_`.

Adds a package-local like-pattern.ts (escapeLikePattern / likePattern /
LIKE_ESCAPE_CHAR) and routes all three compilers through it, each binding
`LIKE ? ESCAPE ?`:

  - read-scope-sql.ts       compileOperator, four arms
  - native-sql-strategy.ts  buildFilterClause — the statement that executes
  - objectql-strategy.ts    LIKE_SQL_OPS — the /analytics/sql echo

The echo moves with the other two on purpose: its execution goes through the
engine to driver-sql, which has always escaped, so leaving it raw would have
re-forked echo from execution (#3601 / #3602 / #3650).

The escape character is BOUND, never written as a SQL literal: MySQL applies C
escape syntax inside string literals so the literal spelling is dialect-specific,
and a bound value rides the existing `?` -> `$N` renumbering in both consumers,
which keeps the whole concern inside the predicate layer.

Second implementation of driver-sql's applyLike, deliberately — service-analytics
depends on no driver and applyLike is a private method over a knex builder. The
two are held to each other by an assertion in the new test, and applyLike's
TSDoc now points here.

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 7:54pm

Request Review

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

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-sql, @objectstack/service-analytics.

14 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/data-modeling/drivers.mdx (via @objectstack/driver-sql)
  • content/docs/getting-started/glossary.mdx (via @objectstack/driver-sql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/driver-sql, @objectstack/service-analytics)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/service-analytics)
  • content/docs/plugins/anatomy.mdx (via @objectstack/driver-sql)
  • content/docs/plugins/packages.mdx (via @objectstack/driver-sql, @objectstack/service-analytics)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/driver-sql)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/driver-sql)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/driver-sql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/driver-sql, @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.

Copy link
Copy Markdown
Contributor Author

Out-of-scope 发现,按 Prime Directive #10 单独立单、未在本 PR 内修:

这正是本 PR「方言确认」一节只能引手册 + 在 SQLite 上模拟、而拿不出真实 PG/MySQL 实测的原因,所以在这里点明来源。不在本 PR 修:落点是 driver-sql 的测试面,与本单的三个 analytics 编译器不是同一个范围面,而本 PR 对 driver-sql 的触碰严格限于一处 TSDoc 互指。


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

补一条佐证:「字面量」这个读法不是我在两种解释里选的一种,本包第四个 $contains 消费者一直就是这么读的。

src/preview-evaluator.ts:102(draft 预览求值,不产生 SQL,所以不在正文列的三个编译器里):

case '$contains': return String(value ?? '').toLowerCase().includes(String(expected ?? '').toLowerCase());

String.includes() —— 纯字面子串,零通配语义。也就是说修前 service-analytics 内部就带着两种 $contains 语义:预览是字面量、三个 SQL 编译器是 pattern;再加上包外 driver-sqlapplyLike 也是字面量。四个消费者里三个读字面量、只有这三个 SQL 编译器读 pattern —— 本 PR 是让那三个跟上,而不是引入新口径。

顺带确认没有第四个拼 LIKE pattern 的地方:grep 全包 src 已无未转义的 %${…} 包裹残留(like-pattern.ts 自身除外)。


Generated by Claude Code

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