Skip to content

$exists 的比较值不是布尔时,三个后端面给出三个答案,driver-memory 自己的两个面在 'yes' 上就已分叉 —— 实测,$null(#5347)的同族另一轴 #5369

Description

@os-zhuang

#5347 / #5348(PR #5368)时,按 PM 要求核对 driver-sql 的守卫表 nullValueSatisfiesOperator 能否随 $null 的拒收一并收紧,清点到紧邻的 $exists arm 带着同款的 === false 二分。实测后确认它也分叉,而且比 $null 更散。不在 #5347 范围内(PM 裁定只覆盖 $null),按 Prime Directive #10 单独记在这里,unassigned。

实测原文

fixture 两行 —— {id:'1', stage:'won'}{id:'2', stage:null}(注意第二行:键在,值为 null)。三个求值面:

                    driver-sql      driver-memory find     driver-memory match
                    (better-sqlite3) (mingo)                (参考匹配器)
$exists: 0          ["1"]           []                     ["2"]
$exists: 'yes'      ["1"]           ["1","2"]              ["1"]
$exists: ''         ["1"]           []                     ["2"]

对照组(布尔值,方向一致):

$exists: true       ["1"]           ["1","2"]              ["1"]
$exists: false      ["2"]           []                     ["2"]

机制:三处按三种规则读同一个比较值

driver-sql(sql-driver.ts,applyFilterCondition 的发射器 switch,$null arm 下一条):

case '$exists':
  (builder as any)[opValue === false ? whereNull : whereNotNull](field);

—— === false 恒等。除字面 false 外一律 IS NOT NULL,所以 0'' 都读作「存在」。

driver-memory 的 mingo 面(memory-driver.ts normalizeFieldOperators):

case '$exists': case '$regex': case '$options':
  result[op] = val;

—— 原样交给 mingo,mingo 按 truthy 读。所以 0 / '' 读作「不存在」,'yes' 读作「存在」。

driver-memory 的参考匹配器(memory-matcher.ts):

case '$exists':
  const exists = value !== undefined && value !== null;
  if (exists !== !!target) return false;

—— 也按 truthy(!!target),但它的 exists 定义是「有值」(!== undefined && !== null),而 mingo 的是「键存在」。所以在 stage: null 那一行上两者必然相反,$exists: 'yes' 就已经分开:mingo 说 ["1","2"],matcher 说 ["1"]

三个规则:=== false 恒等 / truthy + 键存在 / truthy + 有值。

为什么这是独立的一条,而不是 #5299#5347 的一部分

为什么值得修

  1. $null 的理由逐字适用。 spec 的 FieldOperatorsSchema 声明 $exists: z.boolean().optional()(与 $null 同一处),非布尔是越界输入;而 where 走到驱动时没有任何一层按 FieldOperatorsSchema 校验过。AI 生成的元数据把 $exists: "true" / $exists: 1 写出来完全可能,字符串 "false" 是 truthy —— 与 $null 的比较值不是布尔时,driver-sql 与 driver-memory 给出**完全相反**的答案(一个 IS NULL,一个 IS NOT NULL)—— 实测 #5347 记录的陷阱一模一样。
  2. $exists 落在权限面上。$null 同级:RLS scope 里判断「字段有没有值」是常规写法。
  3. 今天没有任何门禁能发现它。 共享表 FILTER_LOGIC_CASES 刻意不含 null 处理(driver-memory 与 formula 对「字段没有值」给出三处不同答案:$notContains(null 值)、$exists(键在值为 null)、$nin(缺键) #5299 已记录这一点),所以三个面的分歧不会被任何 conformance 顶出来。

建议(不代裁决)

#5347 取同一条路 —— 非布尔比较值一律拒收(INVALID_FILTER / 400),落点与 #5368$null 闸门完全对称:

代价与 #5347 相同:这是收紧,今天靠 truthy/falsy 巧合工作的调用方会拿到 400。#5347 的裁定已经为这一类代价定了调,所以本条大概率是同一句话的重复适用,而不是一次新裁决 —— 但仍请 PM 明确裁一次,不要由实现方代拍。

注意先后:若 #5299 先裁定了 $exists 的语义(键存在 vs 有值),本条的修复会顺带把两个 JS 面的实现对齐;若本条先修,#5299 的裁决面会缩小到只剩布尔输入。两条不冲突,任意顺序都可以。

未验证的部分

严重度请按 triage 定,不代表我判断它低 —— 我只是按范围没有在 #5368 里顺手改。

关联

#5347 / PR #5368($null 的同款,已裁定 A 并落地四家后端;本条被明确排除在外)、#5299($exists布尔比较值下的语义分歧,另一轴,未决)、#5240({field:{}} 四后端拒收)、#5324 / #5328(拒收你无法求值的东西)、#5296(nullValueSatisfiesOperator 守卫表)、objectstack-ai/cloud#1116(remote Turso 的 $null,同族)。


Generated by Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions