Skip to content

Commit 5d69394

Browse files
committed
fix(service-analytics)!: the cube door asks the aggregate x field-type table for every measure
A configured or suffix-inferred cube measure whose aggregate AGGREGATE_FIELD_TYPE_COMPATIBILITY refuses for its column's declared type is refused INVALID_FIELD / 400 in ensureCube, ahead of both strategies, as the dataset door refuses the pair at compile. count_distinct keeps its own door. A min / max over a temporal column is described time in fields[] by the dataset door's one rule, measureResultType, at the seam every strategy's result leaves through. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
1 parent be5c1a6 commit 5d69394

4 files changed

Lines changed: 332 additions & 2 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
fix(service-analytics)!: the cube door asks the aggregate × field-type table for every measure, so a configured or suffix-inferred cube measure whose aggregate the table refuses for its column's declared type answers `INVALID_FIELD` / 400 on every driver and both strategies, and a `min` / `max` over a temporal column is described `time` in `fields[]`
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (already-registered dataset-measure-selecting-aggregate-field-type-refused, dataset-measure-aggregate-field-type-refused) the pairs this change refuses are exactly the pairs AGGREGATE_FIELD_TYPE_COMPATIBILITY already refuses, and the table is not edited: every refused min / max pair is registered under protocol major 18 by the first id and every refused sum / avg pair by the second, each with its routes (count, a sort for a first or last record, or a numeric / temporal field for a quantity stored as text). This change adds a query-time reader of the same table at the analytics cube door; it refuses a query shape, not a stored one, and no authorable key, export or stored row moves: CubeSchema and the analytics query body keep parsing every member. -->
10+
11+
**BREAKING**: this narrows what `POST /api/v1/analytics/query` and its dry run `POST /api/v1/analytics/sql` accept, on every driver and on both strategies. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
12+
13+
FROM → TO, for a `measures` entry that resolves to a cube measure over a column of the cube's own object (an authored cube measure, or a suffix-inferred one such as `note_max`):
14+
15+
- `min` / `max` over a type outside the numeric, temporal and boolean classes (the string family such as `text`, `email` and `url`; `select`, `radio`, `lookup`, `user`; `autonumber`; the JSON-stored, file and `formula` types): FROM, on the native-SQL strategy, `200` with the column's own value (a string such as `"y"`) under `fields[] { type: 'number' }`, on SQLite and PostgreSQL alike; on the ObjectQL strategy the engine's door already answered `400 INVALID_FIELD` after the strategy began. TO `400 INVALID_FIELD` before either strategy reads anything.
16+
- `sum` / `avg` over a type outside the numeric and boolean classes (`sum` also refuses `percent`): FROM `200` with a plausible `0` on SQLite and `500 DATABASE_ERROR` on PostgreSQL (the ObjectQL strategy refused `avg` at the engine and passed `sum` to the driver, which answered the same `0` / `500`). TO `400 INVALID_FIELD`.
17+
- `min` / `max` over a `date`, `datetime` or `time` column: FROM `fields[] { type: 'number' }` beside the instant. TO `fields[] { type: 'time' }`, the `DimensionType` word a temporal dimension column already carries, by the same rule the dataset door applies (`measureResultType`).
18+
19+
**What an author sees now.** `400 INVALID_FIELD`, naming the measure as the request wrote it, the cube, the column, the object and its declared type, saying the query was not run, and naming the types the aggregate accepts, read off `AGGREGATE_FIELD_TYPE_COMPATIBILITY`. The thrown error carries `member`, `param` (`measures`), `cube`, `field` and `object`.
20+
21+
**Why a refusal.** The dataset door (`POST /api/v1/analytics/dataset/query`) refuses every one of these pairs at compile by the same table (`DATASET_INVALID`), and the engine's aggregate door refuses most of them on the ObjectQL strategy; the native-SQL strategy compiled its own statement and asked nothing. Measured through the real dispatcher route on SQLite and PostgreSQL 16: a configured cube's `max` over a `text` column answered `"y"` under a column described `number` on the native strategy and `400` on the ObjectQL strategy, and `sum` over the same column answered `0` on SQLite and `500` on PostgreSQL. One cube, one query, an answer chosen by the driver.
22+
23+
**What to write instead.** Aggregate a field of a type the aggregate accepts. A question that was counting in disguise is `count` (or `count_distinct` over a scalar-stored field). A first or last record by a text value is a sort on a list, not an aggregate. A quantity stored as text belongs in a numeric field of its own, aggregated there.
24+
25+
**Who is affected.** A dashboard, report or caller that asked `min` / `max` / `sum` / `avg` of such a column through `/analytics/query` on the native-SQL strategy and read the answer as a real one. No example app and no shipped cube authors such a pair. A reader that branched on `fields[].type === 'number'` for a temporal `min` / `max` column now sees `time`.
26+
27+
**Unchanged.** Every pair the table accepts, a `max` over a `boolean` column included (its column keeps `number`: the rule declines the boolean class); `count` over any column; `count_distinct`, which keeps its own door and words; a measure over a relationship path (`account.name`), which this door does not judge; a column the host's field metadata cannot resolve, or a type outside `FieldType`; a measure whose `sql` is `*`; an expression metric type (`number` / `string` / `boolean`); and a host that wires no `sourceFieldMeta`, where the declaration cannot be read. The dataset door keeps its own `DATASET_INVALID` answer at compile.

