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
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 与五后端从此
被同一张表钉住这四格。
87 changes: 80 additions & 7 deletions packages/rest/src/analytics-filter-refusal-envelope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,16 +96,33 @@ function buildRoute(analyticsProvider?: any) {
/**
* A REAL `AnalyticsService` on the ObjectQL aggregate path.
*
* `executeAggregate` returns a fixed bucket, so a query that gets far enough to
* touch data succeeds — which is what makes the refusal cases meaningful: they
* fail on the FILTER, on a route that demonstrably answers 200 otherwise.
* `executeAggregate` evaluates the engine-side filter it receives over one
* fixed bucket, so a query that gets far enough to touch data succeeds — which
* is what makes the refusal cases meaningful: they fail on the FILTER, on a
* route that demonstrably answers 200 otherwise. It is filter-AWARE (not a
* constant) so the #5322 identity cases are load-bearing too: the zero-row
* constant — `{$not: {}}`, the spelling `filterNodeToCondition` emits for
* FALSE — must come back as 200 with NO rows, distinguishable from both a 400
* and from an ignored filter.
*/
function realAnalytics(): AnalyticsService {
const silent: Logger = { debug() {}, info() {}, warn() {}, error() {} };
const bucket = { stage: 'won', revenue: 100 };
const matches = (cond: Record<string, unknown>): boolean =>
Object.entries(cond).every(([key, value]) => {
if (key === '$and') return (value as Record<string, unknown>[]).every(matches);
if (key === '$or') return (value as Record<string, unknown>[]).some(matches);
if (key === '$not') return !matches(value as Record<string, unknown>);
if (value !== null && typeof value === 'object' && '$eq' in (value as object)) {
return (bucket as Record<string, unknown>)[key] === (value as { $eq: unknown }).$eq;
}
return (bucket as Record<string, unknown>)[key] === value;
});
return new AnalyticsService({
logger: silent,
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
executeAggregate: async () => [{ stage: 'won', revenue: 100 }],
executeAggregate: async (_object: string, options: { filter?: Record<string, unknown> }) =>
matches(options?.filter ?? {}) ? [{ ...bucket }] : [],
isRegisteredObject: () => true,
});
}
Expand Down Expand Up @@ -163,9 +180,14 @@ describe('[#5352] POST /analytics/dataset/query — a filter refusal reaches the
message: /needs a two-element \[min, max\] array/,
},
{
name: 'an empty $or',
runtimeFilter: { $or: [] },
message: /"\$or" requires a non-empty array/,
// FLIPPED with the #5322 ruling (2026-08-04): this entry was `{$or: []}`
// pinning the "requires a non-empty array" refusal. The empty array is
// now the OR identity — FALSE, zero rows, asserted in the #5322 block
// below — so the refusal that survives at the same guard site is the
// non-array spelling, same envelope.
name: 'an $or that is not an array',
runtimeFilter: { $or: 'won' },
message: /"\$or" requires an array of filter objects/,
},
{
name: 'an $or branch that is not a filter object',
Expand Down Expand Up @@ -195,6 +217,57 @@ describe('[#5352] POST /analytics/dataset/query — a filter refusal reaches the
}
});

describe('[#5322] empty combinators are boolean identities at the REST face — evaluated, not refused', () => {
// Until the 2026-08-04 #5322 ruling, `{$or: []}` sat in REFUSALS above and
// this route answered it 400 ("requires a non-empty array"). The ruling took
// the identity reduction the five FILTER_LOGIC_CASES backends already gave:
// these four shapes are ANSWERS now, so each asserts its 200 AND its row
// semantics — the row count is what separates the two identities from each
// other and from a filter that was silently dropped.
const IDENTITIES: Array<{ name: string; runtimeFilter: unknown; rows: unknown[] }> = [
{
// FALSE — the OR identity. Zero rows is the fail-closed direction: a
// disjunct list that looped to zero items hides the data, it does not
// chart the whole dataset (#5134).
name: 'an empty $or → the zero-row constant',
runtimeFilter: { $or: [] },
rows: [],
},
{
// TRUE — the AND identity: a conjunction of zero conditions constrains
// nothing, so the bucket comes back.
name: 'an empty $and → no constraint',
runtimeFilter: { $and: [] },
rows: [{ stage: 'won', revenue: 100 }],
},
{
// NOT TRUE ≡ FALSE (#5325's square, crossing this seam).
name: 'a $not of {} → the zero-row constant',
runtimeFilter: { $not: {} },
rows: [],
},
{
// A `{}` disjunct is TRUE and ABSORBS the $or: every row, NOT the
// narrowed `stage = lost` branch (which would return zero rows here —
// the bucket is stage 'won' — so absorption and narrowing are
// distinguishable in this fixture).
name: 'a {} disjunct absorbs its $or',
runtimeFilter: { $or: [{ stage: 'lost' }, {}] },
rows: [{ stage: 'won', revenue: 100 }],
},
];

for (const c of IDENTITIES) {
it(`${c.name} → 200, rows ${JSON.stringify(c.rows.length)}`, async () => {
const route = buildRoute(async () => realAnalytics());
const res = await post(route, { dataset, selection: { ...selection, runtimeFilter: c.runtimeFilter } });
expect(res.statusCode).toBe(200);
expect(res.body.code).toBeUndefined();
expect(res.body.rows).toEqual(c.rows);
});
}
});

describe('[#5352] the message-sniffing fallback still classifies the families that carry no envelope', () => {
// Every entry of the route's regex list, produced as its owner produces it:
// a bare `Error`. Re-verified unenveloped while #5352 was implemented —
Expand Down
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
Loading
Loading