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
30 changes: 30 additions & 0 deletions .changeset/19888-analytics-implicit-array.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@objectstack/service-analytics": minor
---

fix(service-analytics)!: the analytics `where` door refuses a list in the equality slot instead of reading it as `IN` (#19888)

Clause-②: no (narrowing)

<!-- adr-0087: not-required (already-registered filter-equality-array-comparand-refused) the transition from a list in the equality slot to the operator it stood in for was registered by #19757 for the FilterCondition equality slot at the runtime filter doors; this change adds no new transition, it brings the analytics object-form `where` and the draft-data preview under the one already on the ledger, and the entry's own prescription and acceptance criteria apply verbatim -->

**BREAKING**: this narrows what the analytics faces of `@objectstack/service-analytics` accept. A caller `where`, a dataset `filter` or a measure `filter` that carries a list in the equality slot, `{ field: [...] }` (the empty list included) or `{ field: { $eq: [...] } }`, at any depth under `$and` / `$or` / `$not` or inside a nested relation, compiled before this change. It is now refused with `INVALID_FILTER` / 400. It ships as `minor` under the launch-window convention for accept-set narrowings. The remedy is `$in`:

| you wrote | write instead |
|:--|:--|
| `{ stage: ['won', 'lost'] }` or `{ stage: { $eq: ['won', 'lost'] } }`, meaning "one of these values" | `{ stage: { $in: ['won', 'lost'] } }` |
| `{ stage: ['won'] }`, meaning one value | `{ stage: 'won' }` |
| `{ stage: [] }` | `{ stage: { $in: [] } }`, which matches no row, as the bare list did |

`$in` charts the rows the implicit list charted before. The refusal also names `{ "$contains": "…" }` for "the stored list holds a value" on a multi-value field.

Ruling 乙 of #19757 refuses a list in the equality slot at the shared comparand-shape face in `@objectstack/spec`, for every driver at once. The analytics `where` door met that face only for the `FilterArray` spelling (`['stage', '=', ['won', 'lost']]`), which was already refused. The object spelling was compiled by the analytics filter normalizer, which read one condition four ways:

- `{ stage: ['won', 'lost'] }` compiled to `stage IN (...)`. On the ObjectQL path the engine received `{ stage: { $in: [...] } }`, so the engine's own shared-face check never saw the list.
- `{ stage: { $eq: ['won', 'lost'] } }` compiled to `stage = 'won'`, and `'lost'` was dropped without a word.
- `{ stage: { $eq: [] } }` compiled to no predicate at all, so the chart was drawn over every row.
- `{ stage: [] }` compiled to the FALSE constant.

Each list is now handed to the shared face's equality arm before any node is built. Both spellings of one condition therefore get the same refusal, with the same wording, path and `$in` prescription. The draft-data preview runs the same gate, so a drafted chart refuses what the published chart refuses. Before, it compared each row against the list's string form.

Who is affected: nothing in this repository's examples, seeds or docs authors the shape (measured over `examples/**`, `packages/**` and the fenced code in the docs). Stored datasets, dashboard widget filters, report runtime filters and measure filters in a deployment were NOT measured. The authoring schema still admits the shape, so such a document still publishes, and it is refused when it is charted. The refusal names the field and the path. `$ne` with a list is not part of the ruling and is not judged here. The list operators (`$in`, `$nin`, `$between`) keep their lists, and every scalar, `null` included, compiles as before.
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,14 @@ const MATRIX: readonly Row[] = [
// [#19975] One cell of this row moved AFTER the #8186 measurement, on
// purpose: ruling 乙 (#19757) refuses a list in the equality slot, and the
// read-scope lowering now refuses it under `$eq` instead of binding the list
// as one parameter (`read-scope-eq-array-refusal.test.ts`). The `where`
// door's `$eq` cell is not that change's and keeps its measured answer.
// as one parameter (`read-scope-eq-array-refusal.test.ts`).
// [#19888] And the `where` door's `$eq` cell, for the same ruling: the list
// is handed to the shared face's equality arm and refused INVALID_FILTER /
// 400, where it used to compile to `qty = 'al'` with `'be'` dropped in
// silence (`where-equality-slot-list-refusal.test.ts`).
{ label: 'array', value: ['al', 'be'],
bindable: false, renderable: false,
whereLike: REFUSED_WHERE, whereIn: REFUSED_WHERE, whereEq: OK,
whereLike: REFUSED_WHERE, whereIn: REFUSED_WHERE, whereEq: REFUSED_WHERE,
scopeLike: REFUSED_SCOPE, scopeIn: REFUSED_SCOPE, scopeEq: REFUSED_SCOPE },
// A `$field` scalar comparand is SERVED on the `where` door since the
// 2026-08-12 ruling (NativeSQLStrategy declines, the engine path runs it) and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,7 @@ describe('[#5325] analytics `where` — NULL-safe `$not` and the boolean identit

// ── An empty set is a constant, not an absent predicate ───────────────────

describe('an empty `$in` / bare `[]` is the FALSE constant, not a dropped clause', () => {
describe('an empty `$in` is the FALSE constant, not a dropped clause', () => {
it('`{stage: {$in: []}}` matches no row — was the whole dataset', async () => {
expect(await ids({ stage: { $in: [] } })).toEqual([]);
const { sql } = await sqlFor({ stage: { $in: [] } });
Expand All @@ -556,8 +556,14 @@ describe('[#5325] analytics `where` — NULL-safe `$not` and the boolean identit
expect(await ids({ stage: { $nin: [] } })).toEqual(ALL);
});

it('a bare `[]` is the same constant its explicit spelling is', async () => {
expect(await ids({ stage: [] })).toEqual([]);
// [#19888] RE-JUDGED. This pinned the bare `[]` as the FALSE constant its
// explicit `$in: []` spelling is. Ruling 乙 (#19757) refuses a list in the
// equality slot, the empty one included — only `$in: []` / `$nin: []` are
// declared predicates — so the bare spelling is refused and the explicit
// one above keeps its constant. `where-equality-slot-list-refusal.test.ts`
// pins the refusal at every depth.
it('a bare `[]` is refused (INVALID_FILTER / 400); only `$in: []` is the FALSE constant', async () => {
await expect(ids({ stage: [] })).rejects.toMatchObject({ code: 'INVALID_FILTER', status: 400 });
});

it('it composes: FALSE absorbs an `$and`, is the identity of an `$or`', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,18 +167,19 @@ const MEASURED: Array<{ name: string; where: unknown; path: string; wasReadAs: s
/**
* Comparand positions beyond the issue's seven, measured in the same round.
*
* Two of them are where this gate deliberately diverges from `read-scope-sql`'s
* twin, and in both cases because THIS module accepts the enclosing shape that
* one refuses outright — so a "comparand is undefined" answer is the truest
* thing to say here and would have been a mislabel there.
* One of them (the nested relation) is where this gate deliberately diverges
* from `read-scope-sql`'s twin, because THIS module accepts the enclosing shape
* that one refuses outright — so a "comparand is undefined" answer is the
* truest thing to say here and would have been a mislabel there.
*
* [#19888] There used to be a second: a member of the BARE-ARRAY implicit `$in`,
* `{d: [1, undefined]}` → `"d"[1]`. Ruling 乙 (#19757) refuses a list in the
* equality slot, so that enclosing shape is no longer accepted: the list is
* refused as a whole before this gate runs, as the twin always did, and the row
* moved to `where-equality-slot-list-refusal.test.ts` ("a list is diagnosed as
* the list, not by one of its members").
*/
const BEYOND_THE_TABLE: Array<{ name: string; where: unknown; path: string; wasReadAs: string }> = [
{
name: 'a member of the BARE-ARRAY implicit $in (twin refuses the array itself)',
where: { d: [1, undefined] },
path: '"d"[1]',
wasReadAs: 'd in [1, null]',
},
{
name: "a $between bound — lowered to the leaf's comparand",
where: { d: { $between: [undefined, 5] } },
Expand Down Expand Up @@ -294,11 +295,12 @@ const NULL_CONTROL: Array<{ name: string; where: unknown; tree: unknown }> = [
where: { $not: { d: { $ne: null } } },
tree: { kind: 'not', child: { kind: 'leaf', member: 'd', operator: 'set', values: [] } },
},
{
name: '{d: [1, null]} → a bare-array $in carrying null',
where: { d: [1, null] },
tree: { kind: 'leaf', member: 'd', operator: 'in', values: [1, null] },
},
// [#19888] RE-JUDGED. A row `{d: [1, null]} → d in [1, null]` stood here: a
// bare-array implicit `$in` carrying null. Ruling 乙 (#19757) refuses a list in
// the equality slot, so that shape is refused whole (INVALID_FILTER / 400,
// `where-equality-slot-list-refusal.test.ts`) and is no longer an accepted
// position for this group to hold still. A null member of a list keeps its
// accepted spelling, `{$in: [null]}`, two rows above.
];

// ─────────────────────────────────────────────────────────────────────────────
Expand Down
Loading
Loading