From a42dae2291dabb0c2b78592928f0ebb644741fb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 10:29:04 +0000 Subject: [PATCH 1/2] fix(analytics): refuse a `{ $field }` comparand on both SQL-lowering doors instead of binding it (#7598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on origin/main (5823d593d), the premise of #7598 was inverted: neither door refused a field reference in a scalar comparand position — both BOUND the reference object as the comparison's value, producing a syntactically perfect predicate comparing a column against a value no row can hold. On the read-scope door that is an administrator's RLS predicate silently answering the wrong row set. Both doors now refuse, each in its existing envelope (INVALID_FILTER / 400 on the analytics `where` door, READ_SCOPE_COMPILE_FAILED / 500 on the read-scope lowering). Positions that already refused keep their exact wording, because each of those converges with driver-sql's own #5222 refusal arm. This does NOT port the #5222 capability: its four maintainer rulings turn on an object's declared field set and its tenant-isolation column, neither of which StrategyContext exposes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015fkdTyGmMD5s8ZtEifvuGy --- ...ytics-field-reference-comparand-refusal.md | 53 +++ .../services/service-analytics/package.json | 3 +- .../__tests__/comparand-shape-refusal.test.ts | 32 ++ .../cross-field-reference-refusal.test.ts | 355 ++++++++++++++++++ .../service-analytics/src/comparand-shape.ts | 138 +++++++ .../service-analytics/src/read-scope-sql.ts | 120 ++++++ .../src/strategies/filter-normalizer.ts | 95 ++++- pnpm-lock.yaml | 3 + 8 files changed, 796 insertions(+), 3 deletions(-) create mode 100644 .changeset/analytics-field-reference-comparand-refusal.md create mode 100644 packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts diff --git a/.changeset/analytics-field-reference-comparand-refusal.md b/.changeset/analytics-field-reference-comparand-refusal.md new file mode 100644 index 0000000000..a79b0efed9 --- /dev/null +++ b/.changeset/analytics-field-reference-comparand-refusal.md @@ -0,0 +1,53 @@ +--- +"@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) + +**⚠️ 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. diff --git a/packages/services/service-analytics/package.json b/packages/services/service-analytics/package.json index 0f8a23dff0..e76cf844b1 100644 --- a/packages/services/service-analytics/package.json +++ b/packages/services/service-analytics/package.json @@ -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", @@ -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", diff --git a/packages/services/service-analytics/src/__tests__/comparand-shape-refusal.test.ts b/packages/services/service-analytics/src/__tests__/comparand-shape-refusal.test.ts index b7c48f73ce..299b71f5f4 100644 --- a/packages/services/service-analytics/src/__tests__/comparand-shape-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/comparand-shape-refusal.test.ts @@ -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'); }); }); @@ -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: }` 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 }], + }); }); }); }); diff --git a/packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts b/packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts new file mode 100644 index 0000000000..1cfa544333 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts @@ -0,0 +1,355 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7598] Both of this package's SQL-lowering doors answer a `{ $field }` + * comparand the same way — a loud refusal — instead of binding the reference + * object as a value. + * + * ## What was actually wrong, which is NOT what the issue said + * + * #7598 was filed reading "`service-analytics`' compilers still REFUSE `$field`, + * so a CEL field-to-field RLS rule 400s on those faces". Measured on + * `origin/main` (`5823d593d`) before any change here, nothing 400'd. For the six + * scalar comparison operators — exactly the ones #5222 taught `driver-sql` to + * compile — both doors COMPILED, and bound the reference object as the + * comparison's value: + * + * | face | `{ amount: { $gt: { $field: 'budget' } } }` | + * |---|---| + * | `read-scope-sql` | `"person"."amount" > ?` · bind list `[{"$field":"budget"}]` | + * | analytics `where` → `NativeSQLStrategy` | `WHERE amount > $1` · bound to the JSON TEXT `{"$field":"budget"}` | + * | analytics `where` → `/analytics/sql` echo | `WHERE amount > $1` · bound to the reference OBJECT | + * | analytics `where` → ObjectQL engine | `{amount:{$gt:{$field:'budget'}}}` — 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. On the + * read-scope door that is an ADMIN's RLS predicate quietly answering the wrong + * row set, which is why it is graded above the `where` door's empty chart. The + * gates that were assumed to be catching this — `isBindableComparand` / + * `isRenderableTextComparand` — were never ASKED about that position: both doors + * consult them for the LIKE family and for `$in`/`$nin`/`$between` MEMBERS only. + * They had not drifted from `driver-sql`; they were answering a different + * question. + * + * ## The corpus is the shared one, driven through both faces + * + * `CROSS_FIELD_CASES` / `CROSS_FIELD_REFUSALS` are exported from + * `@objectstack/driver-sql` (`cross-field-conformance-cases.ts`) precisely so a + * second face can be held to the same table, and this suite is that second + * consumer. Held here in the direction the measurement supports: **every case in + * both arms is REFUSED on both analytics doors.** The supported arm's refusals + * are the asymmetry #7598 exists to record, stated as an executable fact — when + * the capability lands here, those cases flip from "refused" to row sets and + * this file is what says so. + * + * ⚠️ Corpus `messageIncludes` are deliberately NOT asserted: those pin + * `driver-sql`'s wordings, and this package's refusals are its own (a driver + * message naming `initObjects` declarations would be a lie here). The envelope + * is asserted for every case; the wordings are asserted separately, per door, + * against the sentences `comparand-shape.ts` owns. + * + * ## Reverse verification — direction predicted BEFORE running it + * + * Plain before-green / after-red, with one predicted asymmetry. Removing the two + * `assertNoFieldReferenceComparand` call sites must: + * + * - turn every case in the `$field`-in-a-scalar-comparand blocks RED, and red + * by RESOLVING rather than by throwing something else — which is why + * `refusalOf` reports "returned" rather than letting a bare `toThrow` count + * a differently-caused throw as a pass; + * - leave the LIKE-family and list-member blocks GREEN, because those refusals + * predate this change and come from the #5234 gates. That half is the proof + * the new gate is NARROW rather than merely present. + * + * Measured with both call sites disabled, over this file and + * `comparand-shape-refusal.test.ts` together: **87 failed / 47 passed**, and + * every failure reads `expected the compiler to refuse this filter, but it + * returned …` — the predicted cells, failing in the predicted MANNER rather than + * by some other throw. Both halves of the prediction held under a targeted + * re-read of the output: + * + * - not one LIKE-family or `$in` / `$nin` corpus case went red, so the new + * gate is proven NARROW and those refusals are proven to come from the + * #5234 gates rather than from this one; + * - the two `$between`-endpoint cases went red on the `where` door only. On + * the read-scope door they stayed green, because `assertCompilableMembers` + * already refused them there — which is exactly why `$between` had to be + * named on the `where` door: its branch in `fieldLeaves` lowers to `gte` / + * `lte` before any shape gate is consulted, so it was the one comparand + * position on that door no gate had ever seen; + * - `comparand-shape-refusal.test.ts` stayed green in full (1 file failed, 1 + * passed), confirming nothing in the #5234 pins depends on this change. + * + * ## The one cell that does NOT refuse on the `where` door, recorded not hidden + * + * `$icontains` is absent from `comparand-shape.ts`'s `TEXT_PATTERN_OPERATORS`, + * so the analytics `where` door applies NO comparand-shape gate to it at all — + * a #5234-class hole that arrived with the operator itself (#6520 added + * `$icontains` to `MONGO_TO_CUBE_OP` and to `read-scope-sql`'s + * `assertRenderableText`, but not to that set). It is out of this card's scope + * and is filed separately; the block at the bottom pins the CURRENT behaviour so + * the gap is visible in test output rather than discovered again from scratch. + */ + +import { describe, it, expect } from 'vitest'; +import { + CROSS_FIELD_CASES, + CROSS_FIELD_REFUSALS, +} from '@objectstack/driver-sql'; +import type { FilterCondition } from '@objectstack/spec/data'; +import type { Cube } from '@objectstack/spec/data'; +import type { AnalyticsQuery, StrategyContext } from '@objectstack/spec/contracts'; + +import { normalizeAnalyticsFilterTree } from '../strategies/filter-normalizer.js'; +import { compileScopedFilterToSql } from '../read-scope-sql.js'; +import { ObjectQLStrategy } from '../strategies/objectql-strategy.js'; +import { + CROSS_FIELD_COMPARISON_OPERATORS, + isFieldReference, +} from '../comparand-shape.js'; + +interface WireBearingError extends Error { + code?: string; + status?: number; +} + +/** + * A refusal, or a failure that names the direction. `toThrow()` alone would let + * a differently-caused throw count as a pass, and — the direction that actually + * bites here — would report "the promise resolved" as the whole diagnosis when + * the defect is a compiler that RETURNS a predicate it should not have built. + */ +function refusalOf(run: () => unknown): WireBearingError { + let returned: unknown; + try { + returned = run(); + } catch (e) { + return e as WireBearingError; + } + throw new Error( + `expected the compiler to refuse this filter, but it returned ${JSON.stringify(returned)}`, + ); +} + +const tree = (where: unknown) => normalizeAnalyticsFilterTree({ where } as any); +const scope = (where: unknown) => compileScopedFilterToSql(where as FilterCondition, 'deal'); + +/** Does this filter put a reference in a position #5222 made the drivers COMPILE? */ +function usesScalarCrossField(filter: unknown): boolean { + if (!filter || typeof filter !== 'object') return false; + if (Array.isArray(filter)) return filter.some(usesScalarCrossField); + return Object.entries(filter as Record).some(([key, value]) => { + if (CROSS_FIELD_COMPARISON_OPERATORS.has(key) && isFieldReference(value)) return true; + return usesScalarCrossField(value); + }); +} + +// ── The shared corpus, through both doors ──────────────────────────────────── + +describe("[#7598] the #5222 corpus's SUPPORTED arm is refused by both analytics doors", () => { + // Every case here RETURNS ROWS on `driver-sql` / `driver-sqlite-wasm`. That + // these same filters are refused two doors away IS the asymmetry #7598 + // records — pinned so it cannot be closed, or widened, without this file + // saying so. + for (const testCase of CROSS_FIELD_CASES) { + it(`the \`where\` door refuses: ${testCase.name}`, () => { + const err = refusalOf(() => tree(testCase.filter)); + expect(err.code, testCase.name).toBe('INVALID_FILTER'); + expect(err.status, testCase.name).toBe(400); + expect(err.message, testCase.name).toContain('$field'); + }); + + it(`the read-scope door refuses: ${testCase.name}`, () => { + const err = refusalOf(() => scope(testCase.filter)); + expect(err.code, testCase.name).toBe('READ_SCOPE_COMPILE_FAILED'); + expect(err.status, testCase.name).toBe(500); + expect(err.message, testCase.name).toContain('read-scope-sql'); + expect(err.message, testCase.name).toContain('$field'); + }); + } +}); + +describe("[#7598] the #5222 corpus's REFUSAL arm stays refused on both doors", () => { + // These are refused on the drivers too, so this block asserts CONVERGENCE + // rather than asymmetry. `$icontains` is the single exception on the `where` + // door — see the recorded-gap block at the bottom. + const cases = CROSS_FIELD_REFUSALS.filter((c) => !JSON.stringify(c.filter).includes('$icontains')); + + for (const testCase of cases) { + it(`the \`where\` door refuses: ${testCase.name}`, () => { + const err = refusalOf(() => tree(testCase.filter)); + expect(err.code, testCase.name).toBe('INVALID_FILTER'); + expect(err.status, testCase.name).toBe(400); + }); + + it(`the read-scope door refuses: ${testCase.name}`, () => { + const err = refusalOf(() => scope(testCase.filter)); + expect(err.code, testCase.name).toBe('READ_SCOPE_COMPILE_FAILED'); + expect(err.status, testCase.name).toBe(500); + }); + } + + it('the two arms are answered by DIFFERENT gates, not by one blanket refusal', () => { + // Otherwise every assertion above would hold for a compiler that refused + // `{$field}` everywhere with one sentence — which is the thing #5240 says + // sends an operator to the wrong repair. The supported arm hits the new + // capability gate; the LIKE family and list members keep the #5234 wordings. + expect(refusalOf(() => tree({ amount: { $gt: { $field: 'budget' } } })).message) + .toContain('does not compile into a column-to-column comparison'); + expect(refusalOf(() => tree({ stage: { $contains: { $field: 'owner' } } })).message) + .toContain('StringOperatorSchema'); + expect(refusalOf(() => tree({ amount: { $in: [{ $field: 'budget' }, 1] } })).message) + .toContain('cannot be bound as a SQL parameter'); + expect(refusalOf(() => scope({ amount: { $gt: { $field: 'budget' } } })).message) + .toContain('does not compile into a column-to-column comparison'); + expect(refusalOf(() => scope({ stage: { $contains: { $field: 'owner' } } })).message) + .toContain('StringOperatorSchema'); + expect(refusalOf(() => scope({ amount: { $in: [{ $field: 'budget' }, 1] } })).message) + .toContain('cannot be bound as a SQL parameter'); + }); + + it('every corpus case that uses a SCALAR cross-field position is covered', () => { + // The corpus is imported, so a case added upstream arrives here silently. + // This asserts the two arms between them really do exercise the position + // this card is about — a corpus that drifted to zero such cases would leave + // every loop above vacuously green (#5821's empty-input-set class). + const covered = [...CROSS_FIELD_CASES, ...CROSS_FIELD_REFUSALS] + .filter((c) => usesScalarCrossField(c.filter)); + expect(covered.length).toBeGreaterThan(20); + }); +}); + +// ── What the refusal replaced: nothing binds any more ──────────────────────── + +describe('[#7598] the reference object never reaches a bind list again', () => { + // The defect was not "an error was missing" — it was a VALUE in the bind list. + // Asserting the throw alone would stay green for a compiler that threw after + // pushing the comparand, which is the exact `params`-alignment hazard both + // modules' headers warn about (#5297). + it('read-scope: a refusal leaves no partially-bound predicate behind', () => { + for (const filter of [ + { amount: { $gt: { $field: 'budget' } } }, + { $and: [{ stage: 'won' }, { amount: { $eq: { $field: 'budget' } } }] }, + { $not: { amount: { $gt: { $field: 'budget' } } } }, + ]) { + const err = refusalOf(() => scope(filter)); + expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED'); + } + }); + + it('read-scope: the identical filter with a LITERAL comparand still compiles', () => { + // The narrowness control for this door: only the reference shape moved. + expect(scope({ amount: { $gt: 5 } })).toEqual({ + sql: '"deal"."amount" > ?', + params: [5], + }); + }); + + it('`where` door: the identical filter with a LITERAL comparand still compiles', () => { + expect(tree({ amount: { $gt: 5 } })).toEqual({ + kind: 'leaf', member: 'amount', operator: 'gt', values: [5], + }); + }); + + it('a `$between` with literal bounds still lowers to its two leaves', () => { + // `$between` is the position no shape gate on the `where` door ever saw, so + // its narrowness control is worth stating explicitly. + expect(tree({ amount: { $between: [1, 9] } })).toEqual({ + kind: 'and', + children: [ + { kind: 'leaf', member: 'amount', operator: 'gte', values: [1] }, + { kind: 'leaf', member: 'amount', operator: 'lte', values: [9] }, + ], + }); + expect(scope({ amount: { $between: [1, 9] } }).params).toEqual([1, 9]); + }); +}); + +// ── The gate keys on the SHAPE, not on "an object" ─────────────────────────── + +describe('[#7598] the field-reference shape is read exactly as `driver-sql` reads it', () => { + it('extra keys do not disqualify a reference — `formula` ignores them too', () => { + const err = refusalOf(() => tree({ amount: { $gt: { $field: 'budget', extra: 1 } } })); + expect(err.code).toBe('INVALID_FILTER'); + // #5222 measured this cell driver-side and moved its own test to the + // supported arm for it: a narrower reading would let the remainder be + // re-bound as a literal on one face and ignored on another. + expect(err.message).toContain('budget'); + }); + + it('a NON-STRING `$field` is not a reference — it stays the object account', () => { + // `driver-sql`'s `fieldReferenceOf` requires `typeof ref === 'string'`, and + // this package mirrors that spelling rather than inventing a third reading. + expect(tree({ amount: { $gt: { $field: 5 } } })).toEqual({ + kind: 'leaf', member: 'amount', operator: 'gt', values: [{ $field: 5 }], + }); + expect(scope({ amount: { $gt: { $field: 5 } } }).params).toEqual([{ $field: 5 }]); + }); + + it('an ordinary object comparand is untouched — #5234 left that account open', () => { + expect(tree({ amount: { $eq: { a: 1 } } })).toEqual({ + kind: 'leaf', member: 'amount', operator: 'equals', values: [{ a: 1 }], + }); + }); +}); + +// ── The path this refusal deliberately does NOT touch ──────────────────────── + +describe('[#7598] a read scope keeps working on the ObjectQL engine path', () => { + const CUBE: Cube = { + name: 'deals', + title: 'Deals', + sql: 'deal', + measures: { total: { name: 'total', label: 'Total', type: 'count', sql: '*' } }, + dimensions: { + id: { name: 'id', label: 'Id', type: 'string', sql: 'id' }, + amount: { name: 'amount', label: 'Amount', type: 'number', sql: 'amount' }, + budget: { name: 'budget', label: 'Budget', type: 'number', sql: 'budget' }, + }, + public: false, + } as unknown as Cube; + + it('the reference reaches `engine.aggregate` intact, never `read-scope-sql`', async () => { + // Load-bearing for `read-scope-sql`'s header claim that this change refuses + // a shape without removing the one path that serves it. `ObjectQLStrategy` + // ANDs the scope into the `FilterCondition` it hands the engine, so the + // reference travels to `driver-sql` — which compiles it under the four #5222 + // rulings, with the declared-field and tenant-column metadata it owns and + // this package does not. + let seen: unknown; + const ctx = { + getCube: (n: string) => (n === 'deals' ? CUBE : undefined), + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + getReadScope: () => ({ amount: { $gt: { $field: 'budget' } } }), + executeAggregate: async (_o: string, opts: { filter?: unknown }) => { + seen = opts.filter; + return []; + }, + } as unknown as StrategyContext; + + await new ObjectQLStrategy().execute( + { cube: 'deals', measures: ['total'], dimensions: ['id'], timezone: 'UTC' } as AnalyticsQuery, + ctx, + ); + expect(seen).toEqual({ amount: { $gt: { $field: 'budget' } } }); + }); +}); + +// ── A measured gap this card does not close, recorded so it is not re-found ── + +describe('[#7598] RECORDED GAP: `$icontains` has no comparand-shape gate on the `where` door', () => { + it('an object comparand still reaches the pattern builder there', () => { + // NOT an endorsement — a pin on current behaviour. `$icontains` is missing + // from `TEXT_PATTERN_OPERATORS`, so the #5234 fence has never covered it on + // this door, while `read-scope-sql` DOES refuse it (its `$icontains` arm + // calls `assertRenderableText`). One operator, two answers inside one + // package — the split #5234 closed for its four siblings. Filed separately; + // fixing it here would be a second defect riding this card. + expect(tree({ stage: { $icontains: { $field: 'owner' } } })).toEqual({ + kind: 'leaf', member: 'stage', operator: 'icontains', values: [{ $field: 'owner' }], + }); + // The sibling door, for contrast — this is what the `where` door should say. + const err = refusalOf(() => scope({ stage: { $icontains: { $field: 'owner' } } })); + expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED'); + }); +}); diff --git a/packages/services/service-analytics/src/comparand-shape.ts b/packages/services/service-analytics/src/comparand-shape.ts index 93aca0b77c..37c1212a89 100644 --- a/packages/services/service-analytics/src/comparand-shape.ts +++ b/packages/services/service-analytics/src/comparand-shape.ts @@ -59,6 +59,29 @@ * both predicates against a mirrored copy of the driver's expressions over a * shared value table. A THIRD hand-copy is the thing to refuse: import from one * of the two, or add a consumer to that test. + * + * ## ⚠️ [#7598] What the mirror does NOT cover: a position no gate ever reached + * + * `{ $field: 'col' }` is the shape the two predicates above are most often + * assumed to handle, and they do not — not because they drifted, but because + * they are only ASKED about two of the positions a comparand can sit in. Both + * still classify a reference object exactly as `driver-sql`'s twins do (an + * object is neither bindable nor renderable), and both doors call them for the + * LIKE family and for `$in`/`$nin`/`$between` MEMBERS only. The whole comparand + * of a scalar comparison — `{ amount: { $gt: { $field: 'budget' } } }` — was + * asked of neither, so it was BOUND: measured on `origin/main` (`5823d593d`), + * the read-scope door compiled `"t"."amount" > ?` with the reference OBJECT in + * the bind list and the analytics `where` door compiled the same predicate with + * the JSON TEXT `{"$field":"budget"}`. Nothing refused, nothing logged, and the + * predicate compares a column against a value no row can hold. + * + * That is why this file gained a THIRD question — {@link isFieldReference} — + * rather than a widened answer to the first two: the defect was never a + * misclassification, so tightening `isBindableComparand` would have changed + * cells that were already right (and broken the mirror) while leaving the + * unasked position unasked. See {@link fieldReferenceComparandMessage} for what + * the two doors now say there, and why they say it instead of compiling the + * comparison the SQL drivers compile since #5222. */ /** @@ -101,6 +124,62 @@ export function isRenderableTextComparand(value: unknown): boolean { return value instanceof Date; } +/** + * [#7598] Is this comparand a `{ $field: 'col' }` reference — the shape + * `FieldReferenceSchema` declares and `compileCelToFilter` really produces for a + * field-to-field comparison in a CEL permission / RLS rule? + * + * Character for character `driver-sql`'s module-private `fieldReferenceOf` + * (`sql-driver.ts`), read as a boolean: a plain object, not an array, carrying a + * `$field` whose value is a STRING. Two deliberate consequences of mirroring + * that spelling rather than inventing a third: + * + * - **Extra keys do not disqualify it.** `{ $field: 'budget', extra: 1 }` IS a + * reference on all three faces, because `@objectstack/formula`'s + * `resolveValue` reads `'$field' in raw` and ignores the remainder. A + * narrower reading here would let the remainder be re-bound as a literal on + * one face and resolved on another — the split this whole file exists to + * close (#5222 measured the same cell driver-side and moved its own test). + * - **A non-string `$field` is NOT one.** `{ $field: 5 }` falls through to the + * ordinary object-comparand account — `driver-sql` binds it as JSON there + * and so does this package (#5234 left `{$eq: {…}}` alone on purpose). That + * cell is untouched here; changing it would be a different ruling, not a + * rider on this one. + * + * ⚠️ `@objectstack/formula` is the WIDER of the two (`'$field' in raw`, any + * value type). The driver's spelling is mirrored because this file's contract is + * to be a value-for-value mirror of `driver-sql`, and because the wider reading + * would refuse a shape the drivers bind — a new divergence in a change that + * exists to remove one. + */ +export function isFieldReference(value: unknown): value is { $field: string } { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + return typeof (value as Record).$field === 'string'; +} + +/** + * [#7598] The comparison operators whose whole comparand `driver-sql` compiles + * into a same-table column-to-column comparison since #5222 — and exactly the + * positions where a `{ $field }` silently BOUND on this package's two doors. + * + * A mirror of `driver-sql`'s module-private `CROSS_FIELD_COMPARISON_OPERATORS`, + * held by `__tests__/cross-field-reference-refusal.test.ts` rather than by this + * comment: that suite drives the SHARED corpus (`CROSS_FIELD_CASES`, exported + * from `@objectstack/driver-sql` precisely so a second face can be held to the + * same table), so an operator the driver starts or stops compiling shows up as a + * corpus case this package answers differently. + * + * Every OTHER position a reference can occupy was already refused on both doors + * and is deliberately left alone, wording included — the LIKE family through + * {@link isRenderableTextComparand}, `$in` / `$nin` members through + * {@link isBindableComparand}, and a bare `{ field: { $field: … } }` as an + * unsupported operator. Those refusals CONVERGE with `driver-sql`, which refuses + * the same positions in its own #5222 refusal arm; only this set diverged. + */ +export const CROSS_FIELD_COMPARISON_OPERATORS: ReadonlySet = new Set([ + '$eq', '$ne', '$gt', '$gte', '$lt', '$lte', +]); + /** * The Filter Protocol operators whose comparand becomes the text of a `LIKE` * pattern — the ones every compiler in this package routes through @@ -144,6 +223,65 @@ export function unrenderableTextComparandMessage(op: string, field: string, valu ); } +/** + * [#7598] The sentence both doors say about a `{ $field }` comparand they do not + * compile — shared for the same reason {@link unrenderableTextComparandMessage} + * is, and with the same split: one diagnosis, two envelopes. + * + * ## What it has to say that the other two do not + * + * The other refusals in this file answer a shape that is wrong everywhere. This + * one answers a shape that is RIGHT somewhere: since #5222 `driver-sql` and + * `driver-sqlite-wasm` compile exactly this comparand into a same-table + * column-to-column comparison, and `@objectstack/formula`'s + * `matchesFilterCondition` has always resolved it in memory. So the author is + * not told "this is nonsense" — they are told WHICH face declined and why, which + * is the difference between an authoring mistake and a platform boundary. + * + * It also names what used to happen, because that is the part a reader cannot + * reconstruct: the reference was BOUND. The predicate was syntactically perfect, + * the query ran, and a column was compared against a value no row can hold — no + * error, no log line, an empty chart or a read scope quietly answering the wrong + * row set. That is the #3650 / #5234 class, and naming it is what stops the next + * reader from "restoring" the old tolerance as a convenience. + * + * ## Why the reason is a MISSING ENUMERATION and not a missing emitter + * + * The SQL is trivial — two identifiers and an operator. What these two compilers + * do not have is the four things #5222's maintainer rulings (2026-08-06) require + * before a name may enter a SQL identifier position: the object's DECLARED field + * set, its declared TYPES (for the same-comparison-class rule), its + * tenant-isolation column (forbidden on both sides), and whether the table is + * federated. `driver-sql` reads all four out of its own `initObjects` capture; + * `StrategyContext` (`@objectstack/spec/contracts`) exposes none of them, so + * these compilers cannot enforce the rulings and refuse rather than ship a + * weaker port of them. Implementing it here is therefore a `packages/spec` + * surface question first — tracked on #7598. + */ +export function fieldReferenceComparandMessage( + op: string, + field: string, + ref: string, + position?: string, +): string { + return ( + `"${op}" on "${field}"${position ? ` (${position})` : ''} compares against the field reference ` + + `{ "$field": "${ref}" }, which this compiler does not compile into a column-to-column ` + + `comparison. Refusing rather than binding it: the reference object used to become the BOUND ` + + `VALUE of the comparison, so the emitted predicate compared "${field}" against the reference ` + + `itself — a value no row can hold — and returned a wrong row set with nothing to read. ` + + `@objectstack/spec declares this shape (FieldReferenceSchema) and it IS executed elsewhere: ` + + `@objectstack/formula resolves it per record in memory, and driver-sql / driver-sqlite-wasm ` + + `compile it to a same-table column comparison for the six scalar operators since #5222. It is ` + + `refused HERE because the #5222 rulings admit a referenced column name into SQL only after ` + + `checking it against the object's declared fields, their declared types, and its ` + + `tenant-isolation column — none of which this compiler can see (StrategyContext exposes no ` + + `such hook), so enforcing them is impossible and skipping them would open a comparison ` + + `surface onto the tenant boundary. Compare against a literal value here, or route the query ` + + `through the ObjectQL engine path, whose driver does the enforcing (#7598).` + ); +} + /** * The sentence both doors say about a list member that cannot be bound. See * {@link unrenderableTextComparandMessage} for why the message is shared and the diff --git a/packages/services/service-analytics/src/read-scope-sql.ts b/packages/services/service-analytics/src/read-scope-sql.ts index ddedbcefd1..18a1c2cf86 100644 --- a/packages/services/service-analytics/src/read-scope-sql.ts +++ b/packages/services/service-analytics/src/read-scope-sql.ts @@ -4,7 +4,10 @@ import type { FilterCondition } from '@objectstack/spec/data'; import type { RegisteredErrorCode } from '@objectstack/spec/api'; import { likePattern, LIKE_ESCAPE_CHAR, asciiLowerSqlExpr } from './like-pattern.js'; import { + CROSS_FIELD_COMPARISON_OPERATORS, + fieldReferenceComparandMessage, isBindableComparand, + isFieldReference, isRenderableTextComparand, unbindableListMemberMessage, unrenderableTextComparandMessage, @@ -203,6 +206,31 @@ import { * `looksLikeInternalErrorLeak` too — FALSE, like the other eleven — so it is * withheld from the response BY DECLARATION and teaches no sniffing list a new * phrase. + * + * ## A `{ $field }` comparand is refused, not bound (#7598) + * + * THIRTEEN refusing sites, and {@link assertNoFieldReferenceComparand} is the + * thirteenth's — the first whose shape is not wrong everywhere, only + * uncompilable HERE. #5222 taught `driver-sql` / `driver-sqlite-wasm` to compile + * `{ amount: { $gt: { $field: 'budget' } } }` into a same-table column-to-column + * comparison, under four maintainer rulings (same-table only, declared-only + * enumeration, tenant-isolation column forbidden on both sides, same comparison + * class). This compiler was measured in the same family and answered a fifth way + * again: it BOUND the reference object, so an admin's RLS predicate compared a + * column against a value no row can hold — see that function for the measured + * table and for why the four rulings cannot be enforced from here at all + * (`StrategyContext` exposes neither an object's declared field set nor its + * tenant-isolation column, so the enumeration the rulings turn on does not + * exist on this side). + * + * ⚠️ Note what this does NOT do: it does not make the capability available. + * A read scope carrying a field-to-field comparison still cannot be served by + * the raw-SQL analytics path — it is now REFUSED there instead of silently + * mis-answered, which is the whole of the change. The same scope continues to + * work on the ObjectQL engine path, where the driver compiles it and enforces + * the rulings with the metadata it owns (measured: `ObjectQLStrategy` ANDs the + * scope into the `FilterCondition` it hands `engine.aggregate`, so the reference + * reaches `driver-sql` intact and never passes through this file). */ const IDENT = /^[a-z_][a-z0-9_]*$/i; @@ -362,6 +390,17 @@ function compileField(field: string, value: unknown, qAlias: string, params: unk // two operators by name), so neither can shadow the other's message. assertBooleanFlagComparands(field, value); + // [#7598] …and the comparand that is neither a position nor a domain problem + // but a CAPABILITY one: a `{ $field }` reference the SQL drivers compile since + // #5222 and this compiler cannot. Third call rather than a widened first, for + // the reason the second one is separate — the three gates gate different + // things, and their triggers are disjoint by construction (a reference is + // never `undefined`, and `$null` / `$exists` are outside this gate's operator + // set), so none can shadow another's message. Runs AFTER both, so a + // `{ $gt: undefined }` keeps being an undefined comparand rather than becoming + // "not a field reference". + assertNoFieldReferenceComparand(field, value); + // Scalar / null → implicit equality. if (value === null) return `${col} IS NULL`; if (typeof value !== 'object' || value instanceof Date) { @@ -751,6 +790,87 @@ function assertBooleanFlagComparands(field: string, spec: unknown): void { } } +/** + * [#7598] A `{ $field: 'col' }` reference in a comparand position this compiler + * BOUND instead of refusing — the THIRTEENTH refusing site, and the first one + * whose shape is executed correctly somewhere else. + * + * ## The measured cell, on `origin/main` (`5823d593d`), alias `person` + * + * | read scope | compiled to | bind list | + * |---|---|---| + * | `{ amount: { $gt: { $field: 'budget' } } }` | `"person"."amount" > ?` | `[{"$field":"budget"}]` | + * | `{ amount: { $eq: { $field: 'budget' } } }` | `"person"."amount" = ?` | `[{"$field":"budget"}]` | + * + * The reference OBJECT goes into the bind list verbatim — `applyReadScope` + * (`native-sql-strategy.ts`) pushes `scopeParams[i]` into the driver's array + * while it renumbers `?` → `$N`, exactly as #6125 measured for `undefined`. What + * the driver then does with a plain object is its own business: JSON text on the + * `toSqlBindValue` drivers, a bare `Undefined binding(s)`-class crash on the ones + * that refuse to guess. Either way an admin's RLS predicate compared a column + * against a value no row can hold, silently. In a module whose contract is + * "a read-scope predicate must never be silently dropped" a predicate that is + * silently MEANINGLESS is the same defect one step further on — and unlike + * #6125's cell it is not reliably fail-closed, because the comparison it + * degrades to depends on the driver rather than on the scope. + * + * ## Which positions this gate covers, and why the others keep their wording + * + * ONLY the positions that were BOUND: the whole comparand of the six scalar + * comparison operators ({@link CROSS_FIELD_COMPARISON_OPERATORS}) and the two + * `$between` endpoints. Everything else a reference can occupy already refused + * here BEFORE this change, with a diagnosis of its own, and each of those + * refusals converges with `driver-sql`'s own #5222 refusal arm — so widening + * this gate over them would restate a rule that is already right in a second + * wording (#5240, read in the direction that matters: a second sentence for a + * shape refused either way only sends the operator to the wrong repair): + * + * - the LIKE family → {@link assertRenderableText} ("matches against the TEXT + * of a pattern"), and `driver-sql` refuses a reference there too — a + * column-side LIKE pattern cannot be metacharacter-escaped portably; + * - `$in` / `$nin` members → {@link assertCompilableMembers} ("cannot be bound + * as a SQL parameter"), and `driver-sql` refuses those members as well, + * because the memory evaluator does not resolve a reference inside a list + * either; + * - a bare `{ field: { $field: … } }` → `unsupported operator "$field"` from + * {@link compileOperator}'s default arm. + * + * `$between` is in the covered set even though {@link assertCompilableMembers} + * would also refuse its endpoints, because this gate runs FIRST and the truer + * diagnosis wins: "a field reference is not compiled here" tells the policy + * author what to write, where "cannot be bound as a SQL parameter" describes a + * consequence of the shape rather than the shape. + * + * ## Envelope: unchanged, deliberately (#5367 ruling, 2026-08-06) + * + * `READ_SCOPE_COMPILE_FAILED` / 500, like the other twelve. The two arguments + * that ruling gave apply to this shape verbatim rather than by analogy: the + * producer is an ADMIN-authored sharing rule / permission set and its CEL + * lowering — `compileCelToFilter` is exactly what emits `{ $field: path }` — so + * a 4xx would bill the caller for a document they cannot author, and a 4xx + * echoes the message, which here names the POLICY's field names. Whether an + * unsupported-capability refusal on this path should nevertheless get a code and + * status of its own is a contract-face question raised on #7598 and left to the + * maintainer; it is not decided as a rider by the change that stops the bind. + */ +function assertNoFieldReferenceComparand(field: string, spec: unknown): void { + if (!isFilterNode(spec)) return; + for (const [op, opValue] of Object.entries(spec)) { + if (CROSS_FIELD_COMPARISON_OPERATORS.has(op) && isFieldReference(opValue)) { + throw readScopeCompileError( + `[read-scope-sql] ${fieldReferenceComparandMessage(op, field, opValue.$field)}`, + ); + } + if (op !== '$between' || !Array.isArray(opValue)) continue; + opValue.forEach((member, index) => { + if (!isFieldReference(member)) return; + throw readScopeCompileError( + `[read-scope-sql] ${fieldReferenceComparandMessage(op, field, member.$field, `index ${index}`)}`, + ); + }); + } +} + function compileOperator(col: string, op: string, val: unknown, field: string, params: unknown[]): string { switch (op) { case '$eq': return val === null ? `${col} IS NULL` : `${col} = ${bind(params, val)}`; diff --git a/packages/services/service-analytics/src/strategies/filter-normalizer.ts b/packages/services/service-analytics/src/strategies/filter-normalizer.ts index 1ae9b5c2ab..ef5a6c39a6 100644 --- a/packages/services/service-analytics/src/strategies/filter-normalizer.ts +++ b/packages/services/service-analytics/src/strategies/filter-normalizer.ts @@ -350,7 +350,10 @@ import { isFilterAST, parseFilterAST, VALID_AST_OPERATORS } from '@objectstack/spec/data'; import { StandardErrorCode } from '@objectstack/spec/api'; import { + CROSS_FIELD_COMPARISON_OPERATORS, + fieldReferenceComparandMessage, isBindableComparand, + isFieldReference, isRenderableTextComparand, TEXT_PATTERN_OPERATORS, unbindableListMemberMessage, @@ -559,10 +562,18 @@ function andOf(children: NormalizedFilterNode[]): NormalizedFilterNode | null { * `FilterCondition` that never passes through here — and carries the same two * checks in its own fail-closed envelope. * - * Only the two shapes #5234 measured are refused; `$eq` and friends keep binding - * an object as JSON (`toSqlBindValue`), which is a separate account. + * Three shapes are refused: the two #5234 measured, and — since #7598 — a + * `{$field}` reference in the comparand of a scalar comparison, the position + * both of #5234's checks are simply never asked about. `$eq` and friends keep + * binding any OTHER object as JSON (`toSqlBindValue`), which remains a separate + * account. */ function assertCompilableComparand(opKey: string, field: string, value: unknown): void { + // [#7598] The field-reference arm runs FIRST, and only over the positions that + // were BOUND — see {@link assertNoFieldReferenceComparand} for the measured + // table, for why the LIKE / list positions keep their own (converging) wording + // instead, and for why refusing is the answer rather than compiling. + assertNoFieldReferenceComparand(opKey, field, value); if (TEXT_PATTERN_OPERATORS.has(opKey)) { // An array reaches this door as `values[0]` — i.e. every member after the // first is silently DROPPED — while `read-scope-sql` and `driver-sql` @@ -582,6 +593,79 @@ function assertCompilableComparand(opKey: string, field: string, value: unknown) } } +/** + * [#7598] A `{ $field: 'col' }` reference in a comparand position this door + * BOUND instead of refusing. + * + * ## The measured cell, on `origin/main` (`5823d593d`) + * + * `{ amount: { $gt: { $field: 'budget' } } }` produced the leaf + * `{member: 'amount', operator: 'gt', values: [{$field: 'budget'}]}` — and then + * the THREE consumers of that leaf answered three different ways, which is the + * split this module's header spends its length removing: + * + * | consumer | answer | + * |---|---| + * | `NativeSQLStrategy` (the statement that executes) | `WHERE amount > $1`, bound to the JSON TEXT `{"$field":"budget"}` | + * | `ObjectQLStrategy.generateSql` (the `/analytics/sql` echo) | `WHERE amount > $1`, bound to the reference OBJECT | + * | `ObjectQLStrategy.convertFilter` (the engine path) | `{amount: {$gt: {$field: 'budget'}}}` — reaches `driver-sql`, which COMPILES it correctly since #5222 | + * + * Two wrong answers and one right one, chosen by which strategy the datasource + * routed to. The two wrong ones are wrong in the silent way (#3650 / #5234): a + * valid statement comparing a column against a value no row can hold, so a + * widget draws an empty chart with nothing to read. + * + * ## Why this refuses instead of compiling — and what it costs + * + * ⚠️ Refusing at the door NARROWS the engine path, which today passes the + * reference through to a driver that handles it properly. That cost is taken + * deliberately and is the part to re-open if the maintainer rules otherwise: + * + * - the pass-through is an ACCIDENT of {@link convertFilter} forwarding an + * unrecognised comparand, not a capability this package implements — nothing + * here validates it, and no test pinned it; + * - leaving it makes one authored `where` mean two things depending on the + * backend behind the cube, which is the exact "whichever face took the query + * is the answer you get" split #5146 / #5332 / #5567 / #5298 each spent a + * round removing — and the loud half of it would still be missing, since the + * other two emitters cannot be made to agree; + * - the four #5222 rulings that make a referenced column name safe in a SQL + * identifier position (declared-only enumeration, declared types for the + * comparison class, the tenant-isolation column, federation) turn on + * metadata `StrategyContext` does not expose, so this door cannot enforce + * them for the two emitters that would need it. + * + * So the package answers ONE way, loudly, exactly as it already does for + * `{$contains: {$field: …}}` — a refusal that CONVERGES with `driver-sql`'s own + * #5222 refusal arm. Whether the capability should instead be IMPLEMENTED here + * (which needs a `StrategyContext` hook, i.e. a `packages/spec` change) is the + * open question on #7598, and is deliberately not decided by this gate. + * + * ## Positions + * + * Only the ones that were bound: the six scalar comparison operators' + * whole comparand ({@link CROSS_FIELD_COMPARISON_OPERATORS}) and the two + * `$between` endpoints. `$between` needs naming because its branch in + * {@link fieldLeaves} lowers to `gte` / `lte` leaves BEFORE this gate is + * consulted, so its endpoints were the one position no shape gate on this door + * ever saw. The LIKE family and `$in` / `$nin` members keep their existing + * wording — they were already refused here AND on `driver-sql`. + */ +function assertNoFieldReferenceComparand(opKey: string, field: string, value: unknown): void { + if (CROSS_FIELD_COMPARISON_OPERATORS.has(opKey) && isFieldReference(value)) { + throw invalidFilterError( + `[analytics] ${fieldReferenceComparandMessage(opKey, field, value.$field)}`, + ); + } + if (opKey !== '$between' || !Array.isArray(value)) return; + value.forEach((member, index) => { + if (!isFieldReference(member)) return; + throw invalidFilterError( + `[analytics] ${fieldReferenceComparandMessage(opKey, field, member.$field, `index ${index}`)}`, + ); + }); +} + /** * [#6386, on #6050's ruling B] `undefined` in a COMPARAND position. * @@ -880,6 +964,13 @@ function fieldLeaves(key: string, raw: unknown): NormalizedFilterNode[] { `${JSON.stringify(v)}. Dropping the predicate would silently widen the query to every row.`, ); } + // [#7598] The endpoints are comparands in their own right, and this + // branch RETURNS before `assertCompilableComparand` below — so a + // reference in a `$between` bound was the one comparand position on + // this door that no shape gate ever saw. Asserted under the `$between` + // name, not under the `gte` / `lte` the bounds lower to, because the + // author wrote `$between` and that is the key they have to repair. + assertNoFieldReferenceComparand(opKey, key, v); leaf('gte', [comparand(v[0])]); leaf('lte', [comparand(v[1])]); continue; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23a65aaa1b..b8daba6ee5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2082,6 +2082,9 @@ importers: specifier: workspace:* version: link:../../types devDependencies: + '@objectstack/driver-sql': + specifier: workspace:* + version: link:../../drivers/driver-sql '@types/node': specifier: ^26.1.2 version: 26.1.2 From 1bfba2a9df4b13d0517093d9e39edfbf737cf3f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 10:32:11 +0000 Subject: [PATCH 2/2] chore(changeset): answer the ADR-0087 disposition for the #7598 refusal in writing The changeset declares a breaking change (`fix(analytics)!`), so check-adr-0087-registration requires the ledger question be answered. Disposition: not-required (no-migration-prescription) -- no authorable key is retired, packages/spec is untouched, and the FROM shape stays valid metadata that still executes on the ObjectQL engine path and both SQL drivers, so there is nothing for `objectstack migrate meta` to rewrite. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015fkdTyGmMD5s8ZtEifvuGy --- .changeset/analytics-field-reference-comparand-refusal.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/analytics-field-reference-comparand-refusal.md b/.changeset/analytics-field-reference-comparand-refusal.md index a79b0efed9..91874eb2dc 100644 --- a/.changeset/analytics-field-reference-comparand-refusal.md +++ b/.changeset/analytics-field-reference-comparand-refusal.md @@ -4,6 +4,8 @@ fix(analytics)!: a `{ $field }` comparand is refused on both SQL-lowering doors instead of being BOUND as the comparison's value (#7598) + + **⚠️ Behaviour change.** A filter whose comparand is a field reference — `{ amount: { $gt: { $field: 'budget' } } }`, the shape `FieldReferenceSchema` declares and `compileCelToFilter` emits for a