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)
filterlive mingo pathreference 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))returnfalse;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

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions