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

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions