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
55 changes: 55 additions & 0 deletions .changeset/analytics-field-reference-comparand-refusal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@objectstack/service-analytics": minor
---

fix(analytics)!: a `{ $field }` comparand is refused on both SQL-lowering doors instead of being BOUND as the comparison's value (#7598)

<!-- adr-0087: not-required (no-migration-prescription) This change retires NO key and adds none. `FieldReferenceSchema` stays declared in `packages/spec` exactly as it is, stays implemented by `@objectstack/formula`'s in-memory evaluator, and stays COMPILED by `driver-sql` / `driver-sqlite-wasm` under #5222 — `packages/spec` is untouched by this PR, no metadata schema gains or loses a key, and no authored or stored shape becomes unparseable. What moves is one COMPILER's posture at two doors of `@objectstack/service-analytics`: a shape that used to compile into a predicate binding the reference OBJECT as a value now refuses. There is therefore nothing for `objectstack migrate meta` to rewrite — the FROM shape is still valid metadata everywhere it was valid before, and rewriting it would be wrong, since the identical filter continues to execute on the ObjectQL engine path and on both SQL drivers. Nor is there a FROM/TO rule a ledger entry could state: the correct repair depends on which face the author's query routes to, which is a deployment fact rather than a metadata one. The channels that do reach an affected reader are this changeset's CHANGELOG text and the refusal message itself, which names the operator, the field, the referenced column, the faces that DO execute the shape, and why this compiler cannot — all shipped with this change. -->

**⚠️ Behaviour change.** A filter whose comparand is a field reference —
`{ amount: { $gt: { $field: 'budget' } } }`, the shape
`FieldReferenceSchema` declares and `compileCelToFilter` emits for a
field-to-field comparison in a CEL permission / RLS rule — used to COMPILE on
both of this package's doors. It now refuses: `INVALID_FILTER` / 400 on the
analytics `where` door, `READ_SCOPE_COMPILE_FAILED` / 500 on the read-scope
lowering (each door's existing envelope, unchanged).

#7598 was filed reading "these compilers still REFUSE `$field`". Measured on
`origin/main` (`5823d593d`), nothing refused. For the six scalar comparison
operators — exactly the ones #5222 taught `driver-sql` to compile into a
same-table column-to-column comparison — the reference OBJECT went into the
bind list:

| face | `{ amount: { $gt: { $field: 'budget' } } }` |
|---|---|
| `read-scope-sql` | `"person"."amount" > ?` · bound to `{"$field":"budget"}` |
| `where` → `NativeSQLStrategy` | `WHERE amount > $1` · bound to the JSON TEXT `{"$field":"budget"}` |
| `where` → `/analytics/sql` echo | `WHERE amount > $1` · bound to the reference OBJECT |
| `where` → ObjectQL engine | reached `driver-sql`, which compiles it CORRECTLY since #5222 |

So the defect was a silent wrong answer, not a refusal: a syntactically perfect
predicate comparing a column against a value no row can hold. Three of the four
faces answered differently, and on the read-scope door the one answering wrongly
is an administrator's RLS predicate. The gates assumed to be catching this
(`isBindableComparand` / `isRenderableTextComparand`) had not drifted from
`driver-sql` — they are simply never ASKED about that position, only about the
LIKE family and `$in` / `$nin` / `$between` MEMBERS.

**What this does not do:** it does not bring the capability to these compilers.
The four maintainer rulings that make a referenced column name safe in a SQL
identifier position (same-table only, declared-only enumeration, tenant-isolation
column forbidden on both sides, same comparison class) all turn on metadata
`StrategyContext` does not expose — neither an object's declared field set nor its
tenant-isolation column — so these compilers cannot enforce them, and shipping a
port without them would open a comparison surface onto the tenant boundary.
Implementing it here is a `packages/spec` contract question, left open on #7598.

Field-to-field RLS rules continue to work on the ObjectQL engine path, where the
driver compiles them with the metadata it owns; they are now loudly refused,
rather than silently mis-answered, on the raw-SQL analytics path.

Positions already refused before this change keep their exact wording — the LIKE
family, `$in` / `$nin` members, and a bare `{ field: { $field: … } }` — because
each of those refusals already CONVERGES with `driver-sql`'s own #5222 refusal
arm. `minor` rather than `patch` follows #5234, the same class of change on the
same two doors.
3 changes: 2 additions & 1 deletion packages/services/service-analytics/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "@objectstack/service-analytics",
"version": "17.0.0-rc.6",
"license": "Apache-2.0",
"description": "Analytics Service for ObjectStack implements IAnalyticsService with multi-driver strategy pattern (NativeSQL, ObjectQL, InMemory)",
"description": "Analytics Service for ObjectStack \u2014 implements IAnalyticsService with multi-driver strategy pattern (NativeSQL, ObjectQL, InMemory)",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand All@@ -23,6 +23,7 @@
"@objectstack/types": "workspace:*"
},
"devDependencies": {
"@objectstack/driver-sql": "workspace:*",
"@types/node": "^26.1.2",
"@types/sql.js": "^1.4.11",
"sql.js": "^1.14.1",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -126,9 +126,28 @@ describe('[#5234] the analytics `where` door refuses an uncompilable comparand',
it('`{$field: …}` is refused here, converging with `driver-sql`', () => {
// Not a special case: a field reference is an object, and this door had no
// opinion about objects at all. `driver-sql` has refused it since #5041.
//
// ⚠️ [#7598] The convergence claim was re-measured after #5222 gave
// `driver-sql` a real cross-field compiler, because that change was assumed
// to have made this comment stale. It did NOT, for THIS case: #5222's
// boundary admits the six scalar comparison operators and leaves the LIKE
// family in its refusal arm (`$contains against a field reference is
// refused` — a column-side LIKE pattern cannot be metacharacter-escaped
// portably, and an unescaped one is the `%`-matches-every-row bypass). So
// this pin still converges, verbatim, and is deliberately unchanged.
//
// What #5222 DID open is a position neither predicate in
// `comparand-shape.ts` is ever asked about — the whole comparand of a
// scalar comparison, which was BOUND rather than refused. That cell is
// pinned in `cross-field-reference-refusal.test.ts` against the shared
// corpus, not here, because it is a different question about a different
// position.
const err = refusalOf(() => tree({ name: { $contains: { $field: 'status' } } }));
expect(err.code).toBe('INVALID_FILTER');
expect(err.message).toContain('$field');
// The wording stays the LIKE-family one — the #7598 gate deliberately does
// not reach this operator, so a reader can tell the two refusals apart.
expect(err.message).toContain('StringOperatorSchema');
});
});

Expand DownExpand Up@@ -160,9 +179,22 @@ describe('[#5234] the analytics `where` door refuses an uncompilable comparand',
it('`{$eq: {…}}` is deliberately UNTOUCHED — a separate account', () => {
// #5526 pinned `toSqlBindValue({a:1})` → `'{"a":1}'`. Refusing it is the
// analytics-side half of #5041, which this change does not open.
//
// ⚠️ [#7598] Still true, and now load-bearing in a second way: the
// field-reference gate added there covers this very operator, so this case
// is what proves the gate keys on the SHAPE `{$field: <string>}` and not on
// "an object comparand". A gate that had widened to every object would turn
// this row red — which is why the row is worth keeping rather than being
// folded into the block above.
expect(tree({ name: { $eq: { a: 1 } } })).toEqual({
kind: 'leaf', member: 'name', operator: 'equals', values: [{ a: 1 }],
});
// The same distinction one step finer: `$field` present but NOT a string is
// the ordinary object account too, exactly as on `driver-sql`, whose
// `fieldReferenceOf` requires `typeof ref === 'string'`.
expect(tree({ name: { $eq: { $field: 5 } } })).toEqual({
kind: 'leaf', member: 'name', operator: 'equals', values: [{ $field: 5 }],
});
});
});
});
Expand Down
Loading
Loading