fix(service-analytics): analytics 三个 SQL 编译器转义 LIKE 比较值,并绑显式 ESCAPE (#5567) - #5587
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 14 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
|
Out-of-scope 发现,按 Prime Directive #10 单独立单、未在本 PR 内修:
这正是本 PR「方言确认」一节只能引手册 + 在 SQLite 上模拟、而拿不出真实 PG/MySQL 实测的原因,所以在这里点明来源。不在本 PR 修:落点是 Generated by Claude Code |
|
补一条佐证:「字面量」这个读法不是我在两种解释里选的一种,本包第四个
case '$contains': return String(value ?? '').toLowerCase().includes(String(expected ?? '').toLowerCase());
顺带确认没有第四个拼 LIKE pattern 的地方: Generated by Claude Code |
Fixes #5567
问题
LIKE 族四个算子(
$contains/$notContains/$startsWith/$endsWith)把作者写的字面量包进通配符。本包三个 SQL 编译器都是直接字符串拼接,零转义、无ESCAPE子句,于是_(单字符通配)和%(多字符通配)不再是字面量。正文实测行集在
origin/main上逐字复现(真 SQLite /sql.js,4 行x_admin/xyadmin/off 50% now/off 5012 now):三处都是放大方向;
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.tscompileOperator四臂 + 新增bindLike()src/strategies/native-sql-strategy.tsbuildFilterClause(likePattern表 →likeShape)src/strategies/objectql-strategy.tsLIKE_SQL_OPS/analytics/sql回显回显一起改是必须的,不是顺手:它描述的那次执行经引擎走到
driver-sql,而applyLike一直是转义的 —— 只修执行侧会让回显重新与执行分叉(#3601 / #3602 / #3650 那一族,#5333 刚为这张表付过一次代价)。helper 落点:包内自建
service-analytics的package.json只依赖@objectstack/core与@objectstack/spec,不依赖任何驱动;而applyLike是 knex builder 上的私有方法,签名收 builder + field 而不是字符串 —— 即使有依赖也没有可导入的东西。为三个同包调用点向@objectstack/core新开一个公开面不值当,所以按派发要点选包内自建,未新增任何包外公开面(index.ts未导出)。防第三份手抄的责任没有落在注释上:
like-pattern.ts与sql-driver.ts的applyLike两处 TSDoc 互指(driver-sql 侧本 PR 只加注释,不改实现),真正的约束是新测试里那条断言 —— 它把escapeLikePattern与applyLike的表达式逐字符比对,任一侧漂移即红。方言确认
ESCAPE子句加在哪一层:完全在谓词层。read-scope-sql.ts发的是?,它的两个消费者(NativeSQLStrategy.applyReadScope、ObjectQLStrategy.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 未在本仓跑到真实引擎,以文档为准并如实标注):
ESCAPEESCAPEclause")\("If you do not specify the ESCAPE character,\is assumed, unless theNO_BACKSLASH_ESCAPESSQL mode is enabled")即:显式子句在 PG / MySQL 上是把默认值写明,在 SQLite 上是唯一能让转义生效的办法。
两半是一个修复,不是两个改进 —— 这一点我没有只写在注释里,而是直接对引擎实测并固化成用例:
验证
pnpm --filter @objectstack/service-analytics test:51 files / 854 tests 全绿(修前:新测试文件 18 failed / 8 passed)。driver-sqltsc --noEmit干净。另跑rest的 analytics 用例(33 绿)、driver-sql的sql-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 例)覆盖: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, 转义符];NativeSQLStrategyparams 相等,且把回显语句真跑一遍取行集;plain这类无元字符比较值,三处 pattern 与改动前逐字节相同(唯一变化是追加的ESCAPE绑定);filter-operator-coverage.test.ts那套比较值(one/alpha/-one)本就不含元字符,未受影响。反向验证的方向:字面
\那一例是例外,如实记录其余四族都是标准的修前红 / 修后绿。但字面反斜杠比较值那一例,在本套件唯一能跑的引擎(SQLite)上不是修前红,把它写成红会是编造:
\只有在已有转义符生效时才是元字符。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