Skip to content

Commit cfc293f

Browse files
os-zhuangclaude
andauthored
fix(service-analytics): 空 $and/$or 按布尔单位元归约,两个编译器对齐五后端,四条进一致性表 (#5322) (#5365)
* fix(service-analytics): 空组合子按布尔单位元归约,两个编译器对齐五后端 (#5322) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB * chore: changeset for #5322 (service-analytics + spec) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB * fix(service-analytics): 空数组单位元在 #5335 的 const 节点体系上重放,pin 翻向 (#5322) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB * chore(#5322): 收官同步 —— filter.zod 空组合子宣告转正、#5366 refusal 表随裁定翻向 - filter.zod.ts:按 #5323 同步散文预留的交接("The declaration flips to stated contract with that PR"),空组合子单位元从「Deliberately NOT declared」段转为正式契约段;{field:{}} 半边保持未宣告(#5376 仍开)。 - filter-refusal-envelope.test.ts(#5366 新到):空数组两行从 REFUSALS 翻入 ACCEPTED(单位元树断言),同一守卫点的非数组拼写补位 REFUSALS,信封不变。 - filter-logic-conformance.ts:族 1 段落按分工删除(四行已进表),族 2/3 原样。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB * test(rest): #5352 信封 suite 的空 $or 行随 #5322 拍板翻向 —— 单位元 200+行数语义,非数组拼写补位 400 REST 层是 #5352 refusal pin 的第三份拷贝(service-analytics 两份已翻)。 harness 的 executeAggregate 从常量改为按引擎侧 filter 求值,四条单位元 断言(空 $or 零行、空 $and 全部行、{$not:{}} 零行、{} 析取项吸收) 因此承重 —— 200 之外还钉行数,与被丢弃的 filter 可区分。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent df54254 commit cfc293f

12 files changed

Lines changed: 528 additions & 95 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
"@objectstack/spec": patch
4+
---
5+
6+
fix(service-analytics): 空 `$and` / `$or` 按布尔单位元归约,两个编译器与五后端对齐 (#5322)
7+
8+
同一个仓库对空组合子曾有两个对立答案:五个 `FILTER_LOGIC_CASES` 后端
9+
(`driver-sql` #5134/PR #5243`driver-memory``formula``driver-sqlite-wasm`
10+
`driver-mongodb` #5239)把 `{ $and: [] }` / `{ $or: [] }` 归约成布尔单位元,而
11+
service-analytics 的两个编译器 —— `read-scope-sql.ts``compileNode`
12+
`filter-normalizer.ts``buildNode` —— 成文地 fail-closed 抛错("An empty
13+
combinator has no defensible reading…"),并有 pin 测试钉住。2026-08-04 维护者拍板
14+
(#5322)取单位元,本次把两处对齐:
15+
16+
- `{ $and: [] }` = TRUE(全部行,AND 单位元);`{ $or: [] }` = FALSE(零行,OR
17+
单位元)。嵌套可归约:空组合子作 `$or` 分支时按 TRUE 吸收/FALSE 退出析取,作
18+
`$not` 操作数时取反(`{$not: {$and: []}}` = 零行、`{$not: {$or: []}}` = 全部
19+
行)。`{}` = TRUE 与 `{ $not: {} }` = 零行两格已由 #5297(read-scope)/#5325
20+
(normalizer)先行落地,本次连同这四格由同一张一致性表钉住。
21+
- **迁移含义**:过去发出空组合子的调用方收到的是抛错(REST 面上是一次失败的请
22+
求);现在按上表求值。`{ $or: [] }` 在 RLS/图表场景是 fail-closed 的 —— 析取列
23+
表循环出零项时隐藏全部行,而不是放行全表。写作期对字面量空组合子的响亮拒收另立
24+
#5330(publish/lint),不在运行期。
25+
- **没有放宽的部分**:非数组的 `$and`/`$or`、非对象的分支、非对象的 `$not` 操作数
26+
仍然抛错(#5325 的形状拒收原样保留)。归约让「无约束」成为有意义的裁决,静默把
27+
畸形分支读成 TRUE 会让垃圾析取项吸收 `$or` 而放宽查询,所以畸形形状保持响亮。
28+
- 归约与 #5146/#5325 的 NULL-safe `$not` 重写的组合语义是「先归约、后 NULL-safe」
29+
—— 常量归约出的单位元不受重写影响,幸存的叶子照常加守卫,有测试钉住。
30+
- `packages/spec`:`FILTER_LOGIC_CASES` 补四条布尔单位元行(空 `$and`、空 `$or`
31+
`{}` 析取项吸收、`{$not: {}}`),两个 analytics conformance suite 与五后端从此
32+
被同一张表钉住这四格。

packages/rest/src/analytics-filter-refusal-envelope.test.ts

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,16 +96,33 @@ function buildRoute(analyticsProvider?: any) {
9696
/**
9797
* A REAL `AnalyticsService` on the ObjectQL aggregate path.
9898
*
99-
* `executeAggregate` returns a fixed bucket, so a query that gets far enough to
100-
* touch data succeeds — which is what makes the refusal cases meaningful: they
101-
* fail on the FILTER, on a route that demonstrably answers 200 otherwise.
99+
* `executeAggregate` evaluates the engine-side filter it receives over one
100+
* fixed bucket, so a query that gets far enough to touch data succeeds — which
101+
* is what makes the refusal cases meaningful: they fail on the FILTER, on a
102+
* route that demonstrably answers 200 otherwise. It is filter-AWARE (not a
103+
* constant) so the #5322 identity cases are load-bearing too: the zero-row
104+
* constant — `{$not: {}}`, the spelling `filterNodeToCondition` emits for
105+
* FALSE — must come back as 200 with NO rows, distinguishable from both a 400
106+
* and from an ignored filter.
102107
*/
103108
function realAnalytics(): AnalyticsService {
104109
const silent: Logger = { debug() {}, info() {}, warn() {}, error() {} };
110+
const bucket = { stage: 'won', revenue: 100 };
111+
const matches = (cond: Record<string, unknown>): boolean =>
112+
Object.entries(cond).every(([key, value]) => {
113+
if (key === '$and') return (value as Record<string, unknown>[]).every(matches);
114+
if (key === '$or') return (value as Record<string, unknown>[]).some(matches);
115+
if (key === '$not') return !matches(value as Record<string, unknown>);
116+
if (value !== null && typeof value === 'object' && '$eq' in (value as object)) {
117+
return (bucket as Record<string, unknown>)[key] === (value as { $eq: unknown }).$eq;
118+
}
119+
return (bucket as Record<string, unknown>)[key] === value;
120+
});
105121
return new AnalyticsService({
106122
logger: silent,
107123
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
108-
executeAggregate: async () => [{ stage: 'won', revenue: 100 }],
124+
executeAggregate: async (_object: string, options: { filter?: Record<string, unknown> }) =>
125+
matches(options?.filter ?? {}) ? [{ ...bucket }] : [],
109126
isRegisteredObject: () => true,
110127
});
111128
}
@@ -163,9 +180,14 @@ describe('[#5352] POST /analytics/dataset/query — a filter refusal reaches the
163180
message: /needs a two-element \[min, max\] array/,
164181
},
165182
{
166-
name: 'an empty $or',
167-
runtimeFilter: { $or: [] },
168-
message: /"\$or" requires a non-empty array/,
183+
// FLIPPED with the #5322 ruling (2026-08-04): this entry was `{$or: []}`
184+
// pinning the "requires a non-empty array" refusal. The empty array is
185+
// now the OR identity — FALSE, zero rows, asserted in the #5322 block
186+
// below — so the refusal that survives at the same guard site is the
187+
// non-array spelling, same envelope.
188+
name: 'an $or that is not an array',
189+
runtimeFilter: { $or: 'won' },
190+
message: /"\$or" requires an array of filter objects/,
169191
},
170192
{
171193
name: 'an $or branch that is not a filter object',
@@ -195,6 +217,57 @@ describe('[#5352] POST /analytics/dataset/query — a filter refusal reaches the
195217
}
196218
});
197219

220+
describe('[#5322] empty combinators are boolean identities at the REST face — evaluated, not refused', () => {
221+
// Until the 2026-08-04 #5322 ruling, `{$or: []}` sat in REFUSALS above and
222+
// this route answered it 400 ("requires a non-empty array"). The ruling took
223+
// the identity reduction the five FILTER_LOGIC_CASES backends already gave:
224+
// these four shapes are ANSWERS now, so each asserts its 200 AND its row
225+
// semantics — the row count is what separates the two identities from each
226+
// other and from a filter that was silently dropped.
227+
const IDENTITIES: Array<{ name: string; runtimeFilter: unknown; rows: unknown[] }> = [
228+
{
229+
// FALSE — the OR identity. Zero rows is the fail-closed direction: a
230+
// disjunct list that looped to zero items hides the data, it does not
231+
// chart the whole dataset (#5134).
232+
name: 'an empty $or → the zero-row constant',
233+
runtimeFilter: { $or: [] },
234+
rows: [],
235+
},
236+
{
237+
// TRUE — the AND identity: a conjunction of zero conditions constrains
238+
// nothing, so the bucket comes back.
239+
name: 'an empty $and → no constraint',
240+
runtimeFilter: { $and: [] },
241+
rows: [{ stage: 'won', revenue: 100 }],
242+
},
243+
{
244+
// NOT TRUE ≡ FALSE (#5325's square, crossing this seam).
245+
name: 'a $not of {} → the zero-row constant',
246+
runtimeFilter: { $not: {} },
247+
rows: [],
248+
},
249+
{
250+
// A `{}` disjunct is TRUE and ABSORBS the $or: every row, NOT the
251+
// narrowed `stage = lost` branch (which would return zero rows here —
252+
// the bucket is stage 'won' — so absorption and narrowing are
253+
// distinguishable in this fixture).
254+
name: 'a {} disjunct absorbs its $or',
255+
runtimeFilter: { $or: [{ stage: 'lost' }, {}] },
256+
rows: [{ stage: 'won', revenue: 100 }],
257+
},
258+
];
259+
260+
for (const c of IDENTITIES) {
261+
it(`${c.name} → 200, rows ${JSON.stringify(c.rows.length)}`, async () => {
262+
const route = buildRoute(async () => realAnalytics());
263+
const res = await post(route, { dataset, selection: { ...selection, runtimeFilter: c.runtimeFilter } });
264+
expect(res.statusCode).toBe(200);
265+
expect(res.body.code).toBeUndefined();
266+
expect(res.body.rows).toEqual(c.rows);
267+
});
268+
}
269+
});
270+
198271
describe('[#5352] the message-sniffing fallback still classifies the families that carry no envelope', () => {
199272
// Every entry of the route's regex list, produced as its owner produces it:
200273
// a bare `Error`. Re-verified unenveloped while #5352 was implemented —
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#5322] Empty combinators reduce to their boolean identities in the
5+
* analytics filter normalizer — `{$and: []}` = TRUE, `{$or: []}` = FALSE —
6+
* matching the five `FILTER_LOGIC_CASES` backends row for row. Together with
7+
* the `{}` / `{$not: {}}` identities #5325 already gave this module, the
8+
* boolean algebra over the combinators is now complete.
9+
*
10+
* # The history this file flips
11+
*
12+
* Until the 2026-08-04 #5322 ruling, `buildNode` REFUSED the empty arrays.
13+
* Its error message argued the opposite position, verbatim:
14+
*
15+
* > `"$and" requires a non-empty array. An empty combinator has no defensible
16+
* > reading — dropping it widens the query, and treating it as "match
17+
* > nothing" silently empties a chart.`
18+
*
19+
* "Treating it as match nothing" is exactly what #5134 ruled for `$or: []`
20+
* and what `driver-sql` / `driver-memory` / `formula` / `driver-sqlite-wasm`
21+
* / `driver-mongodb` (#5239) implement. The ruling took the reduction because
22+
* only a reduction can evaluate a NESTED tree (a rejection must first reduce
23+
* to decide whether `$and: []` inside a `$or` branch is an error — which
24+
* concedes the point), and because `{$or: []}` = zero rows is fail-closed
25+
* where it matters: a scope whose disjunct list loops to zero items hides
26+
* every row rather than widening to the whole table. The loud authoring-time
27+
* rejection of the literal spellings lives on as #5330 (publish/lint), not as
28+
* runtime behavior.
29+
*
30+
* # What deliberately did NOT loosen
31+
*
32+
* Non-array `$and`/`$or` still throws (this file), as do non-object branches
33+
* and non-object `$not` operands (pinned in
34+
* `filter-normalizer-not-null-safe.test.ts`): reduction makes `null` ("no
35+
* constraint") a meaningful verdict, so silently mapping junk to it would let
36+
* a malformed disjunct ABSORB its `$or` and widen the query — the exact
37+
* failure mode the old error message feared, reachable only through the
38+
* lenient path.
39+
*
40+
* Row-level conformance for the four ruled shapes lives in the shared table
41+
* (`filter-logic-conformance.ts`), executed against a real SQLite engine by
42+
* `native-sql-filter-logic-conformance.test.ts` and
43+
* `read-scope-sql-conformance.test.ts`. This file pins the TREE the
44+
* normalizer produces and the seam where the ObjectQL engine path receives
45+
* the boolean constant.
46+
*/
47+
48+
import { describe, it, expect } from 'vitest';
49+
import { DatasetSchema } from '@objectstack/spec/ui';
50+
51+
import {
52+
normalizeAnalyticsFilterTree,
53+
collectFilterLeaves,
54+
} from '../strategies/filter-normalizer.js';
55+
import { AnalyticsService } from '../analytics-service.js';
56+
57+
const tree = (where: unknown) => normalizeAnalyticsFilterTree({ where });
58+
59+
const FALSE_NODE = { kind: 'const', value: false };
60+
const TRUE_NODE = { kind: 'const', value: true };
61+
62+
describe('[#5322] buildNode reduces empty combinators to boolean identities', () => {
63+
it('`{$and: []}` is TRUE — no constraint', () => {
64+
expect(tree({ $and: [] })).toBeNull();
65+
});
66+
67+
it('`{$or: []}` is FALSE — the zero-row constant', () => {
68+
expect(tree({ $or: [] })).toEqual(FALSE_NODE);
69+
});
70+
71+
it('`$not` negates the REDUCED operand, in both directions', () => {
72+
expect(tree({ $not: { $and: [] } })).toEqual(FALSE_NODE); // NOT TRUE ≡ FALSE
73+
expect(tree({ $not: { $or: [] } })).toEqual(TRUE_NODE); // NOT FALSE ≡ TRUE
74+
});
75+
76+
it('an empty-combinator branch carries its identity into the enclosing combinator', () => {
77+
// A `{$and: []}` disjunct is TRUE and ABSORBS the whole `$or` — collapsing
78+
// to the surviving branches instead is the narrowing #5325 fixed for the
79+
// literal `{}` disjunct.
80+
expect(tree({ $or: [{ a: 'x' }, { $and: [] }] })).toBeNull();
81+
// A `{$or: []}` conjunct is FALSE; the compiled conjunction carries the
82+
// constant (row-set: zero rows — pinned via SQL in the conformance suite).
83+
expect(JSON.stringify(tree({ $and: [{ a: 'x' }, { $or: [] }] }))).toContain('"value":false');
84+
expect(JSON.stringify(tree({ a: 'x', $or: [] }))).toContain('"value":false');
85+
});
86+
87+
it('the FALSE constant touches no member', () => {
88+
expect(collectFilterLeaves(tree({ $or: [] }))).toEqual([]);
89+
expect(collectFilterLeaves(tree({ $and: [{ $or: [] }] }))).toEqual([]);
90+
});
91+
92+
it('non-array `$and`/`$or` still throws — #5322 loosened only the EMPTY array', () => {
93+
expect(() => tree({ $and: 'x' })).toThrow(/requires an array/);
94+
expect(() => tree({ $or: { a: 1 } })).toThrow(/requires an array/);
95+
});
96+
});
97+
98+
// ── The engine-path seam: FALSE reaches ObjectQL as a real zero-row filter ──
99+
100+
const dataset = DatasetSchema.parse({
101+
name: 'incidents',
102+
label: 'Incidents',
103+
object: 'incident',
104+
dimensions: [{ name: 'severity', field: 'severity', type: 'string' }],
105+
measures: [{ name: 'incident_count', aggregate: 'count' }],
106+
});
107+
108+
const ROWS: Array<{ severity: string }> = [
109+
{ severity: 'high' },
110+
{ severity: 'high' },
111+
{ severity: 'low' },
112+
];
113+
114+
/**
115+
* Stand-in for `engine.aggregate`, mirroring how a driver receives the
116+
* filter: `{$not: {}}` — the spelling `filterNodeToCondition` uses for the
117+
* FALSE constant, because `formula` and `driver-memory` already pin it as the
118+
* zero-row filter (#5134) — matches nothing, and an absent/empty filter
119+
* matches everything.
120+
*/
121+
function makeEngine(captured: Array<{ filter?: Record<string, unknown> }>) {
122+
const matches = (row: Record<string, unknown>, cond: Record<string, unknown>): boolean =>
123+
Object.entries(cond).every(([key, value]) => {
124+
if (key === '$and') return (value as Record<string, unknown>[]).every((c) => matches(row, c));
125+
if (key === '$or') return (value as Record<string, unknown>[]).some((c) => matches(row, c));
126+
if (key === '$not') return !matches(row, value as Record<string, unknown>);
127+
return row[key] === value;
128+
});
129+
return async (
130+
_object: string,
131+
options: { groupBy?: string[]; filter?: Record<string, unknown> },
132+
): Promise<Array<Record<string, unknown>>> => {
133+
captured.push({ filter: options.filter });
134+
const filtered = ROWS.filter((row) => matches(row, options.filter ?? {}));
135+
return [{ incident_count: filtered.length }];
136+
};
137+
}
138+
139+
describe('[#5322] the ObjectQL path hands the engine the constant, not silence', () => {
140+
it('`{$or: []}` arrives as the zero-row `{$not: {}}` and counts zero rows', async () => {
141+
const captured: Array<{ filter?: Record<string, unknown> }> = [];
142+
const svc = new AnalyticsService({
143+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
144+
executeAggregate: makeEngine(captured),
145+
});
146+
147+
const result = await svc.queryDataset!(dataset, {
148+
measures: ['incident_count'],
149+
runtimeFilter: { $or: [] },
150+
});
151+
152+
// The constant reached the engine as a real zero-row condition — NOT as an
153+
// absent filter, which every driver reads as "every row".
154+
expect(captured).toHaveLength(1);
155+
expect(JSON.stringify(captured[0].filter)).toContain('"$not":{}');
156+
expect(result.rows).toEqual([{ incident_count: 0 }]);
157+
});
158+
159+
it('`{$and: []}` arrives as no constraint and counts every row', async () => {
160+
const captured: Array<{ filter?: Record<string, unknown> }> = [];
161+
const svc = new AnalyticsService({
162+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
163+
executeAggregate: makeEngine(captured),
164+
});
165+
166+
const result = await svc.queryDataset!(dataset, {
167+
measures: ['incident_count'],
168+
runtimeFilter: { $and: [] },
169+
});
170+
171+
expect(JSON.stringify(captured[0].filter ?? {})).not.toContain('$and');
172+
expect(result.rows).toEqual([{ incident_count: 3 }]);
173+
});
174+
175+
it('a `{$and: []}` disjunct absorbs its `$or` instead of narrowing to the other branch', async () => {
176+
const captured: Array<{ filter?: Record<string, unknown> }> = [];
177+
const svc = new AnalyticsService({
178+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
179+
executeAggregate: makeEngine(captured),
180+
});
181+
182+
const result = await svc.queryDataset!(dataset, {
183+
measures: ['incident_count'],
184+
runtimeFilter: { $or: [{ severity: 'high' }, { $and: [] }] },
185+
});
186+
187+
// Narrowing to `severity = high` would count 2 — the #5297/#5325 seam.
188+
expect(JSON.stringify(captured[0].filter ?? {})).not.toContain('severity');
189+
expect(result.rows).toEqual([{ incident_count: 3 }]);
190+
});
191+
});

packages/services/service-analytics/src/__tests__/filter-normalizer-not-null-safe.test.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -526,16 +526,29 @@ describe('[#5325] analytics `where` — NULL-safe `$not` and the boolean identit
526526
// ── Nothing that failed closed stopped failing closed ─────────────────────
527527

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

541554
it('an unknown operator inside a `$not` still THROWS rather than being guarded', async () => {

0 commit comments

Comments
 (0)