Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/mongodb-boolean-identity-reduction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
"@objectstack/driver-mongodb": patch
"@objectstack/spec": patch
---

fix(driver-mongodb): 空 `$and` / `$or` / `$not` 按布尔单位元归约,非 filter 节点先响亮拒收 (#5239)

`translateFilter` 过去把组合子数组**原样透传**给 MongoDB。而 MongoDB 对空数组既不答
TRUE 也不答 FALSE,是第三种行为:**直接拒绝整条查询**(`$and/$or/$nor must be a
nonempty array`)。于是 `{ $and: [] }` 与 `{ $or: [] }` 一路走到 `find` /
`countDocuments` / `updateMany` / `deleteMany`,变成一个不带 ADR-0112 错误码的服务端
异常 —— 而 `driver-sql`(#5134 / PR #5243)、`driver-memory`、`formula` 三家早已按单位
元作答。

改成与它们同一套**结构性三值归约**:先把整棵 filter 树判成 `true` / `false` /
`clause`,再据此产出。空 `$and` 归约为 TRUE(不产出条件),空 `$or` 归约为 FALSE 并产出
一个**真实的零行条件** `{ _id: { $in: [] } }` —— 关键在于「什么都不产出」等于 `{}`,而
`find` / `updateMany` / `deleteMany` 把 `{}` 读作**全部文档**,方向正好相反。`{}` 作为
`$or` 的分支仍是 TRUE 析取项,`{ $not: {} }` 仍是零行,这两条 MongoDB 本来就与布尔代数
一致,所以归约按结构做而不是只判 `length === 0`。发出的每个 `$and` / `$or` 数组因此都保
证非空。

**同一改动里的形状拒收**,顺序是先拒收后归约:单位元把「这个节点没有谓词」读作「匹配全部
文档」,所以空节点必须只有一个成因。改前实测,本驱动这一格比 `driver-sql` 当年更糟 ——
`{ $or: [new Date()] }` 译成 `{ $or: [{}] }`,即**每一份文档**;`{ $or: 'x' }` 与
`{ $not: null }` 译成 `{}`,同样是每一份文档。`updateMany` / `deleteMany` 走的是同一个
translate 层,在那里「放宽到全部文档」不是行数不对而是数据丢失。现在这类操作数按
ADR-0112 以 `INVALID_FILTER` / `status: 400` 拒收,并在消息里点出位置
(`filter.$or[0]`)。`Date` / `RegExp` / class 实例都满足 `typeof x === 'object'` 却枚举
为空,故判定按**原型**而非 `typeof`。

`packages/spec` 侧只动文档:`FilterConditionSchema` 的契约 TSDoc 写明 `$not` 的
**NULL-safe** 语义(#5146 维护者拍板 —— 被比较列为 NULL 的行不满足被否定的条件,应当被
返回,即 `NOT (…) OR col IS NULL`),并在 `filter-logic-conformance.ts` 记下三族已裁定但
**尚未进表**的 case 及其实测矩阵。无运行时行为变化,无 API 变化。

Large diffs are not rendered by default.

267 changes: 248 additions & 19 deletions packages/plugins/driver-mongodb/src/mongodb-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,190 @@ import {
type TemporalFieldKindResolver,
} from './mongodb-temporal.js';

// ── [#5239] Boolean identities for the empty combinators ─────────────────────

/**
* Keys that reach this translator inside a `where` object but describe the
* QUERY rather than a predicate. The emitter has always skipped them; the
* reduction below has to agree, or "what this node is worth as a boolean" and
* "what this node emits" could disagree — which is the class of defect the
* reduction exists to remove.
*/
const QUERY_LEVEL_KEYS = new Set(['limit', 'offset', 'fields', 'orderBy']);

/**
* A MongoDB query document that matches NO document, for the `false` verdict.
*
* `$in: []` is empty-set membership — no value satisfies it, on every server
* version, with no dependence on a collection's fields. `_id` is the one field
* MongoDB guarantees exists. This is the shape #5239 names, and it is emitted
* as a REAL condition: `$or: []` must reach `find` / `updateMany` /
* `deleteMany` as "zero rows", never as the absent filter `{}`, which those
* three read as "every document".
*/
function matchNothing(): Filter<any> {
return { _id: { $in: [] } };
}

/**
* [#5239, mirroring #5134] What a filter node is worth as a boolean, decided
* BEFORE any query document is built.
*
* - `'true'` — matches every document; the translator emits no condition.
* - `'false'` — matches no document; the translator emits {@link matchNothing}.
* - `'clause'` — carries at least one real predicate; translate it normally.
*/
type FilterVerdict = 'true' | 'false' | 'clause';

/**
* [#5239] Is `value` a Filter Protocol NODE — the shape `FilterConditionSchema`
* declares for every element of `$and`/`$or` and for the operand of `$not`?
*
* The PROTOTYPE check is the load-bearing half. The identity reduction turns
* "this node has no predicates" into "matches every document", so any object
* whose own enumerable keys are empty reads as TRUE. A `Date`, a `RegExp`, a
* `Map` or a class instance all satisfy `typeof x === 'object' &&
* !Array.isArray(x)` while enumerating to nothing — accepting them would
* PROMOTE garbage from "silently mistranslated" to "matches all documents",
* which on `deleteMany` is not a wrong row count but data loss. Measured on
* `main` before this change: `{ $or: [new Date()] }` translated to
* `{ $or: [{}] }`, i.e. every document, and `{ $or: 'x' }` / `{ $not: null }`
* translated to `{}`, likewise every document.
*/
function isFilterNode(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}

/**
* [#5239] The gate that gives "this group translated to empty" exactly ONE
* cause.
*
* Identity reduction is sound only once an empty group can mean "the author
* wrote an empty group" and nothing else. Refusing non-nodes here — before any
* identity is applied — is what makes the reduction safe rather than a
* promotion of garbage to match-all. Same discipline as #5134 in `driver-sql`
* and cloud#1073 in Turso's `RemoteTransport.buildWhereSQL`.
*/
function assertFilterNode(value: unknown, path: string): asserts value is Record<string, unknown> {
if (isFilterNode(value)) return;
throw unsupportedFilterError(
`Filter node at ${path} is a ${describeFilterOperand(value)} (${safeShapePreview(value)}), not a filter ` +
`condition object. Every element of "$and"/"$or" and the operand of "$not" must be a plain object of ` +
`field constraints (e.g. { "status": "active" }) or nested combinators — @objectstack/spec ` +
`FilterConditionSchema declares this position as a FilterCondition. It is refused rather than skipped ` +
`because skipping it would silently change which documents match.`,
);
}

/** [#5239] `$and`/`$or` take a list; anything else is refused, never coerced. */
function assertFilterNodeList(value: unknown, key: string, path: string): asserts value is unknown[] {
if (Array.isArray(value)) return;
throw unsupportedFilterError(
`Filter combinator "${key}" at ${path} requires an array of filter conditions, but received a ` +
`${describeFilterOperand(value)} (${safeShapePreview(value)}). @objectstack/spec FilterConditionSchema ` +
`declares "${key}" as FilterCondition[].`,
);
}

/**
* [#5239] Reduce one filter node to its boolean verdict, validating shapes on
* the way down.
*
* A node is the AND of its entries, so FALSE dominates and a node with no
* entries at all is TRUE (the empty conjunction) — which is why `{}` is a TRUE
* disjunct inside `$or` and why `{ $not: {} }` is FALSE.
*
* The walk does NOT short-circuit: a `$or: []` sibling must not stop it from
* reaching — and refusing — a malformed node further along, or the shape gate
* would depend on key order.
*
* Deciding STRUCTURALLY, rather than translating and then asking whether the
* emitted document came out empty, is the point. "Nothing was emitted" cannot
* distinguish "the author wrote an empty group" from "something failed to
* translate"; a structural verdict has no such blind spot.
*/
function reduceFilterNode(node: Record<string, unknown>, path: string): FilterVerdict {
let sawFalse = false;
let sawClause = false;
for (const [key, value] of Object.entries(node)) {
const verdict = reduceFilterKey(key, value, path);
if (verdict === 'false') sawFalse = true;
else if (verdict === 'clause') sawClause = true;
}
return sawFalse ? 'false' : sawClause ? 'clause' : 'true';
}

/** [#5239] The verdict of ONE key of a filter node. */
function reduceFilterKey(key: string, value: unknown, path: string): FilterVerdict {
const here = path ? `${path}.${key}` : key;

if (key === '$and' || key === '$or') {
assertFilterNodeList(value, key, here);
let sawTrue = false;
let sawFalse = false;
let sawClause = false;
value.forEach((element, index) => {
const elementPath = `${here}[${index}]`;
assertFilterNode(element, elementPath);
const verdict = reduceFilterNode(element, elementPath);
if (verdict === 'true') sawTrue = true;
else if (verdict === 'false') sawFalse = true;
else sawClause = true;
});
// `$and: []` → no FALSE, no clause → TRUE (the AND identity).
if (key === '$and') return sawFalse ? 'false' : sawClause ? 'clause' : 'true';
// `$or: []` → no TRUE, no clause → FALSE (the OR identity). MongoDB itself
// answers neither: it rejects the empty array outright
// (`$and/$or/$nor must be a nonempty array`), so this filter used to be a
// 500-shaped throw rather than a verdict.
return sawTrue ? 'true' : sawClause ? 'clause' : 'false';
}

if (key === '$not') {
assertFilterNode(value, here);
const inner = reduceFilterNode(value, here);
// NOT TRUE ≡ FALSE — so `{ $not: {} }` matches nothing.
return inner === 'true' ? 'false' : inner === 'false' ? 'true' : 'clause';
}

// Query-level keys carry no predicate; the emitter skips them and so does the
// verdict, so the two never disagree about what this node is worth.
if (QUERY_LEVEL_KEYS.has(key)) return 'true';

// [#5347] `$null`'s comparand is a boolean by declaration. Checked on THIS
// walk rather than in the emitter's `$null` arm because the emitter is
// skipped wholesale by a boolean identity: `{ $or: [ {}, { stage: { $null:
// 'yes' } } ] }` reduces to TRUE on its first disjunct, so an emitter-side
// gate would refuse the comparand or ignore it depending on its SIBLINGS —
// the "gate conditional on evaluation order" #5368 placed driver-sql's and
// driver-memory's gates on their walks to rule out. #5368 landed this
// driver's gate in the emitter because no walk existed here yet; #5239's
// reduction is that walk, so the gate moved with it. `hasOwnProperty` rather
// than `'$null' in value` so an inherited key can never trip the gate.
// `{ $null: undefined }` still counts: the key is own and enumerable, and
// `undefined` is exactly one of the comparands #5347 measured a divergence
// on.
if (
isFilterNode(value) &&
Object.prototype.hasOwnProperty.call(value, '$null') &&
typeof value.$null !== 'boolean'
) {
throw nonBooleanNullComparandError(key, value.$null, `${here}.$null`);
}

// A field key always contributes a predicate. This stays `'clause'` even for
// `{ field: {} }` (a field constrained by zero operators), which this
// translator emits as `{ field: {} }` — an exact-match on an empty document.
// That shape is a SEPARATE divergence, ruled REJECT in #5240 and since gated
// on driver-sql / driver-sqlite-wasm / driver-memory / formula by #5327 —
// this driver is now the one backend still answering it, tracked by #5376.
// Classifying it as `'clause'` rather than `'true'` is precisely what keeps
// this change from silently ruling on it.
return 'clause';
}

/**
* [#5158] A `FilterArray` reached the driver unlowered — the twin of
* `driver-sql`'s and `driver-memory`'s `filterArrayReachedDriverError`, word
Expand Down Expand Up @@ -150,11 +334,27 @@ export function translateFilter(

if (typeof where !== 'object') return {};

return translateCondition(where as Record<string, unknown>, temporalKind, 'filter');
// [#5239] Shape gate + structural reduction FIRST, over the WHOLE tree. Only
// a `'clause'` node reaches the emitter, so `translateCondition` never has to
// ask whether what it built came out empty.
const node = where as Record<string, unknown>;
const verdict = reduceFilterNode(node, 'filter');
if (verdict === 'true') return {};
if (verdict === 'false') return matchNothing();

return translateCondition(node, temporalKind, 'filter');
}

/**
* Translate a FilterCondition object to a MongoDB filter.
*
* [#5239] Every combinator key is decided by {@link reduceFilterKey} BEFORE
* anything is emitted, so an empty group is applied as its boolean IDENTITY
* rather than dropped: a `'true'` key contributes nothing to the node's AND, a
* `'false'` key contributes {@link matchNothing}, and only `'clause'` members
* are translated. That is why every `$and`/`$or` array this function emits is
* guaranteed non-empty — MongoDB rejects an empty one, and the old code handed
* it straight through.
*/
function translateCondition(
condition: Record<string, unknown>,
Expand All @@ -170,32 +370,50 @@ function translateCondition(
for (const [key, value] of Object.entries(condition)) {
switch (key) {
case '$and':
if (Array.isArray(value)) {
andClauses.push({
$and: value.map((sub, i) => translateCondition(sub as Record<string, unknown>, temporalKind, `${path}.$and[${i}]`)),
});
}
break;

case '$or':
if (Array.isArray(value)) {
andClauses.push({
$or: value.map((sub, i) => translateCondition(sub as Record<string, unknown>, temporalKind, `${path}.$or[${i}]`)),
});
case '$or': {
const here = `${path}.${key}`;
const keyVerdict = reduceFilterKey(key, value, path);
// TRUE is the AND identity for the node — it adds no condition. FALSE
// makes the node match nothing.
if (keyVerdict === 'true') break;
if (keyVerdict === 'false') {
andClauses.push(matchNothing());
break;
}
// `'clause'` guarantees at least one branch survives: a TRUE member
// would have made a `$or` TRUE, a FALSE member would have made a `$and`
// FALSE, and both were handled above. Dropping the identity members is
// what makes `{ $or: [{ a: 'x' }, { $or: [] }] }` mean `a = x` rather
// than an empty `$or` MongoDB refuses.
const branches = (value as unknown[])
.map((sub, index) => ({ sub: sub as Record<string, unknown>, index }))
.filter(({ sub, index }) => reduceFilterNode(sub, `${here}[${index}]`) === 'clause')
.map(({ sub, index }) => translateCondition(sub, temporalKind, `${here}[${index}]`));
andClauses.push(key === '$and' ? { $and: branches } : { $or: branches });
break;
}

case '$not':
if (value && typeof value === 'object') {
const inner = translateCondition(value as Record<string, unknown>, temporalKind, `${path}.$not`);
// MongoDB $not applies per-field; for top-level negation use $nor
andClauses.push({ $nor: [inner] });
case '$not': {
const keyVerdict = reduceFilterKey(key, value, path);
// NOT FALSE ≡ TRUE — no condition. NOT TRUE ≡ FALSE — zero documents.
if (keyVerdict === 'true') break;
if (keyVerdict === 'false') {
andClauses.push(matchNothing());
break;
}
const inner = translateCondition(
value as Record<string, unknown>,
temporalKind,
`${path}.$not`,
);
// MongoDB $not applies per-field; for top-level negation use $nor
andClauses.push({ $nor: [inner] });
break;
}

default:
// Skip query-level keys that are not filter conditions
if (['limit', 'offset', 'fields', 'orderBy'].includes(key)) continue;
if (QUERY_LEVEL_KEYS.has(key)) continue;

if (value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
// Check if this is an operator object (has $ keys)
Expand Down Expand Up @@ -323,6 +541,17 @@ function translateFieldOperators(
// operator had three readings across four backends. Ruled REFUSED on
// #5347: `FieldOperatorsSchema` declares `$null: z.boolean()`, and there
// is no reading of a non-boolean here that is not a guess at intent.
//
// Since #5239's reduction, the LOAD-BEARING copy of this gate sits in
// `reduceFilterKey`, on the validating walk: this emitter is skipped
// wholesale whenever a boolean identity settles the enclosing node
// (`{ $or: [ {}, { stage: { $null: 'yes' } } ] }` reduces to TRUE before
// any arm here runs), so a gate only here would refuse or ignore the
// comparand depending on the shape's SIBLINGS — the evaluation-order
// dependence #5368 placed driver-sql's gates on its walk to rule out.
// This arm keeps the check as local defense for its own invariant; both
// call the one constructor with the same path spelling, so the wire
// answer is identical whichever fires.
case '$null':
if (typeof value !== 'boolean') throw nonBooleanNullComparandError(field, value, `${path}.$null`);
if (value === true) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,42 @@ describe('[#5347] driver-mongodb refuses a non-boolean $null comparand', () => {
expect(refusalOf({ $not: { stage: { $null: 'x' } } }).message).toContain('filter.$not.stage.$null');
});

/**
* [#5239 x #5347] A boolean identity settling the enclosing node must not
* skip this gate.
*
* #5368 landed this driver's gate in the emitter's `$null` arm — sound while
* every node reached an emitter. #5239's structural reduction ended that: a
* TRUE disjunct (`{}`) or a FALSE key (`$or: []`) settles the node before
* any emitter runs, so an emitter-only gate would refuse or ignore the same
* comparand depending on its SIBLINGS — the evaluation-order dependence
* #5368 placed driver-sql's and driver-memory's gates on their validating
* walks to rule out. The gate here moved to `reduceFilterKey` with the
* merge of the two changes; these pins hold it there. Each fixture would
* short-circuit to a verdict (match-all / match-nothing) if the walk did
* not refuse first.
*/
it('is not skipped when a TRUE disjunct settles the $or (#5239 reduction)', () => {
const err = refusalOf({ $or: [{}, { stage: { $null: 'yes' } }] });
expect(err.code).toBe('INVALID_FILTER');
expect(err.status).toBe(400);
expect(err.message).toContain('filter.$or[1].stage.$null');
});

it('is not skipped when a FALSE sibling key settles the node (#5239 reduction)', () => {
const err = refusalOf({ $or: [], stage: { $null: 1 } });
expect(err.code).toBe('INVALID_FILTER');
expect(err.status).toBe(400);
expect(err.message).toContain('filter.stage.$null');
});

it('is not skipped inside a $not whose operand an identity settles (#5239 reduction)', () => {
const err = refusalOf({ $not: { $or: [{}, { stage: { $null: 'x' } }] } });
expect(err.code).toBe('INVALID_FILTER');
expect(err.status).toBe(400);
expect(err.message).toContain('filter.$not.$or[1].stage.$null');
});

it('true and false translate exactly as before', () => {
expect(translateFilter({ stage: { $null: true } })).toEqual({ stage: { $eq: null } });
expect(translateFilter({ stage: { $null: false } })).toEqual({ stage: { $ne: null } });
Expand Down
Loading
Loading