|
| 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