Skip to content
Draft
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
32 changes: 32 additions & 0 deletions .changeset/analytics-empty-combinator-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"@objectstack/service-analytics": patch
"@objectstack/spec": patch
---

fix(service-analytics): 空 `$and` / `$or` 按布尔单位元归约,两个编译器与五后端对齐 (#5322)

同一个仓库对空组合子曾有两个对立答案:五个 `FILTER_LOGIC_CASES` 后端
(`driver-sql` #5134/PR #5243、`driver-memory`、`formula`、`driver-sqlite-wasm`、
`driver-mongodb` #5239)把 `{ $and: [] }` / `{ $or: [] }` 归约成布尔单位元,而
service-analytics 的两个编译器 —— `read-scope-sql.ts` 的 `compileNode` 与
`filter-normalizer.ts` 的 `buildNode` —— 成文地 fail-closed 抛错("An empty
combinator has no defensible reading…"),并有 pin 测试钉住。2026-08-04 维护者拍板
(#5322)取单位元,本次把两处对齐:

- `{ $and: [] }` = TRUE(全部行,AND 单位元);`{ $or: [] }` = FALSE(零行,OR
单位元)。嵌套可归约:空组合子作 `$or` 分支时按 TRUE 吸收/FALSE 退出析取,作
`$not` 操作数时取反(`{$not: {$and: []}}` = 零行、`{$not: {$or: []}}` = 全部
行)。`{}` = TRUE 与 `{ $not: {} }` = 零行两格已由 #5297(read-scope)/#5325
(normalizer)先行落地,本次连同这四格由同一张一致性表钉住。
- **迁移含义**:过去发出空组合子的调用方收到的是抛错(REST 面上是一次失败的请
求);现在按上表求值。`{ $or: [] }` 在 RLS/图表场景是 fail-closed 的 —— 析取列
表循环出零项时隐藏全部行,而不是放行全表。写作期对字面量空组合子的响亮拒收另立
#5330(publish/lint),不在运行期。
- **没有放宽的部分**:非数组的 `$and`/`$or`、非对象的分支、非对象的 `$not` 操作数
仍然抛错(#5325 的形状拒收原样保留)。归约让「无约束」成为有意义的裁决,静默把
畸形分支读成 TRUE 会让垃圾析取项吸收 `$or` 而放宽查询,所以畸形形状保持响亮。
- 归约与 #5146/#5325 的 NULL-safe `$not` 重写的组合语义是「先归约、后 NULL-safe」
—— 常量归约出的单位元不受重写影响,幸存的叶子照常加守卫,有测试钉住。
- `packages/spec`:`FILTER_LOGIC_CASES` 补四条布尔单位元行(空 `$and`、空 `$or`、
`{}` 析取项吸收、`{$not: {}}`),两个 analytics conformance suite 与五后端从此
被同一张表钉住这四格。
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#5322] Empty combinators reduce to their boolean identities in the
* analytics filter normalizer — `{$and: []}` = TRUE, `{$or: []}` = FALSE —
* matching the five `FILTER_LOGIC_CASES` backends row for row. Together with
* the `{}` / `{$not: {}}` identities #5325 already gave this module, the
* boolean algebra over the combinators is now complete.
*
* # The history this file flips
*
* Until the 2026-08-04 #5322 ruling, `buildNode` REFUSED the empty arrays.
* Its error message argued the opposite position, verbatim:
*
* > `"$and" requires a non-empty array. An empty combinator has no defensible
* > reading — dropping it widens the query, and treating it as "match
* > nothing" silently empties a chart.`
*
* "Treating it as match nothing" is exactly what #5134 ruled for `$or: []`
* and what `driver-sql` / `driver-memory` / `formula` / `driver-sqlite-wasm`
* / `driver-mongodb` (#5239) implement. The ruling took the reduction because
* only a reduction can evaluate a NESTED tree (a rejection must first reduce
* to decide whether `$and: []` inside a `$or` branch is an error — which
* concedes the point), and because `{$or: []}` = zero rows is fail-closed
* where it matters: a scope whose disjunct list loops to zero items hides
* every row rather than widening to the whole table. The loud authoring-time
* rejection of the literal spellings lives on as #5330 (publish/lint), not as
* runtime behavior.
*
* # What deliberately did NOT loosen
*
* Non-array `$and`/`$or` still throws (this file), as do non-object branches
* and non-object `$not` operands (pinned in
* `filter-normalizer-not-null-safe.test.ts`): reduction makes `null` ("no
* constraint") a meaningful verdict, so silently mapping junk to it would let
* a malformed disjunct ABSORB its `$or` and widen the query — the exact
* failure mode the old error message feared, reachable only through the
* lenient path.
*
* Row-level conformance for the four ruled shapes lives in the shared table
* (`filter-logic-conformance.ts`), executed against a real SQLite engine by
* `native-sql-filter-logic-conformance.test.ts` and
* `read-scope-sql-conformance.test.ts`. This file pins the TREE the
* normalizer produces and the seam where the ObjectQL engine path receives
* the boolean constant.
*/

import { describe, it, expect } from 'vitest';
import { DatasetSchema } from '@objectstack/spec/ui';

import {
normalizeAnalyticsFilterTree,
collectFilterLeaves,
} from '../strategies/filter-normalizer.js';
import { AnalyticsService } from '../analytics-service.js';

const tree = (where: unknown) => normalizeAnalyticsFilterTree({ where });

const FALSE_NODE = { kind: 'const', value: false };
const TRUE_NODE = { kind: 'const', value: true };

describe('[#5322] buildNode reduces empty combinators to boolean identities', () => {
it('`{$and: []}` is TRUE — no constraint', () => {
expect(tree({ $and: [] })).toBeNull();
});

it('`{$or: []}` is FALSE — the zero-row constant', () => {
expect(tree({ $or: [] })).toEqual(FALSE_NODE);
});

it('`$not` negates the REDUCED operand, in both directions', () => {
expect(tree({ $not: { $and: [] } })).toEqual(FALSE_NODE); // NOT TRUE ≡ FALSE
expect(tree({ $not: { $or: [] } })).toEqual(TRUE_NODE); // NOT FALSE ≡ TRUE
});

it('an empty-combinator branch carries its identity into the enclosing combinator', () => {
// A `{$and: []}` disjunct is TRUE and ABSORBS the whole `$or` — collapsing
// to the surviving branches instead is the narrowing #5325 fixed for the
// literal `{}` disjunct.
expect(tree({ $or: [{ a: 'x' }, { $and: [] }] })).toBeNull();
// A `{$or: []}` conjunct is FALSE; the compiled conjunction carries the
// constant (row-set: zero rows — pinned via SQL in the conformance suite).
expect(JSON.stringify(tree({ $and: [{ a: 'x' }, { $or: [] }] }))).toContain('"value":false');
expect(JSON.stringify(tree({ a: 'x', $or: [] }))).toContain('"value":false');
});

it('the FALSE constant touches no member', () => {
expect(collectFilterLeaves(tree({ $or: [] }))).toEqual([]);
expect(collectFilterLeaves(tree({ $and: [{ $or: [] }] }))).toEqual([]);
});

it('non-array `$and`/`$or` still throws — #5322 loosened only the EMPTY array', () => {
expect(() => tree({ $and: 'x' })).toThrow(/requires an array/);
expect(() => tree({ $or: { a: 1 } })).toThrow(/requires an array/);
});
});

// ── The engine-path seam: FALSE reaches ObjectQL as a real zero-row filter ──

const dataset = DatasetSchema.parse({
name: 'incidents',
label: 'Incidents',
object: 'incident',
dimensions: [{ name: 'severity', field: 'severity', type: 'string' }],
measures: [{ name: 'incident_count', aggregate: 'count' }],
});

const ROWS: Array<{ severity: string }> = [
{ severity: 'high' },
{ severity: 'high' },
{ severity: 'low' },
];

/**
* Stand-in for `engine.aggregate`, mirroring how a driver receives the
* filter: `{$not: {}}` — the spelling `filterNodeToCondition` uses for the
* FALSE constant, because `formula` and `driver-memory` already pin it as the
* zero-row filter (#5134) — matches nothing, and an absent/empty filter
* matches everything.
*/
function makeEngine(captured: Array<{ filter?: Record<string, unknown> }>) {
const matches = (row: Record<string, unknown>, cond: Record<string, unknown>): boolean =>
Object.entries(cond).every(([key, value]) => {
if (key === '$and') return (value as Record<string, unknown>[]).every((c) => matches(row, c));
if (key === '$or') return (value as Record<string, unknown>[]).some((c) => matches(row, c));
if (key === '$not') return !matches(row, value as Record<string, unknown>);
return row[key] === value;
});
return async (
_object: string,
options: { groupBy?: string[]; filter?: Record<string, unknown> },
): Promise<Array<Record<string, unknown>>> => {
captured.push({ filter: options.filter });
const filtered = ROWS.filter((row) => matches(row, options.filter ?? {}));
return [{ incident_count: filtered.length }];
};
}

describe('[#5322] the ObjectQL path hands the engine the constant, not silence', () => {
it('`{$or: []}` arrives as the zero-row `{$not: {}}` and counts zero rows', async () => {
const captured: Array<{ filter?: Record<string, unknown> }> = [];
const svc = new AnalyticsService({
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
executeAggregate: makeEngine(captured),
});

const result = await svc.queryDataset!(dataset, {
measures: ['incident_count'],
runtimeFilter: { $or: [] },
});

// The constant reached the engine as a real zero-row condition — NOT as an
// absent filter, which every driver reads as "every row".
expect(captured).toHaveLength(1);
expect(JSON.stringify(captured[0].filter)).toContain('"$not":{}');
expect(result.rows).toEqual([{ incident_count: 0 }]);
});

it('`{$and: []}` arrives as no constraint and counts every row', async () => {
const captured: Array<{ filter?: Record<string, unknown> }> = [];
const svc = new AnalyticsService({
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
executeAggregate: makeEngine(captured),
});

const result = await svc.queryDataset!(dataset, {
measures: ['incident_count'],
runtimeFilter: { $and: [] },
});

expect(JSON.stringify(captured[0].filter ?? {})).not.toContain('$and');
expect(result.rows).toEqual([{ incident_count: 3 }]);
});

it('a `{$and: []}` disjunct absorbs its `$or` instead of narrowing to the other branch', async () => {
const captured: Array<{ filter?: Record<string, unknown> }> = [];
const svc = new AnalyticsService({
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
executeAggregate: makeEngine(captured),
});

const result = await svc.queryDataset!(dataset, {
measures: ['incident_count'],
runtimeFilter: { $or: [{ severity: 'high' }, { $and: [] }] },
});

// Narrowing to `severity = high` would count 2 — the #5297/#5325 seam.
expect(JSON.stringify(captured[0].filter ?? {})).not.toContain('severity');
expect(result.rows).toEqual([{ incident_count: 3 }]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -526,16 +526,29 @@ describe('[#5325] analytics `where` — NULL-safe `$not` and the boolean identit
// ── Nothing that failed closed stopped failing closed ─────────────────────

describe('the fail-closed guarantees survive the rewrite', () => {
it('an empty `$and` / `$or` still THROWS — #5322 is its own ruling', async () => {
// The empty-combinator square is decided separately (#5322). This change
// must not quietly turn either of them into a boolean identity on the way
// past, so both stay pinned on the THROWING side, inside a `$not` as well
// as outside.
await expect(ids({ $and: [] })).rejects.toThrowError(/non-empty array/);
await expect(ids({ $or: [] })).rejects.toThrowError(/non-empty array/);
await expect(ids({ $not: { $and: [] } })).rejects.toThrowError(/non-empty array/);
await expect(ids({ $not: { $or: [] } })).rejects.toThrowError(/non-empty array/);
await expect(ids({ $or: [{ stage: 'won' }, { $and: [] }] })).rejects.toThrowError(/non-empty array/);
it('an empty `$and` / `$or` reduces to its boolean identity, inside a `$not` as well as outside (#5322)', async () => {
// FLIPPED pin. When this file was written the empty-combinator square was
// still an open ruling, so all five shapes were pinned on the THROWING
// side (`toThrowError(/non-empty array/)`). The 2026-08-04 #5322 ruling
// took the boolean identities, and the pins flipped with it: `{$and: []}`
// is TRUE, `{$or: []}` is FALSE, and — the half that survives from the
// old pin's intent — the `$not` negates the REDUCED operand rather than
// quietly changing the answer on the way past.
await expect(ids({ $and: [] })).resolves.toEqual(ALL); // TRUE — the AND identity
await expect(ids({ $or: [] })).resolves.toEqual([]); // FALSE — the OR identity
await expect(ids({ $not: { $and: [] } })).resolves.toEqual([]); // NOT TRUE ≡ FALSE
await expect(ids({ $not: { $or: [] } })).resolves.toEqual(ALL); // NOT FALSE ≡ TRUE
// A `{$and: []}` disjunct is a TRUE branch and ABSORBS its `$or` —
// exactly as the literal `{}` disjunct does two blocks up.
await expect(ids({ $or: [{ stage: 'won' }, { $and: [] }] })).resolves.toEqual(ALL);
// The other direction: a `{$or: []}` disjunct is FALSE, the OR identity —
// the disjunction collapses to its real branch, NULL-safety intact.
await expect(ids({ $not: { $or: [{ stage: 'won' }, { $or: [] }] } })).resolves.toEqual(['2', '3', '4']);
});

it('a non-array `$and` / `$or` still THROWS — #5322 loosened only the EMPTY array', async () => {
await expect(ids({ $and: 'x' })).rejects.toThrowError(/requires an array/);
await expect(ids({ $or: { stage: 'won' } })).rejects.toThrowError(/requires an array/);
});

it('an unknown operator inside a `$not` still THROWS rather than being guarded', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
* what makes it a bypass rather than a wrong string, which is why the assertion
* below goes through `NativeSQLStrategy.generateSql` and not only through
* `compileScopedFilterToSql`. `$and: []` / `$or: []` in the same loop were
* fail-closed all along; `$not` was the one uncovered square.
* fail-closed when this file was written; #5322 has since ruled them boolean
* identities (TRUE / FALSE), and the fail-closed pin at the bottom of this
* file flipped with that ruling.
*
* **`$not` was not NULL-safe.** SQL is three-valued and a `WHERE` keeps only
* TRUE, so `NOT (stage = 'won')` dropped every row whose `stage` is NULL, while
Expand Down Expand Up @@ -373,17 +375,31 @@ describe('[#5297] read-scope `$not` — boolean identities and NULL safety', ()
});

describe('the fail-closed guarantees survive the rewrite', () => {
it('an empty `$and` / `$or` still THROWS, inside a `$not` as well as outside', () => {
expect(() => compileScopedFilterToSql({ $and: [] } as FilterCondition, ALIAS))
.toThrowError(/non-empty array/);
expect(() => compileScopedFilterToSql({ $or: [] } as FilterCondition, ALIAS))
.toThrowError(/non-empty array/);
// The empty-combinator square is its own ruling (#5322) — the rewrite must
// not quietly turn either of them into a boolean identity on the way past.
expect(() => compileScopedFilterToSql({ $not: { $or: [] } } as FilterCondition, ALIAS))
.toThrowError(/non-empty array/);
expect(() => compileScopedFilterToSql({ $not: { $and: [] } } as FilterCondition, ALIAS))
.toThrowError(/non-empty array/);
it('an empty `$and` / `$or` reduces to its boolean identity, inside a `$not` as well as outside (#5322)', () => {
// FLIPPED pin. This block used to assert `toThrowError(/non-empty
// array/)` four times: when it was written, the empty-combinator square
// was still an open ruling (#5322) and the rewrite was required not to
// change the answer on the way past. The 2026-08-04 ruling took the
// boolean identities, so the pinned answers flipped with it — and the
// second half of the old requirement still holds in its new form: the
// `$not` negates the REDUCED operand.
expect(ids({ $and: [] })).toEqual(ALL); // TRUE — the AND identity
expect(ids({ $or: [] })).toEqual([]); // FALSE — the OR identity
expect(compileScopedFilterToSql({ $or: [] } as FilterCondition, ALIAS))
.toEqual({ sql: '1 = 0', params: [] });
expect(ids({ $not: { $or: [] } })).toEqual(ALL); // NOT FALSE ≡ TRUE
expect(ids({ $not: { $and: [] } })).toEqual([]); // NOT TRUE ≡ FALSE
});

it('reduction composes with the NULL-safe rewrite: identities first, surviving leaves stay guarded (#5322)', () => {
// The FALSE disjunct drops out (the OR identity), leaving
// `{$not: {$or: [{stage: 'won'}]}}` — whose leaf the #5146 rewrite still
// totalises, so the NULL-stage rows 3 and 4 are returned exactly as the
// plain `{$not: {stage: 'won'}}` pin above returns them.
expect(ids({ $not: { $or: [{ stage: 'won' }, { $or: [] }] } })).toEqual(['2', '3', '4']);
// The TRUE disjunct ABSORBS the `$or`, so the whole `$not` is NOT TRUE —
// zero rows — and no leaf survives for the rewrite to guard.
expect(ids({ $not: { $or: [{ stage: 'won' }, { $and: [] }] } })).toEqual([]);
});

it('an unknown operator inside a `$not` still THROWS rather than being guarded', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,12 @@ describe('compileScopedFilterToSql — filter logic conformance', () => {
const { sql, params } = compileScopedFilterToSql(c.filter, ALIAS);
// The compiler returns a boolean expression, exactly as the analytics
// query builder splices it — including the unparenthesized top level.
const stmt = db.prepare(`SELECT "id" FROM "t" AS "${ALIAS}" WHERE ${sql} ORDER BY "id"`);
// `''` is the compiler's TRUE (#5322: `{$and: []}` and an absorbed `$or`
// compile to it), the shape for which `applyReadScope` adds no `WHERE`
// at all — so it executes here as the unconstrained query it stands for.
const stmt = db.prepare(
`SELECT "id" FROM "t" AS "${ALIAS}" WHERE ${sql.length > 0 ? sql : '1 = 1'} ORDER BY "id"`,
);
stmt.bind(params as any[]);
const got: string[] = [];
while (stmt.step()) got.push(String(stmt.get()[0]));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,21 @@ describe('compileScopedFilterToSql', () => {
expect(() => compileScopedFilterToSql({ account: { region: 'NA' } }, 't')).toThrowError(/nested\/relation value/);
});

it('THROWS on an empty $and (degenerate, fail-closed)', () => {
expect(() => compileScopedFilterToSql({ $and: [] }, 't')).toThrowError(/non-empty array/);
it('empty combinators reduce to their boolean identities (#5322)', () => {
// FLIPPED pin. Until the 2026-08-04 #5322 ruling this asserted
// `toThrowError(/non-empty array/)`: the compiler refused empty
// combinators fail-closed while the five FILTER_LOGIC_CASES backends
// reduced them to identities. The ruling took the reduction, so `''`
// (TRUE) and `1 = 0` (FALSE) are now the pinned answers — matching
// `driver-sql` / `driver-memory` / `formula` / `driver-sqlite-wasm` /
// `driver-mongodb` row for row.
expect(compileScopedFilterToSql({ $and: [] }, 't')).toEqual({ sql: '', params: [] });
expect(compileScopedFilterToSql({ $or: [] }, 't')).toEqual({ sql: '1 = 0', params: [] });
});

it('still THROWS on a non-array $and/$or (fail-closed — #5322 loosened only the EMPTY array)', () => {
expect(() => compileScopedFilterToSql({ $and: 'x' } as never, 't')).toThrowError(/requires an array/);
expect(() => compileScopedFilterToSql({ $or: { a: 1 } } as never, 't')).toThrowError(/requires an array/);
});

it('THROWS on a non-object read scope', () => {
Expand Down
Loading
Loading