fix(driver-memory): refuse the filters the live query path cannot evaluate, compile the one it must (#5324, #5328) - #5349
Conversation
…luate, compile the one it must (#5324, #5328) `normalizeFilterCondition` had two ways of not refusing a filter it could not compile, in one `switch`: - `default: result[op] = val` handed every unrecognised `$op` to mingo, which answered with a `MingoError` carrying no `code` and no `status` — outside the ADR-0112 envelope, so a client mistake was served as a 500-shaped body (#5324); - the `$between` arm was written conditionally, so a comparand that was not a two-element array skipped it, the constraint vanished, and `find` returned `[]` (#5328). Opposite symptoms, one cause: the shape that could not be evaluated was not refused. #3948 and #4436 settled that an uncompilable filter is a loud refusal rather than a silent answer; this brings that rule to driver-memory's live query path, reusing `filter-refusal.ts`'s existing `INVALID_FILTER` / 400. `$not` goes the other way. It is a declared combinator (`LOGICAL_OPERATORS`), `cel-to-filter` emits it for a CEL `!expr` RLS scope, and driver-sql, driver-mongodb and this package's own matcher all implement it — but MongoDB has no document-level `$not`, so passing it through meant every negated scope threw. It is compiled to `$nor` with one operand (driver-mongodb's rewrite, #4405), which is NULL-safe by construction and so lands on the #5146 canon. Both filter faces now share ONE shape gate rather than a copy of one check. They had drifted: a malformed `$between` returned NO rows from the live path and EVERY row from the reference matcher. Closes the conformance gap that hid this: `FILTER_LOGIC_CASES` reached this backend through the reference matcher only, which the driver does not call, so the table's `$not` case was green while the same filter through `InMemoryDriver.find` threw. It now runs through the real driver too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
`assertFieldConstraintShape` had inlined `keys.length === 0`, which left `isEmptyFieldConstraint` — and the #5240 reasoning in its doc comment about what does NOT count as one (a `Date` enumerates to nothing but is a comparand) — attached to nothing. Behaviour-identical: the caller has already established the spec is a filter node, which is the predicate's other half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7
…'s wording A malformed `$and`/`$or` operand and a malformed `$not` operand each had two messages: the gate's `filterNodeListExpectedError` / `filterNodeExpectedError`, and a second, differently-worded `unknownLogicalOperatorError` in the translator's unreachable floor. #5240's rule is one condition, one wording — and a floor that says something different from the gate above it is exactly what a reader would use to conclude they had hit a different problem. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7
…one operator the allowlist would have leaked
Measured after the vocabulary landed: `{ field: { $options: 'i' } }` still
escaped the ADR-0112 envelope on the live path (`unknown query operator
$options`, no code, no status) while the reference matcher ignored it and
matched EVERY row. #5324's exact shape, surviving for a single operator —
because `$options` is in the vocabulary as a MODIFIER of `$regex`, not a
predicate, so allowlisting the key without requiring its partner left the hole
open for it alone.
Refused when no `$regex` accompanies it, on both faces, and pinned.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7
开 PR 后的三个追加提交 —— 其中一个补掉了我自己开的一个洞1.
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7
Fixes #5324
Fixes #5328
两条缺陷是同一个形状
normalizeFilterCondition(memory-driver.ts)对它无法求值的东西不拒收,而是二选一地静默处理 —— 两条 arm 在同一个switch里:$op(default: result[op] = val通用透传)code/ 无status的MingoError,逃出 ADR-0112 信封(500 形状)INVALID_FILTER/ 400$between(arm 写成有条件的)find返回[]INVALID_FILTER/ 400一个静默变 500、一个静默变空结果集 —— 方向相反,病因相同:该拒收的没拒收。#4436 / #3948 已确立「无法编译的过滤器必须响亮拒收,不能被跳过或改写」,#5240 又统一了拒收的信封;本 PR 把这条补进 driver-memory 的实时查询路径,复用同包已有的
filter-refusal.ts(没有另造第三套)。按未知
$op通用来写,不是加一条$not分支 —— 透传是通用的,只补$not会把{name:{$sounds_like:'x'}}那一半留在 500 形状里。同批一并收进来的、同一形状的两条:
$键({$nor:…}、{$where:…})。FilterConditionSchema在节点位置只声明$and/$or/$not,其余是字段名;这些键此前由同一个result[key] = value反射透传给 mingo。{$or:'x'}、{$or:[null]}、{$not:'x'})。与 driver-sql 的assertFilterNodeList/assertFilterNode(SqlDriver.applyFilterCondition 丢弃编译成空的 $and/$or 子过滤器,而不是套用布尔单位元 —— 与同仓 matchesFilterCondition / driver-memory 相反 #5134)对齐。$not:方向相反的那一条 —— 实现,不是拒收#5324 正文列了两条路且明确不代裁决。证据把路选了:
$not是 spec 声明的组合子(LOGICAL_OPERATORS),cel-to-filter把每一条 CEL!expr的 RLS read scope 编译成{$not:{…}},driver-sql 编译它、driver-mongodb 翻译它、本包的参考匹配器求值它,而门禁表FILTER_LOGIC_CASES里有一条用例要求它。拒收会让 driver-memory 成为唯一跑不了一个已声明算子的后端,并且让一致性表带上一条永远不可能通过的用例。「拒收你无法求值的东西」有一条配套子句:契约声明了的,你就得求值。 所以未声明的一律拒收,已声明的这一条补上实现。
改法是
$nor单操作数 —— MongoDB(因而 mingo)没有文档级$not,$nor: [X]就是它的文档级否定,与 driver-mongodb 因同一原因做的改写(#4405)一致。它天然 NULL-safe:内层条件对 null / 缺字段的行不成立,$nor因而收下该行 —— 正是 #5146 裁定为 canonical、并据此重写了 driver-sql 的那套语义。这一点不是推理,是逐条实测的(见下表)。这是本 PR 比修两条 arm 更要紧的部分。
FILTER_LOGIC_CASES是五个过滤后端共用的唯一标准(#3774)。其中四家是穿过真正执行查询的代码跑的:driver-sql 编译成 SQL、driver-sqlite-wasm 在 sql.js 上执行、driver-mongodb 翻译并执行、service-analytics 下沉成 read-scope SQL。driver-memory 只经
memory-matcher跑(memory-matcher-or-semantics.test.ts)。而驱动根本不调用match()—— 它只从那个模块 import 了getValueByPath,过滤交给 mingo。所以这个后端在一致性表上的那一半,量的是参考实现,用户真正跑的那一半一次都没对着标准执行过。代价不是假设的:表里
$not ANDs with its sibling keys inside a branch这条自存在起一直是绿的,而同一个 filter 走InMemoryDriver.find会抛unknown top level operator: $not。一个给不能跑的算子发绿灯的门禁,比没有门禁更糟 —— 它报告了自己并不具备的覆盖,正是 Prime Directive #10 说的 declared ≠ enforced。本 PR 按其余三家的做法补上:新增
memory-driver-filter-logic-conformance.test.ts,把整张表穿过InMemoryDriver.find。memory-matcher-or-semantics.test.ts保留 —— 匹配器仍是参考求值器,两个面都被同一张表约束,才让「这个包有两个过滤面」成为一句可以核查的话。表内另加一条断言:两个面对整张表逐条给出相同答案。两个过滤面:一个门,不是两份拷贝
#5240 让两个面拒收同一形状的办法是把同一个检查写了两遍。那仍是一条规则的两个实现,而本单实测的输入证明了两份实现能飘多远:同一个形状错误的
$between,实时路径答「没有行」,参考匹配器答「所有行」 —— 一个包、一个 filter、两个互相矛盾的静默答案,而且都不是区间。所以规则现在只在一个函数里:
filter-refusal.ts的assertFilterConditionShape,两个面在求值前都调用它。它先走完整棵树再求值,理由与 #5240 写下的两条一样(且现在承担得更多):every/some、mingo 自己的谓词组合),求值中途抛出的拒收会取决于当时那条记录触发或不触发 —— 一条写坏的权限规则必须对每条记录都被拒,不能看数据的运气;{$or:[{a:{}},{}]}里那个 TRUE 析取项会让发射器提前返回,永远走不到畸形的那一支 —— driver-sql 的reduceFilterNode把这叫「a gate conditional on evaluation order」。现场核对(STALE-PREMISE)
worktree 基于
origin/main=c7406b0ec(已含 #5158 / PR #5329 的数组方言删除)。逐条核对两个 issue 引用的现场:default:是通用透传,$not只是其一$not抛无code/status的MingoErrorunknown top level operator: $not | code=undefined status=undefined$betweenarm 有条件写入,find返回[]resolves.toEqual([])并留注释指向本单rejects+ 断言信封,注释一并移除memory-filter-ast-vocabulary.test.ts的用例按现状放宽为.rejects.toThrow()code/status断言{$not:{}}用例钉着unknown top level operator[](#5134 的 NOT TRUE ≡ FALSE),两个面都断言,与 driver-sql 一致$not在FILTER_LOGIC_CASES里(filter-logic-conformance.ts:172)$not必须实现而非拒收一处与 issue 表述不同:#5328 说匹配器侧此前也是「匹配 0 行」。实测不是 —— 匹配器的
Array.isArray(target) && …守卫跳过比较,答的是匹配全部行。两个面的静默答案是相反的,这比 issue 记的更糟,也是把门收成一份实现的直接理由。词汇表:来自 spec,不是手抄
SUPPORTED_FIELD_OPERATORS= spec 的FILTER_OPERATORS+$regex+$options。不在这里手写一份列表 —— 「A private alias list here is what let this driver and driver-sql accept different vocabularies」(#3948,同一文件convertConditionToMongo上方的注释)。两个额外项都是既有行为而非新能力:$regex—— 不在 spec 列表里但真的有生产者:plugin-auth 的 ObjectQL adapter 为contains搜索产出{field:{$regex:value}};driver-sql 编译它,objectql 的having允许它,本包匹配器实现它。拒收会打断一个活着的生产者。$options——$regex的旗标伴随键,匹配器读它(new RegExp(target, condition.$options)),objectql 的having同理跳过它。其余一律拒收,包括此前意外放行的 mingo 算子(
$elemMatch、$size、$type、$mod、$where、$expr、字段级$not)—— 都不在 Filter Protocol 里,匹配器都不实现,driver-sql 全部拒收。刻意不收紧的三处(收紧会让本驱动发明一个比它必须与之一致的后端更严的契约):无
$键的字段规格({author:{name:'x'}},读作深比较 —— 与 driver-mongodb 一致)、$between数组的成员类型(driver-sql 只查元数,#5041 实测过成员情形并有意留下)、LIKE 族的字符串化比较值(同上,且方向 fail-closed)。可观察的行为变更
变更集已写明。原本静默返回空结果集 / 原本抛 500 形状的输入,现在一律
INVALID_FILTER/ 400,消息带算子名、字段名与路径(filter.$or[1].$and[0].stage)。一个因此开始收到 400 的查询,此前就已经是坏的 —— 它对同一输入要么返回空集要么抛无信封的 500,而 driver-sql 一直在拒收它。反方向:带否定 scope 的查询从抛异常变为正常返回。测试
memory-driver-filter-logic-conformance.test.ts(新)FILTER_LOGIC_CASES全表穿透InMemoryDriver.find+ fixture 完整性 + 两面逐条同答案memory-filter-vocabulary-refusal.test.ts(新)$sounds_like/$elemMatch/$size/$type/$mod/$where/$expr/字段级$not)、4 个节点级未声明$键、6 个畸形$between(非数组/一元/三元/对象/字符串/null)、7 个畸形组合子操作数、21 条合法形状不变、位置信息、拒收与记录无关 —— 每条都跑两个面并断言两面消息逐字相同memory-driver-document-not.test.ts(新)$not在每个位置(顶层/$or内/$and内/带兄弟键/两层嵌套/CEL RLS 形状)+ #5146 整张 canon(NULLED 与 MISSING 两种读法)+ #5134 布尔单位元 + 三条已知两面语义分叉(钉住,指向 #5299)memory-filter-ast-vocabulary.test.ts(改)memory-empty-field-constraint.test.ts(改){$not:{}}由「抛 MingoError」改为「两个面都答[]」信封与 driver-sql 的一致性:
code === 'INVALID_FILTER'、status === 400逐字断言;消息首句与 driver-sql 逐字一致(Unsupported filter operator "$x" on field "y"./Operator "$between" on field "y" requires a [min, max] value array.),作为字面量钉在测试里(driver-memory 不依赖 driver-sql,也不该依赖 —— 与memory-filter-refusal-envelope.test.ts钉信封的做法相同)。supported 列表由词汇表 Set 生成,故顺序与 driver-sql 略异、内容随实现自动同步。反向验证(把改动 stash 掉)
只 stash 三个源文件(
filter-refusal.ts/memory-driver.ts/memory-matcher.ts),测试原样保留:失败原文(节选):
expected undefined to be 'INVALID_FILTER'就是丢失的信封本身。连带面回归(driver-memory 是测试与 dev 的默认驱动)
@objectstack/driver-memory@objectstack/objectql@objectstack/plugin-security@objectstack/runtime@objectstack/service-datasource@objectstack/cli@objectstack/plugin-dev合计 5243 passed, 0 failed, 0 skipped。
@objectstack/driver-memorytypecheck(tsc --noEmit)干净,eslint --max-warnings=0干净。本地跳过 / CI 才跑的盲区:上述七个包没有任何 skipped 用例(vitest 未报告 skip 计数)。依赖清点:仓内声明依赖
@objectstack/driver-memory的包共 7 个(runtime、service-datasource、qa/dogfood、cli、plugin-dev、examples/embed-objectql、自身),上表覆盖了其中有测试脚本的全部。driver-mongodb的一致性套件需要真实 mongod,本地未跑 —— 本 PR 不改该包,故不主张覆盖它;但下面第 2 条范围外发现正是关于它的,实现方须先实测。一处环境注意:首轮
typecheck/plugin-security test因依赖包dist未构建而失败(Cannot find module '@objectstack/spec/data'、Failed to resolve entry for package "@objectstack/formula")。这是 AGENTS.md §9 记录的陈旧构建态陷阱,不是本改动引起 —— 未改动的文件同样报错;pnpm --filter "…" build后全部转绿。范围外发现(均已立单,unassigned,未在本 PR 修)
按 PD #10。四条都先搜过既有 open issue 再落单;第五条命中既有单,按「attach, don't scatter」改为评论。
$or/$not整条丢,$between/$startsWith/$null/$regex因无 cube 映射而丢 —— 聚合结果被放大 #5345 — 同包第三个过滤面memory-analytics.ts的flattenFilterCondition静默丢弃大半个 filter:$or/$not整条continue,$between/$startsWith/$endsWith/$null/$regex因 cube 无映射而丢。方向是放大(少过滤 = 多返回行),且$not正是 RLS scope 的形状。本 PR 的门禁穿透只覆盖find面。$between/ 非数组$and、$or/ 非对象$not全被静默吞掉,且它的算子拒收没有 ADR-0112 信封 #5346 — driver-mongodb 带着 driver-memory 对形状错误的$between静默不发谓词(匹配 0 行),driver-sql 同一过滤器抛错 —— 一个过滤器两个答案 #5328 的孪生代码:$between同款有条件写入、$and/$or非数组与$not非对象被静默吞掉;其未知算子拒收无 ADR-0112 信封且消息带[mongodb]前缀(analytics /query 未做 cube 存在性校验,未注册名直达驱动当表名;且错误路径原样回显驱动 SQL(#3770 同类,另一子系统) #3867 已在 SQL 侧删除同类前缀)。逐行代码对读,未实测(需 mongod),已在单里写明。$null的比较值不是布尔时,driver-sql 与 driver-memory 给出**完全相反**的答案(一个 IS NULL,一个 IS NOT NULL)—— 实测 #5347 —$null比较值非布尔时,driver-sql 与 driver-memory 给出完全相反的答案(实测:{stage:{$null:'yes'}}→ SQL[](IS NULL)、memory["1"](IS NOT NULL))。两边默认分支挂在相反一侧(=== falsevs=== true)。这需要先裁决 canonical 才能改,单里列了三条路与推荐。本 PR 刻意未收紧$null的比较值类型,以免把分叉换个位置。$op({$where:…}、{$nor:[…]})当成列名编译,静默返回空结果集 —— #5324 的文档级一半在 SQL 侧还在 #5348 — driver-sql 把文档级未声明$op({$where:…}、{$nor:[…]})当成列名编译,静默返回空结果集(实测);而它的字段级同类输入一直是正确的INVALID_FILTER/ 400。四家后端里现在只剩它。$notContains(null 值)、$exists(键在值为 null)、$nin(缺键) #5299 的补充评论 — 本单已记 driver-memory ↔ formula 在$notContains/$exists/$nin上的三处分叉;实测补充:同样这三个算子在 driver-memory 自己的两个面之间也已分叉($exists与$nin甚至不带任何$not)。属语义而非形状,本 PR 的门不碰,现状已按实测钉在memory-driver-document-not.test.ts并注释指向 driver-memory 与 formula 对「字段没有值」给出三处不同答案:$notContains(null 值)、$exists(键在值为 null)、$nin(缺键) #5299。未做的清点:
$in/$nin收到非数组比较值时两个后端的行为、$regex族形状错误时的行为 —— 都没实测,故未立单。硬禁区遵守
packages/spec/**未改一行(FILTER_LOGIC_CASES的内容归 #5239,本 PR 只补 driver-memory 侧的穿透);packages/services/service-analytics/**(#5325 在飞)、packages/plugins/driver-sql/**、packages/formula/**均只读作参照;content/docs/releases/**未改,变更记录只走.changeset/*.md;无生成物手改。🤖 Generated with Claude Code
https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7
Generated by Claude Code