Skip to content

Commit 044403e

Browse files
claude[bot]claude
andauthored
docs(objectql): name the include-relation boundary of a cross-field comparand, and pin it (#15103 fallback B) (#15781)
The #15103 gate was measured before any code: the ADR-0071 join chain lives in NativeSQLStrategy, which declines every { $field } comparand (2026-08-12 ruling, #7598), and the driver that compiles the comparand builds no joins — so the chain cannot serve the where compilation without JOIN planning in the driver, an alias contract through executeAggregate, or a second enforcement site for the #5222 rulings. Fallback B: capability unchanged; the query-syntax page names the boundary and a service-analytics pin measures it over a real engine (dimension and filter member through the join; the comparand refused INVALID_FILTER/400 with duty in include; native declined on that pass). Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent fa85759 commit 044403e

2 files changed

Lines changed: 256 additions & 0 deletions

File tree

content/docs/protocol/objectql/query-syntax.mdx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,19 @@ both. The same-table rule applies to the offset column too: `addDays: { $field:
512512
'duty.grace_days' }` (a relation path) is resolved by the memory evaluator and refused
513513
by SQL push-down with `INVALID_FILTER`, exactly as a dotted `$field` is.
514514

515+
**A dataset's `include` does not widen this.** A dataset compiles a `LEFT JOIN` for every
516+
relation it `include`s (ADR-0071, up to three hops), and on the native-SQL path those joins
517+
serve dimensions, measures and filter *members* — a `where` on `duty.grace_days` reads the
518+
joined column. A `{ $field }` *comparand* takes a different road: the native-SQL strategy
519+
declines it so that the driver, which alone holds the declared field set, the column types
520+
and the tenant column the cross-field rules check, compiles it (#7598) — and the driver
521+
compiles single-table statements, with no join in scope. So
522+
`completed_on <= due_on + duty.grace_days` is refused with `INVALID_FILTER` even when `duty`
523+
is in the dataset's `include`; measured and pinned in
524+
`packages/services/service-analytics/src/__tests__/include-relation-cross-field-boundary.test.ts`
525+
(#15103). The spelling that answers on both paths is the same-table one — every column the
526+
reference names, offset included, is a column of the object the filter runs over.
527+
515528
### Date, Datetime, and Time Filters
516529

517530
Before a comparison is built, the driver puts the comparand into the **same canonical
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#15103] A cross-field comparand naming a column of an `include`d relation —
5+
* the boundary, measured on one dataset over a real engine.
6+
*
7+
* ## Why this is a pin and not a feature
8+
*
9+
* The #14104 ruling spelled its driving shape as
10+
* `completed_at <= due_date + duty.grace_days` — a RELATION path. #15103 was
11+
* ruled **A, gated on one measurement** (maintainer ruling 5548479553,
12+
* 2026-09-05): if the ADR-0071 join chain a dataset already compiles for its
13+
* `include`d relations can serve the `where` compilation of a `{ $field }`
14+
* comparand, SQL push-down accepts that spelling for `include`d relations; if
15+
* it cannot without inventing JOIN planning, fallback **B** — capability
16+
* unchanged, the ruling's example corrected to the same-table spelling, and
17+
* the boundary named. The measurement answered B; this file is its executable
18+
* form, so the sentence `query-syntax.mdx` states is re-read the day the
19+
* boundary moves.
20+
*
21+
* ## What was measured
22+
*
23+
* - The join chain lives in `NativeSQLStrategy` (`qualifyAndRegisterJoin`),
24+
* where it already serves dimensions, measures AND filter MEMBERS. The first
25+
* block drives a dimension and a `runtimeFilter` member on `duty.grace_days`
26+
* through one `LEFT JOIN` over a real SQLite engine and reads real rows back.
27+
* - A `{ $field }` comparand never reaches that chain. `canHandle` DECLINES
28+
* it (maintainer ruling 2026-08-12 Q1 = B, #7598) so that `driver-sql`
29+
* enforces the four #5222 rulings with metadata only it holds; the driver
30+
* compiles single-table statements — no join exists there, and nothing about
31+
* the dataset's `include` reaches it (`executeAggregate` carries `groupBy`,
32+
* `aggregations`, `filter`, `timezone`, `context` and nothing else). So the
33+
* dotted comparand is refused `INVALID_FILTER` / 400 EVEN WHEN the relation
34+
* is in `include` — the second block, bare arm and offset arm, with the
35+
* native-SQL spy proving the chain was never consulted.
36+
* - The same-table spelling answers on the SQL path over the same fixture —
37+
* the third block, the positive control. The memory half of the asymmetry
38+
* (the evaluator WALKS `duty.grace_days` when the row carries the relation)
39+
* is pinned where the evaluator lives:
40+
* `packages/formula/src/matches-filter-field-reference-offset.test.ts`;
41+
* this package does not depend on `@objectstack/formula`, deliberately.
42+
*
43+
* ## What landing A would need — none of it authorised by #15103
44+
*
45+
* A join synthesised inside the driver (JOIN planning, the ruling's fallback
46+
* trigger); a join descriptor threaded through `executeAggregate` → engine →
47+
* `DriverQuery` (the alias contract the 2026-08-06 and 2026-09-05 rulings both
48+
* exclude); or the `$field` compilation moved into `NativeSQLStrategy` behind
49+
* new `StrategyContext` hooks and a second implementation of the #5222
50+
* rulings (option A of the 2026-08-12 ruling, rejected there). A red here is
51+
* the signal that one of those landed and the docs owe a new sentence.
52+
*/
53+
54+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
55+
import {
56+
CROSS_FIELD_OFFSET_OBJECT_FIELDS,
57+
CROSS_FIELD_OFFSET_ROWS,
58+
} from '@objectstack/driver-sql';
59+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
60+
import { DatasetSchema } from '@objectstack/spec/ui';
61+
import type { AggregationNode, FilterCondition } from '@objectstack/spec/data';
62+
import type { DriverQuery } from '@objectstack/spec/contracts';
63+
import type { ExecutionContext } from '@objectstack/spec/kernel';
64+
65+
import { AnalyticsService } from '../analytics-service.js';
66+
67+
const TASK = 'cross_field_task';
68+
const DUTY = 'cross_field_duty';
69+
const CTX = { tenantId: 'org_A' } as ExecutionContext;
70+
71+
/** The offset fixture's task, plus the `duty` lookup the ruling's shape walks. */
72+
const TASK_FIELDS: Record<string, Record<string, unknown>> = {
73+
...CROSS_FIELD_OFFSET_OBJECT_FIELDS,
74+
duty: { type: 'lookup', name: 'duty', reference: DUTY },
75+
};
76+
const DUTY_FIELDS: Record<string, Record<string, unknown>> = {
77+
id: { type: 'text', name: 'id' },
78+
name: { type: 'text', name: 'name' },
79+
grace_days: { type: 'number', name: 'grace_days' },
80+
organization_id: { type: 'text', name: 'organization_id' },
81+
};
82+
83+
/**
84+
* One duty per distinct grace value, so `duty.grace_days` and the task's own
85+
* `grace_days` name the SAME number for every row: the relation spelling and
86+
* the same-table spelling are one predicate over this fixture, and any
87+
* difference in their answers is the paths, never the data.
88+
*/
89+
const DUTIES: ReadonlyArray<{ id: string; name: string; grace_days: number | null; organization_id: string }> = [
90+
{ id: 'duty_2', name: 'two days of grace', grace_days: 2, organization_id: 'o1' },
91+
{ id: 'duty_0', name: 'no grace', grace_days: 0, organization_id: 'o1' },
92+
{ id: 'duty_none', name: 'grace unset', grace_days: null, organization_id: 'o1' },
93+
{ id: 'duty_m3', name: 'three days early', grace_days: -3, organization_id: 'o1' },
94+
];
95+
const dutyFor = (grace: number | null): string =>
96+
grace === null ? 'duty_none' : grace === 2 ? 'duty_2' : grace === 0 ? 'duty_0' : 'duty_m3';
97+
const TASKS = CROSS_FIELD_OFFSET_ROWS.map((row) => ({ ...row, duty: dutyFor(row.grace_days) }));
98+
99+
const SAME_TABLE_ON_TIME: FilterCondition = {
100+
completed_on: { $lte: { $field: 'due_on', addDays: { $field: 'grace_days' } } },
101+
};
102+
const RELATION_OFFSET_ON_TIME: FilterCondition = {
103+
completed_on: { $lte: { $field: 'due_on', addDays: { $field: 'duty.grace_days' } } },
104+
};
105+
const RELATION_BARE: FilterCondition = {
106+
completed_on: { $lte: { $field: 'duty.grace_days' } },
107+
};
108+
109+
/** The dataset: `duty` IS in `include`, and a dimension already reads through it. */
110+
const DATASET = DatasetSchema.parse({
111+
name: 'task_health_by_duty',
112+
label: 'Task health by duty',
113+
object: TASK,
114+
include: ['duty'],
115+
dimensions: [
116+
{ name: 'title', field: 'title', type: 'string' },
117+
{ name: 'duty_grace', field: 'duty.grace_days', type: 'number' },
118+
],
119+
measures: [
120+
{ name: 'total', aggregate: 'count' },
121+
{ name: 'done_on_time', aggregate: 'count', filter: SAME_TABLE_ON_TIME },
122+
{ name: 'done_on_time_by_duty', aggregate: 'count', filter: RELATION_OFFSET_ON_TIME },
123+
{ name: 'done_by_duty_bare', aggregate: 'count', filter: RELATION_BARE },
124+
],
125+
});
126+
127+
interface WireBearingError extends Error {
128+
code?: string;
129+
status?: number;
130+
}
131+
132+
const errorFrom = async (run: () => Promise<unknown>): Promise<WireBearingError> => {
133+
let returned: unknown;
134+
try {
135+
returned = await run();
136+
} catch (e) {
137+
return e as WireBearingError;
138+
}
139+
throw new Error(`expected a refusal, but the analytics face returned ${JSON.stringify(returned)}`);
140+
};
141+
142+
describe('[#15103] a cross-field comparand on an `include`d relation — the boundary, measured', () => {
143+
let driver: SqliteWasmDriver;
144+
let service: AnalyticsService;
145+
const rawSqlCalls: string[] = [];
146+
147+
beforeAll(async () => {
148+
driver = new SqliteWasmDriver({ filename: ':memory:' });
149+
await driver.initObjects([
150+
{ name: DUTY, fields: DUTY_FIELDS } as any,
151+
{ name: TASK, fields: TASK_FIELDS } as any,
152+
]);
153+
for (const duty of DUTIES) await driver.create(DUTY, { ...duty });
154+
for (const task of TASKS) await driver.create(TASK, { ...task });
155+
156+
service = new AnalyticsService({
157+
debugSql: true,
158+
// BOTH paths available: native SQL wins `resolveStrategy` unless it
159+
// declines, so the spy on `executeRawSql` MEASURES the decline.
160+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }),
161+
relationshipResolver: (object, rel) => (object === TASK && rel === 'duty' ? DUTY : undefined),
162+
executeRawSql: async (_object, sql, params) => {
163+
rawSqlCalls.push(sql);
164+
// NativeSQLStrategy emits `$n`; knex speaks `?` — the plugin's own
165+
// bridge does this translation (plugin.ts).
166+
const result = await driver.execute(sql.replace(/\$(\d+)/g, '?'), params as unknown[]);
167+
if (Array.isArray(result)) return result as Record<string, unknown>[];
168+
return ((result as { rows?: Record<string, unknown>[] } | null)?.rows ?? []);
169+
},
170+
executeAggregate: async (objectName, options) => {
171+
const query: DriverQuery = {
172+
where: options.filter as FilterCondition,
173+
groupBy: options.groupBy as DriverQuery['groupBy'],
174+
aggregations: options.aggregations?.map(({ field, method, alias }) => ({
175+
field,
176+
function: method as AggregationNode['function'],
177+
alias,
178+
})),
179+
};
180+
return (await driver.aggregate(objectName, query)) as Record<string, unknown>[];
181+
},
182+
});
183+
});
184+
185+
afterAll(async () => {
186+
await driver?.disconnect?.();
187+
});
188+
189+
describe('the ADR-0071 chain serves a projection AND a filter member — on the native path', () => {
190+
it('a dimension and a runtimeFilter member on duty.grace_days compile to one LEFT JOIN over the real engine', async () => {
191+
rawSqlCalls.length = 0;
192+
const result = await service.queryDataset(
193+
DATASET,
194+
{ dimensions: ['duty_grace'], measures: ['total'], runtimeFilter: { duty_grace: { $gte: 0 } } },
195+
CTX,
196+
);
197+
expect(rawSqlCalls, 'native SQL served it').toHaveLength(1);
198+
const sql = rawSqlCalls[0];
199+
expect(sql).toContain(`LEFT JOIN "${DUTY}" "duty" ON "${TASK}"."duty" = "duty"."id"`);
200+
// The WHERE reads the joined column — the chain participates in the
201+
// predicate for a MEMBER. This is the half the ruling called
202+
// "conceivable"; it is real, for members.
203+
expect(sql).toMatch(/WHERE .*"duty"\."grace_days" >= \$1/);
204+
// Grace 0 → row 3; grace 2 → rows 1, 2, 5, 6. NULL (4, 7) and -3 (8, 9)
205+
// fail `>= 0` — LEFT JOIN semantics keep the NULL-grace rows in the
206+
// table and the predicate then drops them, like any NULL comparison.
207+
const byGrace = new Map(result.rows.map((r) => [r.duty_grace === null ? null : Number(r.duty_grace), Number(r.total)]));
208+
expect(byGrace.get(0)).toBe(1);
209+
expect(byGrace.get(2)).toBe(4);
210+
expect(byGrace.has(-3)).toBe(false);
211+
expect(byGrace.has(null)).toBe(false);
212+
});
213+
});
214+
215+
describe('the `$field` COMPARAND never reaches that chain — refused even with `duty` in `include`', () => {
216+
for (const [arm, measure, ref] of [
217+
['offset', 'done_on_time_by_duty', 'duty.grace_days'],
218+
['bare', 'done_by_duty_bare', 'duty.grace_days'],
219+
] as const) {
220+
it(`${arm} arm → INVALID_FILTER / 400 naming the dotted path; native SQL declined, so no join was ever built`, async () => {
221+
rawSqlCalls.length = 0;
222+
const err = await errorFrom(() => service.queryDataset(DATASET, { measures: [measure] }, CTX));
223+
expect(err.code).toBe('INVALID_FILTER');
224+
expect(err.status).toBe(400);
225+
expect(err.message).toContain(`"${ref}" is a dotted path`);
226+
expect(err.message).toContain('same-table column references only');
227+
// The chain was never consulted: the pass declined native SQL
228+
// (2026-08-12 ruling) and the driver, which has no chain, refused.
229+
expect(rawSqlCalls, 'NativeSQLStrategy did not decline').toEqual([]);
230+
});
231+
}
232+
});
233+
234+
describe('positive control — the fixture and the road are live', () => {
235+
it('the same-table spelling answers on the SQL path: rows 2 and 3 are on time', async () => {
236+
rawSqlCalls.length = 0;
237+
const result = await service.queryDataset(DATASET, { measures: ['done_on_time'] }, CTX);
238+
expect(result.rows).toHaveLength(1);
239+
expect(Number(result.rows[0].done_on_time)).toBe(2);
240+
expect(rawSqlCalls, 'a cross-field pass is the engine path\'s').toEqual([]);
241+
});
242+
});
243+
});

0 commit comments

Comments
 (0)