From 03b4aa4783db3a095011e34499841bc9fd21dd94 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 18:59:02 +0000 Subject: [PATCH 1/5] fix(service-analytics): judge and present a relationship-path cube measure by the declaration on the object its last hop reaches The cube door's aggregate x field-type judgment, the measure result type and the native-SQL presenter now locate a measure column written as a relationship path on the object the one hop resolver (hop-object.ts, columnObjectOf) names for its last hop, and read the declaration there. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../src/analytics-service.ts | 64 +++++++++---- .../src/cube-measure-field-type-door.ts | 94 ++++++++++++++----- .../src/measure-result-type.ts | 8 +- .../src/strategies/native-sql-strategy.ts | 45 ++++++--- 4 files changed, 155 insertions(+), 56 deletions(-) diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index d867a4d6335..34fb71b6048 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -70,7 +70,7 @@ import { measureResultType } from './measure-result-type.js'; // a measure whose aggregate × field-type pair it refuses is refused in // `ensureCube`, ahead of both strategies, as the dataset door refuses it at // compile. -import { assertCubeMeasureFieldTypesAccepted } from './cube-measure-field-type-door.js'; +import { assertCubeMeasureFieldTypesAccepted, type MeasureColumn } from './cube-measure-field-type-door.js'; import type { AnalyticsStrategy, AnalyticsDriverCapabilities, StrategyContext, DatasetScopedStrategyContext, DatasetScope, ReadScopeFilterJudge } from './strategies/types.js'; import { NativeSQLStrategy } from './strategies/native-sql-strategy.js'; import { ObjectQLStrategy } from './strategies/objectql-strategy.js'; @@ -115,8 +115,10 @@ import { assertNoStructuredJsonDimension } from './structured-json-dimension-doo import { ACCEPTED_SQL_DIALECTS, isUnrecognisedSqlDialectAnswer, type AcceptedSqlDialect } from './text-match-sql.js'; // [#20986] The one resolver of the object a relationship-path hop reads. The // door's field gate and its admitted and scoped set read it here; both -// strategies read it through the context's `relationshipReference`. -import { resolvePathHops, type HopReference } from './hop-object.js'; +// strategies read it through the context's `relationshipReference`. [#21129] +// So do the measure × field-type door and the measure result type, for the +// object a relationship-path measure column is declared on. +import { columnObjectOf, resolvePathHops, type HopReference } from './hop-object.js'; /** * [#5717] Does this error carry an ADR-0112 envelope — i.e. did its PRODUCER @@ -651,20 +653,37 @@ function withDeclaredMeasureFormats( /** * [#21044] The cube measure a `measures` entry resolves to — through * {@link declaredMemberEntry}, the resolver {@link withDeclaredMeasureFormats} - * reads — and the base-object COLUMN it aggregates: its `sql` when that is a - * bare identifier, `null` otherwise (`'*'`, or a relationship path whose - * declaration lives on another object). `undefined` when the entry resolves to - * no declared measure. + * reads — and the COLUMN it aggregates, located on the object that declares + * it; `null` when its `sql` is not a column reference (`'*'`, an expression). + * `undefined` when the entry resolves to no declared measure. + * + * - A bare identifier is a column of `baseObject`. + * - [#21129] A relationship path (`account.name`) is its last segment, on the + * object the path's last hop reaches: {@link columnObjectOf}, the one hop + * resolver (`hop-object.ts`) — the cube's join, else the relationship + * field's declared target through `referenceOf`, else the alias — which is + * the object both strategies join and read for that path. ⛔ No second walk. */ function declaredMeasureColumn( cube: Cube, member: string, -): { type: unknown; column: string | null } | undefined { + baseObject: string, + referenceOf: HopReference | undefined, +): { type: unknown; column: MeasureColumn | null } | undefined { const entry = declaredMemberEntry(cube, member, 'measure'); const measure = entry ? cube.measures[entry.key] : undefined; if (!measure) return undefined; const sql = typeof measure.sql === 'string' ? measure.sql.trim() : ''; - return { type: measure.type, column: BARE_IDENTIFIER.test(sql) ? sql : null }; + if (BARE_IDENTIFIER.test(sql)) return { type: measure.type, column: { object: baseObject, column: sql, path: sql } }; + if (!IDENTIFIER_PATH.test(sql)) return { type: measure.type, column: null }; + return { + type: measure.type, + column: { + object: columnObjectOf(cube, baseObject, sql, referenceOf), + column: sql.slice(sql.lastIndexOf('.') + 1), + path: sql, + }, + }; } /** The `AggregationFunction` vocabulary — the aggregates `measureResultType` speaks about. */ @@ -680,13 +699,15 @@ const AGGREGATION_FUNCTIONS: ReadonlySet = new Set(AggregationFunction.o * ADR-0021 enrichment (`enrichResultColumns`); the cube door described a * `min` / `max` over a temporal column as a number, in the same response that * carried the instant. ⛔ No copy of the rule: this asks it with the cube - * measure's aggregate and the declared type of the base-object column it reads, - * and writes only what it answers. It answers `undefined` for every pair it has + * measure's aggregate and the declared type of the column it reads, and writes + * only what it answers. It answers `undefined` for every pair it has * nothing to say about — the numeric and boolean classes, the count / sum / avg * rows, an expression metric type — and for every pair the aggregate × * field-type table refuses, which the cube door has refused before any strategy - * ran ({@link assertCubeMeasureFieldTypesAccepted}). A relationship-path column - * is not described: the declaration this reads is the base object's. + * ran ({@link assertCubeMeasureFieldTypesAccepted}). [#21129] The column is + * {@link declaredMeasureColumn}'s, the door's own: a relationship-path column + * is described by the declaration on the object its last hop reaches, as a + * base-object column is by the base object's. * * Copy-on-write, like the format pass: the strategy owns the object it * returned. @@ -696,6 +717,7 @@ function withMeasureResultTypes( query: AnalyticsQuery, cube: Cube | undefined, declaredTypeOf: ((object: string, field: string) => string | undefined) | undefined, + referenceOf: HopReference | undefined, ): AnalyticsResult { if (!declaredTypeOf || !cube || !result?.fields?.length || !query.measures?.length) return result; const object = typeof cube.sql === 'string' ? cube.sql.trim() : ''; @@ -704,9 +726,12 @@ function withMeasureResultTypes( let fields: AnalyticsResult['fields'] | undefined; result.fields.forEach((f, i) => { if (!requested.has(f.name)) return; - const measure = declaredMeasureColumn(cube, f.name); + const measure = declaredMeasureColumn(cube, f.name, object, referenceOf); if (!measure?.column || typeof measure.type !== 'string' || !AGGREGATION_FUNCTIONS.has(measure.type)) return; - const type = measureResultType(measure.type as AggregationFunction, declaredTypeOf(object, measure.column)); + const type = measureResultType( + measure.type as AggregationFunction, + declaredTypeOf(measure.column.object, measure.column.column), + ); if (type === undefined || f.type === type) return; fields ??= [...result.fields]; fields[i] = { ...f, type }; @@ -2216,6 +2241,7 @@ export class AnalyticsService implements IAnalyticsService { query, cube, this.sourceFieldMeta && ((object, field) => this.sourceFieldMeta?.(object, field)?.type), + this.hopReference, ), ); } catch (e) { @@ -3189,7 +3215,10 @@ export class AnalyticsService implements IAnalyticsService { * - The measure and column a member resolves to are * {@link declaredMeasureColumn}'s — {@link declaredMemberEntry}, the * resolver {@link withDeclaredMeasureFormats} reads — and nothing else - * resolves a member to a field here. + * resolves a member to a field here. [#21129] A relationship-path column + * is located on the object its last hop reaches by the one hop resolver, + * with {@link hopReference}: the answer the field gate admitted the path + * with and the strategies join it by. * - The column's declared type is {@link AnalyticsServiceConfig.sourceFieldMeta}'s, * the declaration the #20807 / #20912 door reads beside it. * @@ -3206,8 +3235,7 @@ export class AnalyticsService implements IAnalyticsService { assertCubeMeasureFieldTypesAccepted( query, cube.name, - object, - (member) => declaredMeasureColumn(cube, member), + (member) => declaredMeasureColumn(cube, member, object, this.hopReference), (o, field) => fieldMeta(o, field)?.type, ); } diff --git a/packages/services/service-analytics/src/cube-measure-field-type-door.ts b/packages/services/service-analytics/src/cube-measure-field-type-door.ts index 6c7bdce48e4..e71c540df58 100644 --- a/packages/services/service-analytics/src/cube-measure-field-type-door.ts +++ b/packages/services/service-analytics/src/cube-measure-field-type-door.ts @@ -47,9 +47,13 @@ * and a compiled dataset's are one population. The member is resolved by the * CALLER's resolver (the same one `withDeclaredMeasureFormats` reads), never * a second one here. - * - The measure's column is its `sql` when that is a bare identifier: a column - * of the cube's own object, whose declaration the door already reads - * (`sourceFieldMeta`). + * - The measure's column is its `sql` when that is a column reference, read on + * the object that DECLARES it (`sourceFieldMeta`): a bare identifier is a + * column of the cube's own object; [#21129] a relationship path + * (`account.name`) is its last segment, on the object the path's last hop + * reaches. The caller locates it through the one hop resolver + * (`hop-object.ts`, `columnObjectOf`) — the object both strategies join and + * read for that path — and ⛔ this module walks no path of its own. * - The verdict is `isAggregateCompatibleWithFieldType`'s. ⛔ No row is * restated here: the accepted set the words name is read off the exported * table, so a row changed in the spec changes this refusal in the same @@ -69,19 +73,40 @@ * - A member that resolves to no declared measure (the source-field gate's). * - A measure type outside the table's vocabulary: the expression metric types * (`number` / `string` / `boolean`). - * - A RELATIONSHIP-PATH column (`account.name`): the declaration the door - * reads is the base object's, so it would answer about a different column of - * the same name, or about nothing. The spec module says exactly this ("a - * consumer that cannot resolve a field's type must NOT call the predicate - * with a guess"). - * - A column the declaration hook cannot resolve, or a type outside + * - A `sql` that is not a column reference (`'*'`, an expression). + * - A column the declaration hook cannot resolve — for a relationship path, + * one whose hop reaches an object the host does not know (the resolver's + * alias tier with nothing registered under the alias) — or a type outside * `FieldType` (a driver-internal alias): the table is fail-closed on - * vocabulary, and refusing on it would refuse a pair nobody declared. + * vocabulary, and refusing on it would refuse a pair nobody declared. The + * spec module says the same ("a consumer that cannot resolve a field's type + * must NOT call the predicate with a guess"). + * + * ## [#21129] A relationship-path column + * + * Measured through `POST /api/v1/analytics/query` on the real dispatcher route + * at `c6b6889193`, a configured cube over `deal` with a declared join + * `account` to a related object holding `name` (`text`), `revenue` + * (`number`), `opened_at` (`datetime`) and `tier` (`select`): + * + * | measure `sql` | face | SQLite | PostgreSQL 16.14 | + * |:--|:--|:--|:--| + * | `max` / `min` over `account.name`, `max` over `account.tier` | `NativeSQLStrategy` | 200, the text, `fields[]` `number` | the same | + * | `sum` over `account.name` | `NativeSQLStrategy` | 200, `0` | 500 `DATABASE_ERROR` | + * | every one of them | `ObjectQLStrategy` | 400 `INVALID_FIELD`, its cross-object refusal | the same | + * + * A base-object column of the same type was already refused here, so one + * question about one declared type was answered by whether the column sat a + * hop away. Read where the column is declared, a relationship-path pair the + * table refuses is refused as a base-object one is, ahead of both strategies: + * one envelope and one wording on both faces. * * ## The envelope, and why it is not the dataset door's code * * `INVALID_FIELD` / 400 through `invalidMemberError` (ADR-0112), with the - * column and its object attached, as the #20912 door attaches them. The + * column and its object attached, as the #20912 door attaches them: `field` is + * the column as the measure's `sql` spells it (`note`, or `account.name`), and + * `object` is the object that declares it. The * dataset door answers the same pair `DATASET_INVALID`, which is a verdict * about a dataset DOCUMENT (`dataset-refusal.ts`'s header): this door's caller * sent no dataset, and a verdict about ONE MEMBER the request named is the @@ -89,6 +114,9 @@ * its #20912 `count_distinct` door and the engine's aggregate door already * answer, the last one for this very pair on the ObjectQL face. The dataset * door never reaches this one for a pair it refuses: it refuses at compile. + * Its compile check reads the base object's declaration and leaves a + * relationship-path field unjudged, so such a pair reaches this door through + * `DatasetExecutor` and is refused here. */ import { @@ -120,17 +148,36 @@ const reasonFor = (aggregate: string): string => : 'Each SQL dialect would coerce the stored form to a number or fail, so the answer would depend on ' + 'the backend.'; +/** + * A measure's column, located on the object that declares it — the shape the + * #20912 door's `DimensionColumn` carries for a dimension. + */ +export interface MeasureColumn { + /** The object that declares the column: the cube's own, or the one a relationship path's last hop reaches. */ + readonly object: string; + /** The column's name on that object. */ + readonly column: string; + /** The measure's `sql` as written: the column, or the relationship path to it. */ + readonly path: string; +} + +/** Who declares the column, as the words say it: the cube's own object, or the related one. */ +function declarerOf(target: MeasureColumn): string { + return target.path === target.column + ? `which object '${target.object}' declares` + : `whose column '${target.column}' the related object '${target.object}' declares`; +} + /** * Refuse the first `measures` entry of `query` whose aggregate the table * refuses for its column's declared type — `INVALID_FIELD` / 400. See the * module header. * - * @param baseObject - The object `cube.sql` names (the caller has checked it is - * a bare object name). * @param measureOf - The cube measure a `measures` entry resolves to, by the - * caller's resolver: its `type`, and the base-object `column` it aggregates - * (`null` when its `sql` is not a bare identifier) — or `undefined` when the - * entry resolves to no declared measure. + * caller's resolver: its `type`, and the {@link MeasureColumn} it aggregates, + * located on the object that declares it (`null` when its `sql` is not a + * column reference) — or `undefined` when the entry resolves to no declared + * measure. * @param declaredTypeOf - The declared `FieldType` of a column on an object, or * `undefined` when nothing authoritative answers. * @@ -141,8 +188,7 @@ const reasonFor = (aggregate: string): string => export function assertCubeMeasureFieldTypesAccepted( query: AnalyticsQuery, cubeName: string, - baseObject: string, - measureOf: (member: string) => { type: unknown; column: string | null } | undefined, + measureOf: (member: string) => { type: unknown; column: MeasureColumn | null } | undefined, declaredTypeOf: (object: string, field: string) => string | undefined, ): void { for (const member of query.measures ?? []) { @@ -150,20 +196,20 @@ export function assertCubeMeasureFieldTypesAccepted( if (!measure?.column) continue; const aggregate = measure.type; if (!isJudgedAggregate(aggregate)) continue; - const column = measure.column; - const declared = declaredTypeOf(baseObject, column); + const target = measure.column; + const declared = declaredTypeOf(target.object, target.column); if (typeof declared !== 'string' || !DECLARED_FIELD_TYPES.has(declared)) continue; if (isAggregateCompatibleWithFieldType(aggregate, declared)) continue; const err = invalidMemberError( - `Measure '${member}' on cube '${cubeName}' takes the ${aggregate} of field '${column}', which object ` - + `'${baseObject}' declares as ${declared}: ${aggregate} does not accept that type, so the query was NOT ` + `Measure '${member}' on cube '${cubeName}' takes the ${aggregate} of field '${target.path}', ` + + `${declarerOf(target)} as ${declared}: ${aggregate} does not accept that type, so the query was NOT ` + `run. ${aggregate} accepts ${AGGREGATE_FIELD_TYPE_COMPATIBILITY[aggregate].join(', ')}; aggregate a ` + `field of one of those types, or count the rows with count. ${reasonFor(aggregate)}`, { member, param: 'measures', cube: cubeName }, ) as Error & { field?: string; object?: string }; - err.field = column; - err.object = baseObject; + err.field = target.path; + err.object = target.object; throw err; } } diff --git a/packages/services/service-analytics/src/measure-result-type.ts b/packages/services/service-analytics/src/measure-result-type.ts index 258793275be..7ae5a1515d9 100644 --- a/packages/services/service-analytics/src/measure-result-type.ts +++ b/packages/services/service-analytics/src/measure-result-type.ts @@ -176,9 +176,11 @@ export const TEMPORAL_SOURCE_FIELD_TYPES: ReadonlySet = new Set([ * * Tiered "cannot answer, do not block", the same way every other chain reading * `sourceFieldMeta` is: an unknown field type, a host with no data engine - * wired, and a relationship-path measure (`account.closed_at`, which - * `sourceFieldMeta` cannot resolve because it looks a column up on the BASE - * object) all answer `undefined` and leave the column exactly as it was. + * wired, and a relationship-path measure whose caller looks its column up on + * the BASE object (`account.closed_at`, which the dataset door's enrichment + * asks of the dataset's own object) all answer `undefined` and leave the + * column exactly as it was. [#21129] The cube door's caller locates such a + * column on the object its last hop reaches, so it passes the real type. * * `undefined` is also the answer for every ACCEPTED pair whose `min`/`max` has * no single backend-independent value (booleans) and for every pair the spec diff --git a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts index 3c20edb4734..d7c20d35c3b 100644 --- a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts @@ -139,6 +139,26 @@ export const EXPRESSION_METRIC_TYPES = new Set(['number', 'string', 'boolean']); */ const IDENTIFIER_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/; +/** + * [#21129] The column a measure's `sql` aggregates, located on the object that + * declares it — `undefined` for `'*'` and for an expression. A bare identifier + * is a column of `parentTable`; a relationship path is its last segment, on + * the object the path's last hop reaches ({@link columnObjectOf}, the one hop + * resolver: the object this statement joins for that path). The aggregand's + * operand policy and the presenter both read the column here, so the column + * whose class shapes the statement and the column whose type presents its + * answer are one column. + */ +function measureColumnOf( + cube: Cube, + parentTable: string, + sql: string, + referenceOf: HopReference | undefined, +): { readonly object: string; readonly field: string } | undefined { + if (sql === '*' || !IDENTIFIER_PATH.test(sql)) return undefined; + return { object: columnObjectOf(cube, parentTable, sql, referenceOf), field: sql.slice(sql.lastIndexOf('.') + 1) }; +} + /** * [#20986] The joins ONE statement registers, keyed by alias: each join's SQL * and the object it reads — the object {@link resolvePathHops} named for that @@ -710,16 +730,22 @@ export class NativeSQLStrategy implements AnalyticsStrategy { // number whatever the column held; `min` / `max` answer a value OF the // column, so they are presented only when that column is declared numeric // (`driver-sql`'s `readPresentationKind` rule), asked through - // `declaredFieldType` — a host that cannot answer, or a relationship-path - // column, leaves the value as the client gave it. Expression metric types + // `declaredFieldType` on the object that declares the column + // ({@link measureColumnOf}) — [#21129] for a relationship path, the object + // its last hop reaches, as the statement joined it. A host that cannot + // answer leaves the value as the client gave it. Expression metric types // (`number` / `string` / `boolean`) are the author's SQL and stay as they // are. Rows are presented in place, as the driver presents its own. const declaredType = (ctx as DatasetScopedStrategyContext).declaredFieldType; + const referenceOf = relationshipReferenceOf(ctx); const numberMeasures = (query.measures ?? []).filter((member) => { const measure = this.lookupMember(cube, member, 'measure'); if (!measure?.type || !Object.prototype.hasOwnProperty.call(AGGREGATE_ANSWER_KIND, measure.type)) return false; if (AGGREGATE_ANSWER_KIND[measure.type as AggregationFunction] === 'number') return true; - const sourceType = typeof declaredType === 'function' ? declaredType.call(ctx, objectName, measure.sql) : undefined; + const target = measureColumnOf(cube, objectName, measure.sql, referenceOf); + const sourceType = target && typeof declaredType === 'function' + ? declaredType.call(ctx, target.object, target.field) + : undefined; return sourceType !== undefined && NUMERIC_VALUE_TYPES.has(sourceType); }); if (numberMeasures.length > 0 && Array.isArray(rows)) { @@ -1225,21 +1251,18 @@ export class NativeSQLStrategy implements AnalyticsStrategy { // answered `500` for a boolean `sum` the engine answers. The column's // class is the one predicate's, over the declaration the host relays for // the object the column lives on — the base object, or the object a - // relationship path's last hop reads ({@link columnObjectOf}, the one hop - // resolver). An expression, a column the host cannot describe, or a host + // relationship path's last hop reads ({@link measureColumnOf}, through the + // one hop resolver). An expression, a column the host cannot describe, or a host // that names no dialect gets no class or no policy, and is aggregated as // stored. The expression metric types are not aggregates and are never // wrapped. + const target = measureColumnOf(cube, parentTable, measure.sql, joins.referenceOf); const col = column === '*' || !Object.prototype.hasOwnProperty.call(AGGREGATE_ANSWER_KIND, measure.type) ? column : aggregandOperandSql( measure.type as AggregationFunction, - IDENTIFIER_PATH.test(measure.sql) - ? aggregandColumnClass( - declaredValueShapeResolver(ctx, columnObjectOf(cube, parentTable, measure.sql, joins.referenceOf))?.( - measure.sql.slice(measure.sql.lastIndexOf('.') + 1), - ), - ) + target + ? aggregandColumnClass(declaredValueShapeResolver(ctx, target.object)?.(target.field)) : undefined, sqlDialectFor(ctx, parentTable), column, From bcfb4d637d5a2d8be6be97586ddc74e8744026b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 19:01:16 +0000 Subject: [PATCH 2/5] test(service-analytics): pin the relationship-path cube measure judgment and presentation on both faces Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- ...ube-measure-relationship-path-type.test.ts | 358 ++++++++++++++++++ 1 file changed, 358 insertions(+) create mode 100644 packages/services/service-analytics/src/__tests__/cube-measure-relationship-path-type.test.ts diff --git a/packages/services/service-analytics/src/__tests__/cube-measure-relationship-path-type.test.ts b/packages/services/service-analytics/src/__tests__/cube-measure-relationship-path-type.test.ts new file mode 100644 index 00000000000..82093cbf816 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/cube-measure-relationship-path-type.test.ts @@ -0,0 +1,358 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21129] A cube measure whose `sql` is a RELATIONSHIP PATH (`account.name`) + * is judged by the aggregate × field-type table, described in `fields[]`, and + * presented on the native face by the declaration on the object the path's + * last hop reaches — located through the one hop resolver (`hop-object.ts`), + * as a base-object measure is by the base object's declaration (#21044). + * + * ## The shape this closes + * + * Measured through `POST /api/v1/analytics/query` on the real dispatcher route + * at `c6b6889193`, SQLite and PostgreSQL 16.14, a configured cube over `deal` + * with a declared join `account`: + * + * | measure `sql` | native-SQL face | ObjectQL face | + * |:--|:--|:--| + * | `max` / `min` over `account.name` (`text`), `max` over `account.tier` (`select`) | 200, the text, `fields[]` `number` | 400 `INVALID_FIELD`, its cross-object refusal | + * | `sum` over `account.name` | 200 `0` on SQLite, 500 on PostgreSQL | the same 400 | + * | `max` / `min` over `account.revenue` (`number`) | 200; on PostgreSQL the string `"250.000000000000000000000000000000"` | the same 400 | + * | `max` over `account.opened_at` (`datetime`) | 200, the instant, `fields[]` `number` | the same 400 | + * + * The cube door refused the base-object column of the same type + * (`max_note`, the control) on both faces. + * + * ## What these pins hold + * + * - A relationship-path pair the table refuses is refused by the cube door's + * `INVALID_FIELD` / 400 BEFORE either strategy reads anything, with the + * same envelope on both faces: the member, the request key, the cube, the + * path as the measure spells it and the object that declares the column. + * The ObjectQL face's own cross-object refusal carries no `field` / `object`, + * so equal envelopes are the door's, not two refusals that happen to agree. + * - The hop's object is the one resolver's: the cube's declared join (tier 1), + * and — for a relationship the cube declares no join for — the relationship + * field's declared `reference` (tier 2), never an object named after the + * relationship (`owner` reaches `os21129_person`). + * - On the native face, a relationship-path `min` / `max` over a numeric + * column is a JS number typed `number` — on PostgreSQL too — and over a + * temporal column is typed `time`, as a base-object one is. + * - The base-object measures are the control. + * + * ⚠️ Not pinned here: the ObjectQL face still refuses an ACCEPTED + * relationship-path pair (`max` over `account.revenue`) as a cross-object + * measure, a capability the engine's aggregate does not have. Whether the two + * faces converge on that pair is not this card's ruling. + * + * ## The dialect axis of THIS file + * + * The SQLite cell always runs. The PostgreSQL cell runs where + * `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise; no CI step + * provisions that variable for this package, so the live cell is red-capable + * and un-run in CI, and the PR that landed this file carries its local + * PostgreSQL 16 run. The live cell owns its tables, dropped before and after. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import type { Cube } from '@objectstack/spec/data'; +import { AnalyticsService } from '../analytics-service.js'; +import { AnalyticsServicePlugin } from '../plugin.js'; + +const ACCOUNT = 'os21129_account'; +const PERSON = 'os21129_person'; +const DEAL = 'os21129_deal'; + +const ACCOUNT_OBJECT = { + name: ACCOUNT, + label: 'Relationship-path account', + fields: { + name: { name: 'name', type: 'text' as const }, + revenue: { name: 'revenue', type: 'number' as const }, + opened_at: { name: 'opened_at', type: 'datetime' as const }, + tier: { + name: 'tier', + type: 'select' as const, + options: [{ label: 'A', value: 'a' }, { label: 'B', value: 'b' }], + }, + }, +}; + +const PERSON_OBJECT = { + name: PERSON, + label: 'Relationship-path person', + fields: { + email: { name: 'email', type: 'text' as const }, + score: { name: 'score', type: 'number' as const }, + }, +}; + +const DEAL_OBJECT = { + name: DEAL, + label: 'Relationship-path deal', + fields: { + note: { name: 'note', type: 'text' as const }, + amount: { name: 'amount', type: 'number' as const }, + account: { name: 'account', type: 'lookup' as const, reference: ACCOUNT }, + // Named after neither its target nor anything the cube joins: only the + // field's declared `reference` reaches `os21129_person`. + owner: { name: 'owner', type: 'lookup' as const, reference: PERSON }, + }, +}; + +const ACCOUNTS = [ + { id: 'a1', name: 'alpha', revenue: 100, opened_at: '2026-01-02T03:04:05.000Z', tier: 'a' }, + { id: 'a2', name: 'zeta', revenue: 250, opened_at: '2026-03-04T05:06:07.000Z', tier: 'b' }, +] as const; +const PEOPLE = [ + { id: 'p1', email: 'p1@example.com', score: 7 }, + { id: 'p2', email: 'p2@example.com', score: 41 }, +] as const; +const DEALS = [ + { id: 'd1', note: 'x', amount: 10, account: 'a1', owner: 'p1' }, + { id: 'd2', note: 'y', amount: 32, account: 'a2', owner: 'p2' }, +] as const; + +const CUBE: Cube = { + name: 'os21129_cube', + title: 'Relationship-path measure cube', + sql: DEAL, + public: true, + // `account` is joined (tier 1); `owner` is not (tier 2, its `reference`). + joins: { account: { name: ACCOUNT } }, + measures: { + max_acct_name: { type: 'max', sql: 'account.name', label: 'Largest account name (text)' }, + min_acct_name: { type: 'min', sql: 'account.name', label: 'Smallest account name (text)' }, + max_acct_tier: { type: 'max', sql: 'account.tier', label: 'Largest account tier (select)' }, + sum_acct_name: { type: 'sum', sql: 'account.name', label: 'Sum of account names (text)' }, + max_owner_email: { type: 'max', sql: 'owner.email', label: 'Largest owner email (text)' }, + max_acct_revenue: { type: 'max', sql: 'account.revenue', label: 'Largest account revenue (number)' }, + min_acct_revenue: { type: 'min', sql: 'account.revenue', label: 'Smallest account revenue (number)' }, + max_owner_score: { type: 'max', sql: 'owner.score', label: 'Largest owner score (number)' }, + max_acct_opened: { type: 'max', sql: 'account.opened_at', label: 'Latest account opening (datetime)' }, + max_note: { type: 'max', sql: 'note', label: 'Largest note (text)' }, + max_amount: { type: 'max', sql: 'amount', label: 'Largest amount (number)' }, + }, + dimensions: {}, +} as Cube; + +/** Pairs the table refuses: the member, the path its `sql` spells, and the object that declares the column. */ +const REFUSED: ReadonlyArray = [ + ['max_acct_name', 'account.name', ACCOUNT], + ['min_acct_name', 'account.name', ACCOUNT], + ['max_acct_tier', 'account.tier', ACCOUNT], + ['sum_acct_name', 'account.name', ACCOUNT], + ['max_owner_email', 'owner.email', PERSON], +]; + +/** Accepted relationship-path `min` / `max` over a numeric column, and the value the rows hold. */ +const NUMERIC: ReadonlyArray = [ + ['max_acct_revenue', 250], + ['min_acct_revenue', 100], + ['max_owner_score', 41], +]; + +interface Cell { + id: 'sqlite' | 'pg'; + label: string; + env: string | null; + config: () => Record | null; +} + +const CELLS: readonly Cell[] = [ + { id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) }, + { + id: 'pg', + label: 'live postgres', + env: 'OS_TEST_POSTGRES_URL', + config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null), + }, +]; + +const FACES = ['native', 'objectql'] as const; +type Face = (typeof FACES)[number]; + +const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; + +type Refusal = Error & { code?: string; status?: number; member?: string; param?: string; cube?: string; field?: string; object?: string }; + +/** The envelope a refusal carries, without its words. */ +const envelopeOf = (err: Refusal | undefined) => ({ + code: err?.code, + status: err?.status, + member: err?.member, + param: err?.param, + cube: err?.cube, + field: err?.field, + object: err?.object, +}); + +for (const cell of CELLS) { + const config = cell.config(); + describe.skipIf(!config)( + `[#21129] a relationship-path cube measure is judged and presented by its column's declaration (${cell.label})${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, + () => { + let driver: any; + let engine: ObjectQL; + /** Raw-SQL statements and engine aggregates, on any object. */ + const reads = { rawSql: 0, aggregate: 0 }; + /** `native`: the plugin's own capabilities. `objectql`: narrowed to the engine-aggregate path. */ + const services: Partial> = {}; + + const dropTables = async () => { + if (cell.id !== 'pg') return; + for (const table of [DEAL, ACCOUNT, PERSON]) await driver?.execute(`drop table if exists ${table}`).catch(() => {}); + }; + + /** One query on one face, with the reads it caused counted. */ + const read = async (face: Face, measures: readonly string[]) => { + const before = { ...reads }; + const outcome = await services[face]!.query({ cube: CUBE.name, measures: [...measures] } as any).then( + (res) => ({ res, err: undefined as Refusal | undefined }), + (err) => ({ res: undefined, err: err as Refusal }), + ); + return { ...outcome, rawSql: reads.rawSql - before.rawSql, aggregate: reads.aggregate - before.aggregate }; + }; + + beforeAll(async () => { + driver = new SqlDriver(config as any); + await dropTables(); + engine = new ObjectQL({ logger: quiet } as any); + engine.registerDriver(driver, true); + await engine.init(); + for (const object of [ACCOUNT_OBJECT, PERSON_OBJECT, DEAL_OBJECT]) engine.registry.registerObject(object as any); + await engine.syncSchemas(); + for (const row of ACCOUNTS) await engine.insert(ACCOUNT, { ...row } as any); + for (const row of PEOPLE) await engine.insert(PERSON, { ...row } as any); + for (const row of DEALS) await engine.insert(DEAL, { ...row } as any); + + const realExecute = (engine as any).execute.bind(engine); + (engine as any).execute = (sql: unknown, opts?: unknown) => { + reads.rawSql += 1; + return realExecute(sql, opts); + }; + const realAggregate = engine.aggregate.bind(engine); + (engine as any).aggregate = (...args: unknown[]) => { + reads.aggregate += 1; + return (realAggregate as any)(...args); + }; + + // The plugin's own composition over the real engine — `sourceFieldMeta` + // and the relationship resolver wired from the engine's registry. + for (const [face, caps] of [ + ['native', undefined], + ['objectql', () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false })], + ] as const) { + const registered: Record = {}; + await new AnalyticsServicePlugin({ cubes: [CUBE], ...(caps ? { queryCapabilities: caps } : {}) } as any).init({ + getService: (name: string) => (name === 'data' ? engine : registered[name]), + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, + replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, + hook: () => {}, + logger: quiet, + } as never); + services[face] = registered.analytics as AnalyticsService; + } + }); + + afterAll(async () => { + await dropTables(); + try { await engine?.destroy(); } catch { /* noop */ } + }); + + it('a relationship-path pair the table refuses is refused INVALID_FIELD / 400 before anything is read, with one envelope on both faces', async () => { + for (const [member, path, object] of REFUSED) { + const envelopes: Record = {}; + for (const face of FACES) { + const { res, err, rawSql, aggregate } = await read(face, [member]); + expect(res, `${face} ${member} must not be served`).toBeUndefined(); + expect(err?.code, `${face} ${member}: ${err?.message}`).toBe('INVALID_FIELD'); + expect(err?.status, `${face} ${member}`).toBe(400); + expect(err?.member, `${face} ${member}`).toBe(member); + expect(err?.param, `${face} ${member}`).toBe('measures'); + expect(err?.cube, `${face} ${member}`).toBe(CUBE.name); + expect(err?.field, `${face} ${member}: the path as the measure spells it`).toBe(path); + expect(err?.object, `${face} ${member}: the object that declares the column`).toBe(object); + expect(rawSql, `${face} ${member}: no raw statement ran`).toBe(0); + expect(aggregate, `${face} ${member}: no engine aggregate ran`).toBe(0); + envelopes[face] = envelopeOf(err); + } + expect(envelopes.objectql, `${member}: one door, one envelope`).toEqual(envelopes.native); + } + }); + + it('the control — a base-object pair the table refuses is refused the same way', async () => { + for (const face of FACES) { + const { res, err, rawSql, aggregate } = await read(face, ['max_note']); + expect(res, `${face} max_note must not be served`).toBeUndefined(); + expect(err?.code, `${face}: ${err?.message}`).toBe('INVALID_FIELD'); + expect(err?.status).toBe(400); + expect(err?.field).toBe('note'); + expect(err?.object).toBe(DEAL); + expect(rawSql + aggregate, `${face}: nothing was read`).toBe(0); + } + }); + + it('native: a relationship-path min / max over a numeric column is a number, typed number', async () => { + for (const [member, value] of NUMERIC) { + const { res, err } = await read('native', [member]); + expect(err, `${member}: ${err?.message}`).toBeUndefined(); + expect(res!.rows[0]![member], `${member} answers the number`).toBe(value); + expect(res!.fields.find((f) => f.name === member)?.type, member).toBe('number'); + } + }); + + it('native: a relationship-path max over a temporal column is served and typed time', async () => { + const { res, err } = await read('native', ['max_acct_opened']); + expect(err, err?.message).toBeUndefined(); + expect(res!.rows[0]!.max_acct_opened, 'max_acct_opened answers a value').not.toBeNull(); + expect(res!.fields.find((f) => f.name === 'max_acct_opened')?.type).toBe('time'); + }); + + it('the control — a base-object max over a number column is served, a number, typed number, on both faces', async () => { + for (const face of FACES) { + const { res, err } = await read(face, ['max_amount']); + expect(err, `${face}: ${err?.message}`).toBeUndefined(); + expect(res!.rows[0]!.max_amount).toBe(32); + expect(res!.fields.find((f) => f.name === 'max_amount')?.type).toBe('number'); + } + }); + + it('the dry-run door refuses a relationship-path pair the query door refuses', async () => { + const err = await services.native!.generateSql({ cube: CUBE.name, measures: ['max_acct_name'] } as any).then( + () => undefined, + (e) => e as Refusal, + ); + expect(err?.code, err?.message).toBe('INVALID_FIELD'); + expect(err?.status).toBe(400); + expect(err?.field).toBe('account.name'); + expect(err?.object).toBe(ACCOUNT); + const control = await services.native!.generateSql({ cube: CUBE.name, measures: ['max_acct_revenue'] } as any); + expect(control.sql).toMatch(/max\(/i); + }); + }, + ); +} + +describe('[#21129] cannot answer, do not block', () => { + it('a relationship path whose object the host does not describe gets no verdict: the pair reaches its strategy', async () => { + let statements = 0; + const service = new AnalyticsService({ + logger: quiet as any, + cubes: [CUBE], + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + // Describes the base object only. + sourceFieldMeta: (object: string, field: string) => + object === DEAL ? (DEAL_OBJECT.fields as Record)[field] : undefined, + executeRawSql: async () => { + statements += 1; + return [{ max_acct_name: 'zeta' }]; + }, + }); + const res = await service.query({ cube: CUBE.name, measures: ['max_acct_name'] } as any); + expect(statements).toBe(1); + expect(res.rows).toEqual([{ max_acct_name: 'zeta' }]); + }); +}); From 37196767fc4b6a70cd3979f1e066aec6eefbfdda Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 19:05:12 +0000 Subject: [PATCH 3/5] test(service-analytics): re-judge the two dataset fixtures whose field hook answered for every object Both cases pinned the compile check's stand-down on a dotted field with a sourceFieldMeta stub that described the joined object too. The cube door now reads the column there, so the stand-down case gets a hook that describes the base object alone, as its comment says, and a sibling case pins the cube door's query-time refusal where the joined object is described. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- ...aggregate-datetime-measure-refusal.test.ts | 51 +++++++++++++++---- ...regate-nontemporal-measure-refusal.test.ts | 6 ++- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/packages/services/service-analytics/src/__tests__/aggregate-datetime-measure-refusal.test.ts b/packages/services/service-analytics/src/__tests__/aggregate-datetime-measure-refusal.test.ts index 9015671eeab..fdbf0257026 100644 --- a/packages/services/service-analytics/src/__tests__/aggregate-datetime-measure-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/aggregate-datetime-measure-refusal.test.ts @@ -470,21 +470,52 @@ describe('#16737 — the gate stands down rather than guessing', () => { expect(result.rows.length).toBe(1); }); + const rel = () => DatasetSchema.parse({ + name: 'contract_cycle_rel', + label: 'Contract cycle', + object: 'clm_contract', + include: ['account'], + dimensions: [{ name: 'status', field: 'status', type: 'string' }], + measures: [{ name: 'avg_acct', aggregate: 'avg', field: 'account.submitted_at' }], + }); + it('a RELATIONSHIP-PATH field → not judged, because the hook answers about the base object', async () => { // `account.submitted_at` is a column on `account`, not on `clm_contract`. // Judging it from `sourceFieldMeta('clm_contract', …)` would be answering - // about a different column that happens to share a name. - const { svc } = makeService([{ status: 'open', avg_acct: 1 }]); - const rel = DatasetSchema.parse({ - name: 'contract_cycle_rel', - label: 'Contract cycle', - object: 'clm_contract', - include: ['account'], - dimensions: [{ name: 'status', field: 'status', type: 'string' }], - measures: [{ name: 'avg_acct', aggregate: 'avg', field: 'account.submitted_at' }], + // about a different column that happens to share a name. [#21129] The hook + // here answers about the base object ALONE, as this case says: the cube + // door locates the column on `account`, which it cannot describe, so no + // door judges the pair and its statement runs. `makeService`'s hook + // answers for every object, which is the next case. + const sqls: string[] = []; + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async (_object: string, sql: string) => { + sqls.push(sql); + return [{ status: 'open', avg_acct: 1 }]; + }, + sourceFieldMeta: (o: string, f: string) => (o === 'clm_contract' && FIELD_TYPES[f] ? { type: FIELD_TYPES[f] } : undefined), }); - const result: any = await svc.queryDataset(rel, { dimensions: ['status'], measures: ['avg_acct'] }); + const result: any = await svc.queryDataset(rel(), { dimensions: ['status'], measures: ['avg_acct'] }); expect(result.rows.length).toBe(1); + expect(sqls.length).toBe(1); + }); + + it('[#21129] …and where the hook describes the joined object, the cube door judges the column there: INVALID_FIELD / 400, before any statement', async () => { + // The compile check above still stands down on the dotted field; the + // query the dataset runs passes the cube door, which reads the declaration + // on the object the path's hop reaches (`hop-object.ts`) — here the + // compiled join's `account`. + const { svc, sqls } = makeService([{ status: 'open', avg_acct: 1 }]); + const err: any = await svc.queryDataset(rel(), { dimensions: ['status'], measures: ['avg_acct'] }).then( + () => undefined, + (e) => e, + ); + expect(err?.code, err?.message).toBe('INVALID_FIELD'); + expect(err?.status).toBe(400); + expect(err?.field).toBe('account.submitted_at'); + expect(err?.object).toBe('account'); + expect(sqls.length, 'no statement ran').toBe(0); }); }); diff --git a/packages/services/service-analytics/src/__tests__/aggregate-nontemporal-measure-refusal.test.ts b/packages/services/service-analytics/src/__tests__/aggregate-nontemporal-measure-refusal.test.ts index 8be8f70bbda..292cf0d639b 100644 --- a/packages/services/service-analytics/src/__tests__/aggregate-nontemporal-measure-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/aggregate-nontemporal-measure-refusal.test.ts @@ -369,11 +369,14 @@ describe('#16099 — the controls: every pair the table accepts still compiles', expect(unknown.sqls.length).toBe(1); // 2. a dotted relationship path — the hook answers for the BASE object + // [#21129] …and for it ALONE: the cube door reads a relationship-path + // column on the object its hop reaches (`account` here), so a hook that + // answered for every object would describe the column and refuse the pair. const sqls: string[] = []; const svc = new AnalyticsService({ queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), executeRawSql: async (_o: string, sql: string) => { sqls.push(sql); return [{ status: 'open', probe_measure: 1 }]; }, - sourceFieldMeta: (_o: string, f: string) => (FIELD_TYPES[f] ? { type: FIELD_TYPES[f] } : undefined), + sourceFieldMeta: (o: string, f: string) => (o === 'duly_task' && FIELD_TYPES[f] ? { type: FIELD_TYPES[f] } : undefined), } as never); const rel = DatasetSchema.parse({ name: 'task_metrics_rel', label: 'Task metrics', object: 'duly_task', include: ['account'], @@ -382,6 +385,7 @@ describe('#16099 — the controls: every pair the table accepts still compiles', }); const r2: any = await svc.queryDataset(rel as never, { dimensions: ['status'], measures: ['probe_measure'] } as never); expect(r2.rows.length).toBe(1); + expect(sqls.length).toBe(1); // 3. no `declaredFieldType` hook at all (no data engine wired) const bare: string[] = []; From 6fa57bf341d6f3a19a9cdefbdcfdbb05d478af11 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 19:09:52 +0000 Subject: [PATCH 4/5] docs(service-analytics): changeset, and the dataset refusal code for a relationship-path measure Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../21129-relationship-path-measure-type.md | 27 +++++++++++++++++++ .../docs/deployment/validating-metadata.mdx | 4 ++- 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 .changeset/21129-relationship-path-measure-type.md diff --git a/.changeset/21129-relationship-path-measure-type.md b/.changeset/21129-relationship-path-measure-type.md new file mode 100644 index 00000000000..6376a18a926 --- /dev/null +++ b/.changeset/21129-relationship-path-measure-type.md @@ -0,0 +1,27 @@ +--- +"@objectstack/service-analytics": minor +--- + +fix(service-analytics)!: a cube measure whose `sql` is a relationship path (`account.name`) is judged by the aggregate × field-type table, described in `fields[]` and presented on the native-SQL strategy by the declaration on the object the path's last hop reaches, as a measure over the cube's own column already was + +Clause-②: no (the door judgment narrows a measure the base-object door already refuses for its own fields; if the build finds an accept-set change beyond that, it reports it). + + + +**BREAKING**: this narrows what `POST /api/v1/analytics/query` and its dry run `POST /api/v1/analytics/sql` accept on the native-SQL strategy, on every driver. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes. + +The column a relationship path names is located by the one hop resolver both strategies join and read it through: the cube's declared join at that path, else the relationship field's declared `reference`. + +FROM → TO, for a `measures` entry that resolves to a cube measure over a relationship path (an authored cube measure, or a compiled dataset's measure over an `include`d relationship): + +- `min` / `max` / `sum` / `avg` over a related field of a type the table refuses for that aggregate (the string family such as `text`, `select`, `lookup`; the JSON-stored, file and `formula` types; and the rest the table lists): FROM, on the native-SQL strategy, `200` with the related column's own value (a string such as `"zeta"`) under `fields[] { type: 'number' }` on SQLite and PostgreSQL, and for `sum` a plausible `0` on SQLite and `500 DATABASE_ERROR` on PostgreSQL; the ObjectQL strategy refused it as a cross-object measure. TO `400 INVALID_FIELD` on both strategies, before either reads anything — the refusal a base-object column of the same type already got. +- `min` / `max` over a related numeric field on PostgreSQL: FROM the exact-decimal string (`"250.000000000000000000000000000000"`) under `fields[] number`. TO the number `250`. +- `min` / `max` over a related `date`, `datetime` or `time` field: FROM `fields[] { type: 'number' }` beside the instant. TO `fields[] { type: 'time' }`. + +**What an author sees now.** `400 INVALID_FIELD`, naming the measure as the request wrote it, the cube, the path, the related object and the type it declares, saying the query was not run, and naming the types the aggregate accepts. The thrown error carries `member`, `param` (`measures`), `cube`, `field` (the path, `account.name`) and `object` (the related object that declares the column). + +**What to write instead.** Aggregate a related field of a type the aggregate accepts, or `count` the rows. A first or last related record by a text value is a sort on a list, not an aggregate. + +**Who is affected.** A dashboard, report or caller that asked `min` / `max` / `sum` / `avg` of such a related column through the native-SQL strategy and read the answer as a real one. No example app and no shipped cube or dataset authors such a pair. A dataset whose measure aggregates such a related field is now refused when its query runs (`INVALID_FIELD`), where its compile check, which reads the base object's declaration, still lets it through. + +**Unchanged.** Every pair the table accepts; a measure over the cube's own column; `count`, and `count_distinct`, which keeps its own door; a related column the host's field metadata cannot describe; an expression `sql` or `*`; a host that wires no `sourceFieldMeta`; and the ObjectQL strategy's refusal of a related-field measure the table accepts (`max` over a related `number`), a capability limit of the engine aggregate — run that query on a native-SQL driver. diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 950579ac3c0..c8d03e69767 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -214,7 +214,9 @@ for equality alike. The check reads the field's declaration as well as its type: a `select`, `radio`, `lookup`, `user`, `file` or `image` field declared `multiple: true` holds a list stored as JSON, so `count_distinct` over it is refused too, although the table accepts the type. The analytics service -refuses the same pair with `400 DATASET_INVALID` when a query is built; this +refuses the same pair with `400 DATASET_INVALID` when a query is built — or, +for a field reached through a relationship path, with `400 INVALID_FIELD` when +the query runs, judged on the object the path reaches; this is the identical verdict, from the identical table, one door earlier. The rule stays silent wherever the field's type cannot be resolved (an object this stack does not define, a From c632454740e26177da76986fd5e849725ada4adf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 19:46:07 +0000 Subject: [PATCH 5/5] docs(service-analytics): the changeset declares the narrowing arm, per the claim revision Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .changeset/21129-relationship-path-measure-type.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/21129-relationship-path-measure-type.md b/.changeset/21129-relationship-path-measure-type.md index 6376a18a926..6ec089324fa 100644 --- a/.changeset/21129-relationship-path-measure-type.md +++ b/.changeset/21129-relationship-path-measure-type.md @@ -4,7 +4,7 @@ fix(service-analytics)!: a cube measure whose `sql` is a relationship path (`account.name`) is judged by the aggregate × field-type table, described in `fields[]` and presented on the native-SQL strategy by the declaration on the object the path's last hop reaches, as a measure over the cube's own column already was -Clause-②: no (the door judgment narrows a measure the base-object door already refuses for its own fields; if the build finds an accept-set change beyond that, it reports it). +Clause-②: no (narrowing)