Skip to content

Commit 49f208b

Browse files
hotlongclaude
andauthored
fix(analytics): refuse an undefined comparand in the where door instead of reading it seven ways (#6386) (#6445)
`buildNode`'s first line was `if (raw === undefined) continue;` — the entry gate doing the one thing the rest of the module forbids: dropping a key without a trace, which does not narrow a query, it WIDENS it. Measured on origin/main (5faa23c), one shape had seven readings; the first three widen: {d: undefined} -> null (the WHOLE where dropped) {stage:'won', d: undefined} -> stage equals 'won' (the conjunct vanished) {$not: {d: undefined}} -> NOT (d set) = d IS NULL (an invented predicate) {d: {$eq: undefined}} -> d equals [null] (value comparison) {d: {$gt: undefined}} -> d gt [null] {d: {$in: [undefined]}} -> d in [null] {d: {$ne: undefined}} -> d notSet OR d notEquals [null] Row three is the strangest: #5146's null-safe rewrite splits the leaf into `{d: {$null: false}} AND {d: undefined}`, the entry gate dropped the second half, and the surviving guard was negated — so a predicate grew out of a discarded leaf. #6050 ruling B (undefined in a comparand position is refused) is pushed down to this door, in this module's existing envelope: INVALID_FILTER / 400, because unlike `read-scope-sql`'s platform-compiled artifact this door receives caller input. `assertDefinedComparands` sweeps the four comparand positions in `fieldLeaves` — the only leaf producer, so one gate covers all three consumers of the tree — and deleting the entry line lets each combinator reach the refusal it already had. The direction is silently wrong RESULTS, not a permission bypass: read scope is compiled by the other door and never passes through here. `comparand()` (#5526) and the strict `wrapper[opKey] === null` branch (#5332) are byte-identical: this refuses an INPUT and reopens neither. The normalisation is now unreachable from this door, which is recorded rather than acted on. Claude-Session: https://claude.ai/code/session_015a5qkLzpGXhLL2F5gvJ7dD Co-authored-by: Claude <noreply@anthropic.com>
1 parent 53068c1 commit 49f208b

6 files changed

Lines changed: 869 additions & 17 deletions

File tree

‎.changeset/lucky-pandas-repeat.md‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(analytics): an `undefined` comparand in an analytics `where` is refused (400 `INVALID_FILTER`), not read seven different ways
6+
7+
**Observable behaviour change.** A `where` key whose value is `undefined` used to
8+
compile — in seven different ways, depending on where it sat. It is now refused
9+
with `INVALID_FILTER` / 400, the envelope every other refusal at this door
10+
already carries.
11+
12+
The three that mattered WIDENED the query, which is the failure mode
13+
`filter-normalizer.ts` forbids in its own body ("NEVER drop: a missing predicate
14+
does not narrow the query, it WIDENS it"), while its entry line did exactly that:
15+
16+
| `where` | used to normalize to | reading |
17+
|---|---|---|
18+
| `{d: undefined}` | `null` | the WHOLE filter dropped — the query ran **unfiltered** |
19+
| `{stage: 'won', d: undefined}` | `stage equals 'won'` | the `d` conjunct vanished in silence |
20+
| `{$not: {d: undefined}}` | `NOT (d set)` | `d IS NULL` — a predicate the author never wrote |
21+
| `{d: {$eq: undefined}}` | `d equals [null]` | a value comparison, **not** `$eq: null`'s null predicate |
22+
| `{d: {$gt: undefined}}` | `d gt [null]` | ditto |
23+
| `{d: {$in: [undefined]}}` | `d in [null]` | ditto |
24+
| `{d: {$ne: undefined}}` | `d notSet OR d notEquals [null]` | ditto |
25+
26+
The direction is silently **wrong results** — an analytics figure, a report
27+
total, an aggregate, wrong with nothing to read — **not** a permission bypass:
28+
read scope is compiled by a different door (`read-scope-sql.ts`) and never passed
29+
through here, so a caller still saw only rows it was entitled to, just more of
30+
them than it asked for.
31+
32+
**What to change if this refuses your filter.** `undefined` cannot cross JSON, so
33+
neither REST door can carry it — this only reaches in-process callers of
34+
`AnalyticsService.query({ where })` that spread a possibly-absent value into the
35+
filter object (`{ owner_id: ctx.user?.id }`). Two repairs, both stated by the
36+
error message:
37+
38+
- meant the null predicate → write `{ field: null }` or `{ field: { $null: true } }`;
39+
- the value is genuinely absent → **omit the key**, which is the same "no
40+
constraint" without the ambiguity.
41+
42+
Inside stored metadata, the platform's own answer to "scope this to the current
43+
user" is unaffected and was already fail-closed: a `{current_user_id}`
44+
placeholder resolves through `resolveFilterTokens`, which raises
45+
`FILTER_TOKEN_UNRESOLVED` / 400 rather than emitting `undefined`.
46+
47+
⛔ **`null` does not move.** `{d: null}`, `{$eq: null}`, `{$ne: null}`,
48+
`{$null: …}`, `{$exists: …}` and `$contains: null` keep their exact lowering —
49+
`null` is a declared comparand and is the null predicate. `$null` / `$exists`
50+
carry a declared boolean flag rather than a comparand and are likewise untouched.

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

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -722,9 +722,34 @@ describe('[#5325] analytics `where` — NULL-safe `$not` and the boolean identit
722722
expect(sql).toContain('"account"."region" = $1');
723723
});
724724

725-
it('an undefined value is still skipped, as it always was', async () => {
726-
expect(await ids({ stage: undefined, owner: 'u1' })).toEqual(['1', '3']);
727-
expect(await ids({ $not: undefined, owner: 'u1' })).toEqual(['1', '3']);
725+
it('an undefined value is REFUSED at this seam (#6386), not skipped', async () => {
726+
// ⚠️ REPLACED, not re-spelled. This case read "an undefined value is still
727+
// skipped, as it always was" and asserted `ids({stage: undefined, owner:
728+
// 'u1'}) === ['1','3']` — i.e. it PINNED the skip as a guarantee. #6386
729+
// measured what the skip actually bought: `{stage: undefined}` alone
730+
// normalised to `null`, so a single-key `where` ran with NO filter and the
731+
// chart was drawn over every row — the #3650 widening this file's own
732+
// subject (`$not`) exists to prevent, arriving through the entry gate
733+
// instead. The two-key spelling above hid that: `owner: 'u1'` survived, so
734+
// the row set still looked filtered.
735+
//
736+
// Kept at THIS seam deliberately — `ids` executes end to end through
737+
// `NativeSQLStrategy`, which is where a compiled-to-nothing filter turns
738+
// into a statement with no `WHERE` (#5297's lesson, one call above the
739+
// compiler). So this asserts the refusal reaches the executing seam, not
740+
// merely that the normalizer throws in isolation.
741+
await expect(ids({ stage: undefined, owner: 'u1' })).rejects.toThrowError(
742+
/comparand at "stage" is undefined/,
743+
);
744+
// `$not: undefined` is NOT the same condition and must not borrow the same
745+
// message: it is a combinator with a missing operand, and the branch that
746+
// already owned that shape gives the truer diagnosis.
747+
await expect(ids({ $not: undefined, owner: 'u1' })).rejects.toThrowError(
748+
/"\$not" requires a filter object/,
749+
);
750+
// The row set the old assertion recorded is still reachable — by writing
751+
// the filter the author meant, with the unknowable key simply omitted.
752+
expect(await ids({ owner: 'u1' })).toEqual(['1', '3']);
728753
});
729754

730755
it('an ordinary filter compiles to exactly the SQL it always did', async () => {

0 commit comments

Comments
 (0)