Skip to content

driver-memory's reference matcher puts a NULL-VALUED row inside $gt/$gte/$lt/$lte on a NUMERIC column — null coerces to 0, and the live mingo path excludes it #13553

Description

@zhuangjianguo

Filed unassigned by the #13494 + #13495 + #13549 dev while measuring that fold's arms across a NUMERIC fixture. Recording only — no severity asserted, routing is triage's.

This is the ordering-operator sibling of #13549, in four arms that no queued card names. It is reported rather than repaired because it is a different arm family from the one those three cards cover, and the PR that closes them deliberately leaves it alone.

Measured

Executed on df18120502 (i.e. after PR #13550 landed), on both faces of packages/drivers/driver-memory, fixture:

{id:'1', n:5}   {id:'2', n:0}   {id:'3', n:null}   {id:'4'}   (key ABSENT)
filter live mingo path reference matcher
{n: {$gte: -1}} ['1','2'] ['1','2','3'] DISAGREE
{n: {$gt: -1}} ['1','2'] ['1','2','3'] DISAGREE
{n: {$lte: 1}} ['2'] ['2','3'] DISAGREE
{n: {$lt: 1}} ['2'] ['2','3'] DISAGREE

Row 3 has no value. The matcher says it is greater than -1 and less than 1 at the same time.

Cause

packages/drivers/driver-memory/src/memory-matcher.ts, the four ordering arms:

case '$gte':
    if (!(value >= target)) return false;
    break;

With value === null and a NUMERIC comparand, JS coerces null to 0, so null >= -1 is a true comparison between two numbers rather than a meaningless one. The arm has no reason to exclude the row, and the no-value row lands inside the bound.

The MISSING reading (row 4) escapes only because the pre-switch guard in checkCondition short-circuits on value === undefined before these arms run — so the matcher also disagrees with ITSELF across the two readings of "no value", the same shape #13549 recorded for $between.

Why a STRING fixture hides it

Every fixture in #13494, #13495 and #13549 uses ISO date strings, and there null >= '2026-07-01' compares 0 against NaN and is false, so the arms look correct. #13549's body records that "the other seventeen declared operators each returned the identical id set on both faces on this fixture" — that observation is true and is fixture-specific: the four ordering arms agree on strings and disagree on numbers. The $eq/$in/$contains families are unaffected either way, because none of them use a relational comparison.

Not covered by the queued cards

After the fold's PR lands, $between and the four ordering arms answer a null-valued numeric row differently from each other — $between excludes it, $gte/$lte include it — even though the live path compiles $between INTO $gte plus $lte. That inconsistency is pre-existing and in the opposite direction on string columns today; closing it is what this card is for.

Deliberately not asserting the target

The live path excludes the no-value row, and the platform's settled reading is that only the negation-carrying operators ($ne / $nin / $notContains) admit a no-value row (#5298 option A, re-affirmed 2026-08-10). Both point the same way, but null in an ordering position was recorded by the #5332 lane as "positions no ruling covers", so the target is left to triage rather than asserted here.

Related

#13549 (the $between value axis) · #13495 (the $between comparand axis) · #13494 (the $eq: null guard) · #13357 (the list spellings, awaiting a ruling) · #13166 (the same guard, negation allowlist) · #3948 (widening on a read scope)

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions