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
5 changes: 5 additions & 0 deletions .changeset/having-filter-null-safe-negative-operators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@objectstack/objectql': patch
---

HAVING 求值对齐 #5298 的 NULL-safe 裁决:聚合行上没有值的列现在满足 `$nin` 与 `$notContains`,与 driver-sql / formula / service-analytics 一致(此前 HAVING 是唯一仍判否的求值面)。
120 changes: 116 additions & 4 deletions packages/objectql/src/having-filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
* HAVING evaluator (#4286 step 3) — semantics over AGGREGATED rows.
*
* The namespace is the aggregated row's own columns (aggregation aliases +
* groupBy projections); operator semantics mirror the Filter Protocol's
* memory evaluation, EXCEPT that an unknown operator throws — ignoring one
* would silently return unfiltered aggregates, the exact silently-inert
* failure (#4286, ADR-0078) enforcement exists to end.
* groupBy projections); operator semantics follow the Filter Protocol, with two
* deliberate divergences from driver-memory's matcher: an unknown operator
* throws — ignoring one would silently return unfiltered aggregates, the exact
* silently-inert failure (#4286, ADR-0078) enforcement exists to end — and the
* negation-carrying operators are NULL-safe per #5298 (see the grid at the
* bottom of this file, #5905).
*/

import { describe, it, expect } from 'vitest';
Expand Down Expand Up @@ -78,3 +80,113 @@ describe('matchesHaving — the unknown-operator refusal', () => {
expect(matchesHaving({ k: 'Alpha' }, { k: { $regex: '^alp', $options: 'i' } })).toBe(true);
});
});

/**
* [#5905] The no-value grid for the negation-carrying operators.
*
* #5298 ruled (option A, 2026-08-06) that "the column has no value" SATISFIES a
* test for "not this value", and PR #5962 landed it on driver-sql, formula,
* service-analytics and the `FILTER_LOGIC_*` conformance table. HAVING is the
* fifth evaluation face of the same vocabulary and was not in that PR's
* inventory, so it stayed the lone holdout — and no conformance table would
* have caught it, because `FILTER_LOGIC_CASES` does not drive the HAVING path
* (verified: `packages/objectql` imports it nowhere). This grid IS that
* coverage.
*
* Two no-value shapes, deliberately separated, because on this face they did
* NOT arrive at the old answer by the same route:
*
* - NULLED — the key is present with `null`. The early-exit guard tests
* `=== undefined`, so it never fired here; `$nin` was already NULL-safe and
* `$notContains` was not (`typeof null !== 'string'` ⇒ judged false).
* - MISSING — the key is absent, so the aggregated row reads `undefined`. The
* early-exit guard fired first and answered false for BOTH operators, before
* either arm was reached.
*
* The positive-operator rows are the control: `$in` / `$contains` must keep
* REJECTING both no-value shapes. Widening the exemption list too far would
* turn them green, which is the failure this pair is here to catch.
*/
describe('no-value rows and the negation-carrying operators (#5905 / #5298 option A)', () => {
const NULLED = { customer_id: 'nulled', tag: null, total: 100 };
const MISSING = { customer_id: 'missing', total: 100 };
const VALUED_OUT = { customer_id: 'valued_out', tag: 'gamma', total: 100 };
const VALUED_IN = { customer_id: 'valued_in', tag: 'alpha', total: 100 };
const GRID = [NULLED, MISSING, VALUED_OUT, VALUED_IN];

describe('$nin', () => {
it('a NULLED column satisfies $nin', () => {
expect(matchesHaving(NULLED, { tag: { $nin: ['alpha', 'beta'] } })).toBe(true);
});

it('a MISSING column satisfies $nin', () => {
expect(matchesHaving(MISSING, { tag: { $nin: ['alpha', 'beta'] } })).toBe(true);
});

it('a present value OUTSIDE the list still satisfies $nin (unchanged)', () => {
expect(matchesHaving(VALUED_OUT, { tag: { $nin: ['alpha', 'beta'] } })).toBe(true);
});

it('a present value INSIDE the list still fails $nin (unchanged)', () => {
expect(matchesHaving(VALUED_IN, { tag: { $nin: ['alpha', 'beta'] } })).toBe(false);
});

it('applyHaving keeps both no-value rows and drops only the listed value', () => {
expect(applyHaving(GRID, { tag: { $nin: ['alpha', 'beta'] } }).map((r) => r.customer_id))
.toEqual(['nulled', 'missing', 'valued_out']);
});
});

describe('$notContains', () => {
it('a NULLED column satisfies $notContains', () => {
expect(matchesHaving(NULLED, { tag: { $notContains: 'lph' } })).toBe(true);
});

it('a MISSING column satisfies $notContains', () => {
expect(matchesHaving(MISSING, { tag: { $notContains: 'lph' } })).toBe(true);
});

it('a present value WITHOUT the substring still satisfies $notContains (unchanged)', () => {
expect(matchesHaving(VALUED_OUT, { tag: { $notContains: 'lph' } })).toBe(true);
});

it('a present value WITH the substring still fails $notContains (unchanged)', () => {
expect(matchesHaving(VALUED_IN, { tag: { $notContains: 'lph' } })).toBe(false);
});

it('applyHaving keeps both no-value rows and drops only the containing value', () => {
expect(applyHaving(GRID, { tag: { $notContains: 'lph' } }).map((r) => r.customer_id))
.toEqual(['nulled', 'missing', 'valued_out']);
});
});

describe('the control: positive operators still reject a no-value column', () => {
it('$in rejects NULLED and MISSING', () => {
expect(matchesHaving(NULLED, { tag: { $in: ['alpha', 'beta'] } })).toBe(false);
expect(matchesHaving(MISSING, { tag: { $in: ['alpha', 'beta'] } })).toBe(false);
});

it('$contains rejects NULLED and MISSING', () => {
expect(matchesHaving(NULLED, { tag: { $contains: 'lph' } })).toBe(false);
expect(matchesHaving(MISSING, { tag: { $contains: 'lph' } })).toBe(false);
});

it('$ne — already exempt before #5905 — is unchanged for both shapes', () => {
expect(matchesHaving(NULLED, { tag: { $ne: 'alpha' } })).toBe(true);
expect(matchesHaving(MISSING, { tag: { $ne: 'alpha' } })).toBe(true);
expect(matchesHaving(VALUED_IN, { tag: { $ne: 'alpha' } })).toBe(false);
});
});

/**
* The NULL-safety lives at the LEAF, so `$not` inverts it rather than
* inheriting it — the same design driver-sql writes down for its own
* `$not` rewrite (a nested negation totalises its own operand). A no-value
* row satisfies `$nin`, therefore it does NOT satisfy `$not: { $nin }`.
*/
it('$not inverts the leaf answer instead of re-applying the guard', () => {
expect(matchesHaving(MISSING, { $not: { tag: { $nin: ['alpha'] } } })).toBe(false);
expect(matchesHaving(NULLED, { $not: { tag: { $notContains: 'lph' } } })).toBe(false);
expect(matchesHaving(VALUED_IN, { $not: { tag: { $nin: ['alpha'] } } })).toBe(true);
});
});
60 changes: 52 additions & 8 deletions packages/objectql/src/having-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,27 @@
// item's `alias` for structured entries — after date bucketing). It is an
// ordinary FilterCondition over those columns: implicit equality, the
// comparison / set / null / existence / string operators, and `$and` / `$or` /
// `$not` composition. Operator semantics mirror the Filter Protocol as
// driver-memory's matcher implements it, with ONE deliberate divergence:
// `$not` composition. Operator semantics follow the Filter Protocol, with TWO
// deliberate divergences from driver-memory's matcher — the face this module
// was originally written against:
//
// AN UNKNOWN OPERATOR THROWS. The memory matcher ignores operators it does not
// know; here an ignored operator would silently return UNFILTERED aggregates —
// the precise failure mode (#4286, ADR-0078) this module exists to end. The
// rejection names the operator and the supported set.
// 1. AN UNKNOWN OPERATOR THROWS. The memory matcher ignores operators it does
// not know; here an ignored operator would silently return UNFILTERED
// aggregates — the precise failure mode (#4286, ADR-0078) this module exists
// to end. The rejection names the operator and the supported set.
//
// 2. [#5905] THE NEGATION-CARRYING OPERATORS ARE NULL-SAFE. `$ne` / `$nin` /
// `$notContains` are satisfied by a row whose column HAS NO VALUE — "the
// column has no value" satisfies a test for "not this value". That is the
// ruling the maintainer took on #5298 (option A, 2026-08-06), landed by
// PR #5962 across driver-sql, formula, service-analytics and the
// `FILTER_LOGIC_*` conformance table. HAVING is the FIFTH evaluation face of
// the same vocabulary and was not in that PR's inventory, which left this
// file as the lone holdout (#5905) — and the only face no conformance table
// covers, since `FILTER_LOGIC_CASES` does not drive the HAVING path.
// driver-memory / driver-mongodb still answer the old way only because
// #5499 freezes them; the divergence is against a frozen face, not against
// the ruling.

import type { FilterCondition } from '@objectstack/spec/data';

Expand All @@ -49,6 +63,27 @@ function unknownOperator(op: string, where: 'logical' | 'condition'): Error {
);
}

/**
* [#5905] Operators whose answer for a column with NO VALUE is decided by the
* operator's own arm below, not by the early exit in {@link checkCondition}.
*
* That exit exists so a POSITIVE test (`$gt`, `$in`, `$contains`, …) can never
* be accidentally satisfied by a column the aggregated row does not carry. The
* operators listed here are the ones for which "no value" is a real answer
* rather than an accident:
*
* - `$exists` / `$null` — answering about absence IS their whole job;
* - `$ne` / `$nin` / `$notContains` — they carry their own negation, and #5298
* ruled (option A) that a value-less column satisfies them, on every backend.
*
* `$nin` and `$notContains` were missing from this list, which is the defect
* #5905 records: the exit fired first and answered FALSE for them, so the arms
* below — which would have answered TRUE — were never reached.
*/
const NO_VALUE_ANSWERED_BY_OPERATOR: ReadonlySet<string> = new Set([
'$exists', '$ne', '$null', '$nin', '$notContains',
]);

/**
* Filter aggregated rows by the query's `having` condition. An absent or empty
* condition returns the rows unchanged (same vacuous-filter convention as
Expand Down Expand Up @@ -109,7 +144,7 @@ function checkCondition(value: any, condition: any): boolean {
for (const op of keys) {
if (op === '$options') continue; // consumed by $regex below
const target = (condition as Record<string, any>)[op];
if (value === undefined && op !== '$exists' && op !== '$ne' && op !== '$null') return false;
if (value === undefined && !NO_VALUE_ANSWERED_BY_OPERATOR.has(op)) return false;
switch (op) {
// eslint-disable-next-line eqeqeq
case '$eq': if (value != target) return false; break;
Expand All @@ -134,7 +169,16 @@ function checkCondition(value: any, condition: any): boolean {
if (target === false && value == null) return false;
break;
case '$contains': if (typeof value !== 'string' || !value.includes(target)) return false; break;
case '$notContains': if (typeof value !== 'string' || value.includes(target)) return false; break;
// [#5905] The mirror of `$contains`, NOT its copy-with-a-negated-test.
// `$contains` fails a non-string value because "contains" cannot hold for
// something that is not text; `$notContains` SUCCEEDS for the same value
// for the same reason — it cannot contain the substring. Reusing the
// `typeof value !== 'string' ⇒ false` guard here (what this line used to
// do) made a value-less column fail BOTH an operator and its negation,
// the two-valued reading #5298 ruled out. This is `formula`'s shape
// (`matches-filter.ts`: `!(typeof actual === 'string' && …)`), which
// driver-sql's polarity table already follows for the same operator.
case '$notContains': if (typeof value === 'string' && value.includes(target)) return false; break;
case '$startsWith': if (typeof value !== 'string' || !value.startsWith(target)) return false; break;
case '$endsWith': if (typeof value !== 'string' || !value.endsWith(target)) return false; break;
case '$regex': {
Expand Down
Loading