‎packages/services/service-analytics/src/analytics-service.ts‎

Lines changed: 128 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type {
77
CubeMeta,
88
DatasetSelection,
99
} from '@objectstack/spec/contracts';
10-
import { percentScaleOf, type AggregationFunction, type Cube, type FilterCondition } from '@objectstack/spec/data';
10+
import { percentScaleOf, AggregationFunction, type Cube, type FilterCondition } from '@objectstack/spec/data';
1111
import type { ExecutionContext } from '@objectstack/spec/kernel';
1212
import type { Dataset } from '@objectstack/spec/ui';
1313
// [#6761] The ONE shared `I18nLabel → string` resolver (#6765, maintainer
@@ -59,6 +59,11 @@ import {
5959
// Owned in its own module so the enumerated verdict per `AggregationFunction`
6060
// member has one home rather than being inlined at the enrichment site.
6161
import { measureResultType } from './measure-result-type.js';
62+
// [#21044] …and the table that rule asks first, asked at the cube door too:
63+
// a measure whose aggregate × field-type pair it refuses is refused in
64+
// `ensureCube`, ahead of both strategies, as the dataset door refuses it at
65+
// compile.
66+
import { assertCubeMeasureFieldTypesAccepted } from './cube-measure-field-type-door.js';
6267
import type { AnalyticsStrategy, AnalyticsDriverCapabilities, StrategyContext, DatasetScopedStrategyContext, DatasetScope, ReadScopeFilterJudge } from './strategies/types.js';
6368
import { NativeSQLStrategy } from './strategies/native-sql-strategy.js';
6469
import { ObjectQLStrategy } from './strategies/objectql-strategy.js';
@@ -594,6 +599,72 @@ function withDeclaredMeasureFormats(
594599
return fields ? { ...result, fields } : result;
595600
}
596601

602+
/**
603+
* [#21044] The cube measure a `measures` entry resolves to — through
604+
* {@link declaredMemberEntry}, the resolver {@link withDeclaredMeasureFormats}
605+
* reads — and the base-object COLUMN it aggregates: its `sql` when that is a
606+
* bare identifier, `null` otherwise (`'*'`, or a relationship path whose
607+
* declaration lives on another object). `undefined` when the entry resolves to
608+
* no declared measure.
609+
*/
610+
function declaredMeasureColumn(
611+
cube: Cube,
612+
member: string,
613+
): { type: unknown; column: string | null } | undefined {
614+
const entry = declaredMemberEntry(cube, member, 'measure');
615+
const measure = entry ? cube.measures[entry.key] : undefined;
616+
if (!measure) return undefined;
617+
const sql = typeof measure.sql === 'string' ? measure.sql.trim() : '';
618+
return { type: measure.type, column: BARE_IDENTIFIER.test(sql) ? sql : null };
619+
}
620+
621+
/** The `AggregationFunction` vocabulary — the aggregates `measureResultType` speaks about. */
622+
const AGGREGATION_FUNCTIONS: ReadonlySet<string> = new Set(AggregationFunction.options);
623+
624+
/**
625+
* [#21044] Describe each measure column's `type` by the dataset door's one
626+
* rule, {@link measureResultType} — at the seam every strategy's result leaves
627+
* the cube door through, beside {@link withDeclaredMeasureFormats}.
628+
*
629+
* Every producer of `fields[]` mints `{ type: 'number' }` for a measure
630+
* (`buildFieldMeta`, both strategies). The dataset door corrects that in its
631+
* ADR-0021 enrichment (`enrichResultColumns`); the cube door described a
632+
* `min` / `max` over a temporal column as a number, in the same response that
633+
* carried the instant. ⛔ No copy of the rule: this asks it with the cube
634+
* measure's aggregate and the declared type of the base-object column it reads,
635+
* and writes only what it answers. It answers `undefined` for every pair it has
636+
* nothing to say about — the numeric and boolean classes, the count / sum / avg
637+
* rows, an expression metric type — and for every pair the aggregate ×
638+
* field-type table refuses, which the cube door has refused before any strategy
639+
* ran ({@link assertCubeMeasureFieldTypesAccepted}). A relationship-path column
640+
* is not described: the declaration this reads is the base object's.
641+
*
642+
* Copy-on-write, like the format pass: the strategy owns the object it
643+
* returned.
644+
*/
645+
function withMeasureResultTypes(
646+
result: AnalyticsResult,
647+
query: AnalyticsQuery,
648+
cube: Cube | undefined,
649+
declaredTypeOf: ((object: string, field: string) => string | undefined) | undefined,
650+
): AnalyticsResult {
651+
if (!declaredTypeOf || !cube || !result?.fields?.length || !query.measures?.length) return result;
652+
const object = typeof cube.sql === 'string' ? cube.sql.trim() : '';
653+
if (!object || !BARE_IDENTIFIER.test(object)) return result;
654+
const requested = new Set(query.measures);
655+
let fields: AnalyticsResult['fields'] | undefined;
656+
result.fields.forEach((f, i) => {
657+
if (!requested.has(f.name)) return;
658+
const measure = declaredMeasureColumn(cube, f.name);
659+
if (!measure?.column || typeof measure.type !== 'string' || !AGGREGATION_FUNCTIONS.has(measure.type)) return;
660+
const type = measureResultType(measure.type as AggregationFunction, declaredTypeOf(object, measure.column));
661+
if (type === undefined || f.type === type) return;
662+
fields ??= [...result.fields];
663+
fields[i] = { ...f, type };
664+
});
665+
return fields ? { ...result, fields } : result;
666+
}
667+
597668
/**
598669
* Configuration for AnalyticsService.
599670
*/
@@ -1822,8 +1893,20 @@ export class AnalyticsService implements IAnalyticsService {
18221893
// `analytics_cube.measures.format` is described at the same seam, for
18231894
// the same reason: whichever strategy answered, the measure columns
18241895
// leave with the format the cube declares.
1896+
//
1897+
// [#21044] …and with the `type` the dataset door's one rule gives a
1898+
// `min` / `max` over a temporal column (`time`), read off the cube
1899+
// measure and the column's declaration, never off the rows.
18251900
const result = await strategy.execute(query, ctx);
1826-
return this.applySqlEchoPolicy(withDeclaredMeasureFormats(result, query, scope.getCube(query.cube!)));
1901+
const cube = scope.getCube(query.cube!);
1902+
return this.applySqlEchoPolicy(
1903+
withMeasureResultTypes(
1904+
withDeclaredMeasureFormats(result, query, cube),
1905+
query,
1906+
cube,
1907+
this.sourceFieldMeta && ((object, field) => this.sourceFieldMeta?.(object, field)?.type),
1908+
),
1909+
);
18271910
} catch (e) {
18281911
if ((e as { code?: string })?.code === 'RAW_SQL_UNSUPPORTED') {
18291912
this.logger.warn(
@@ -2665,6 +2748,9 @@ export class AnalyticsService implements IAnalyticsService {
26652748
this.assertDimensionFields(query, cube, Object.keys(cube.dimensions));
26662749
// [#20807] …and a grouped dimension's column must not be structured JSON.
26672750
this.assertDimensionsGroupScalarColumns(query, cube);
2751+
// [#21044] …and a measure's aggregate must be one the aggregate ×
2752+
// field-type table accepts for its column's declared type.
2753+
this.assertMeasureFieldTypes(query, cube);
26682754
// [#5669] …and the `where`'s, third and last of the three request keys that
26692755
// carry a field name. Its members are read from the filter TREE, not from
26702756
// `cube.dimensions` — which on this path was minted from this very query,
@@ -2737,6 +2823,9 @@ export class AnalyticsService implements IAnalyticsService {
27372823
// the rejection suggests.
27382824
this.assertDimensionFields(query, augmented, Object.keys(cube.dimensions));
27392825
this.assertDimensionsGroupScalarColumns(query, augmented);
2826+
// [#21044] Judged on the AUGMENTED cube: a suffix-inferred measure and an
2827+
// authored one are one population for the aggregate × field-type table.
2828+
this.assertMeasureFieldTypes(query, augmented);
27402829
// [#5669] The `where` gate resolves a filter member through dimensions AND
27412830
// measures (that is what the strategies do for a filter member), so it is
27422831
// handed the AUGMENTED cube — a caller filtering on a suffix-inferred
@@ -2752,10 +2841,47 @@ export class AnalyticsService implements IAnalyticsService {
27522841
this.assertMeasureFields(query, cube, Object.keys(cube.measures));
27532842
this.assertDimensionFields(query, cube, Object.keys(cube.dimensions));
27542843
this.assertDimensionsGroupScalarColumns(query, cube);
2844+
this.assertMeasureFieldTypes(query, cube);
27552845
this.assertWhereFields(query, cube, Object.keys(cube.dimensions));
27562846
}
27572847
}
27582848

2849+
/**
2850+
* [#21044] Refuse a `measures` entry whose aggregate the aggregate ×
2851+
* field-type table (`AGGREGATE_FIELD_TYPE_COMPATIBILITY`) refuses for its
2852+
* column's declared type — `INVALID_FIELD` / 400, naming the member the
2853+
* caller wrote — before either strategy reads anything. The rule, the tiers,
2854+
* the measurements and the envelope are
2855+
* {@link assertCubeMeasureFieldTypesAccepted}'s
2856+
* (`cube-measure-field-type-door.ts`); this method supplies the two answers
2857+
* only the service has:
2858+
*
2859+
* - The measure and column a member resolves to are
2860+
* {@link declaredMeasureColumn}'s — {@link declaredMemberEntry}, the
2861+
* resolver {@link withDeclaredMeasureFormats} reads — and nothing else
2862+
* resolves a member to a field here.
2863+
* - The column's declared type is {@link AnalyticsServiceConfig.sourceFieldMeta}'s,
2864+
* the declaration the #20807 / #20912 door reads beside it.
2865+
*
2866+
* Runs after that door on every `ensureCube` path, so a `count_distinct`
2867+
* measure keeps its own verdict and a member naming a column the object does
2868+
* not have is answered as that first. Same stand-downs as that door: no
2869+
* `sourceFieldMeta`, or a cube whose `sql` is not a bare object name.
2870+
*/
2871+
private assertMeasureFieldTypes(query: AnalyticsQuery, cube: Cube): void {
2872+
const fieldMeta = this.sourceFieldMeta;
2873+
if (!fieldMeta) return;
2874+
const object = typeof cube.sql === 'string' ? cube.sql.trim() : '';
2875+
if (!object || !BARE_IDENTIFIER.test(object)) return;
2876+
assertCubeMeasureFieldTypesAccepted(
2877+
query,
2878+
cube.name,
2879+
object,
2880+
(member) => declaredMeasureColumn(cube, member),
2881+
(o, field) => fieldMeta(o, field)?.type,
2882+
);
2883+
}
2884+
27592885
/**
27602886
* [#20807] Refuse a GROUPED dimension whose column is a declared
27612887
* structured-JSON field — `INVALID_FIELD` / 400, naming the member the caller

0 commit comments

Comments
 (0)