@objectstack/service-analytics 8.0.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +215 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +99 -3
- package/dist/index.d.ts +99 -3
- package/dist/index.js +213 -9
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.d.cts
CHANGED
|
@@ -83,6 +83,73 @@ interface CompiledDataset {
|
|
|
83
83
|
type RelationshipResolver = (baseObject: string, relationshipName: string) => string | undefined;
|
|
84
84
|
declare function compileDataset(dataset: Dataset, resolver?: RelationshipResolver): CompiledDataset;
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Dimension display-label resolution (ADR-0021).
|
|
88
|
+
*
|
|
89
|
+
* Analytics groups by the raw stored value of a dimension field. For two field
|
|
90
|
+
* kinds that value is NOT human-readable:
|
|
91
|
+
*
|
|
92
|
+
* - **select** — grouped by the stored option `value` (e.g. `backlog`), but the
|
|
93
|
+
* user-facing text is the option `label` (e.g. `Backlog`).
|
|
94
|
+
* - **lookup / master_detail** — grouped by the foreign-key `id` (e.g.
|
|
95
|
+
* `8eqtuKI4G9IhUsPS`), but the user-facing text is the related record's
|
|
96
|
+
* display field (its name/title).
|
|
97
|
+
*
|
|
98
|
+
* `resolveDimensionLabels` post-processes the result rows IN PLACE, replacing the
|
|
99
|
+
* raw value at `row[dimension.name]` with its display label when one is found.
|
|
100
|
+
* Unresolved values are left untouched so an orphaned id still renders as itself
|
|
101
|
+
* rather than blanking out. Date / number / plain-string dimensions are no-ops.
|
|
102
|
+
*
|
|
103
|
+
* The resolution LOGIC lives here (and is unit-tested); the low-level capabilities
|
|
104
|
+
* — reading an object's field map and fetching id→label pairs — are injected via
|
|
105
|
+
* {@link DimensionLabelDeps} so this module stays free of any engine dependency.
|
|
106
|
+
*/
|
|
107
|
+
/** The minimal field shape this resolver needs. */
|
|
108
|
+
interface FieldMetaLite {
|
|
109
|
+
type?: string;
|
|
110
|
+
/** Lookup / master_detail target object name. */
|
|
111
|
+
reference?: string;
|
|
112
|
+
/** Select options — the value→label source. */
|
|
113
|
+
options?: Array<{
|
|
114
|
+
value: unknown;
|
|
115
|
+
label?: string;
|
|
116
|
+
}>;
|
|
117
|
+
}
|
|
118
|
+
/** Capabilities the resolver needs from the runtime (injected by the plugin). */
|
|
119
|
+
interface DimensionLabelDeps {
|
|
120
|
+
/** Return the field map for an object, or `undefined` if unknown. */
|
|
121
|
+
getObjectFields(objectName: string): Record<string, FieldMetaLite> | undefined;
|
|
122
|
+
/**
|
|
123
|
+
* Fetch a map of `id → display label` for the given ids of a target object.
|
|
124
|
+
* The implementation chooses the target's display field. Returning an empty
|
|
125
|
+
* map (e.g. no display field, no data access) leaves the ids unresolved.
|
|
126
|
+
*/
|
|
127
|
+
fetchRecordLabels(targetObject: string, ids: unknown[]): Promise<Map<unknown, string>>;
|
|
128
|
+
}
|
|
129
|
+
/** Date-dimension granularity (mirrors the dataset `dateGranularity` enum). */
|
|
130
|
+
type DateGranularity = 'day' | 'week' | 'month' | 'quarter' | 'year';
|
|
131
|
+
/**
|
|
132
|
+
* Replace raw dimension values with display labels, in place.
|
|
133
|
+
*
|
|
134
|
+
* @param baseObject - the dataset's base object (where the dimension fields live)
|
|
135
|
+
* @param dims - selected dimensions as `{ name, field, type?, dateGranularity? }`
|
|
136
|
+
* (row key = `name`)
|
|
137
|
+
* @param rows - result rows, mutated in place
|
|
138
|
+
* @param deps - injected runtime capabilities
|
|
139
|
+
*/
|
|
140
|
+
declare function resolveDimensionLabels(baseObject: string, dims: Array<{
|
|
141
|
+
name: string;
|
|
142
|
+
field: string;
|
|
143
|
+
type?: string;
|
|
144
|
+
dateGranularity?: DateGranularity | string;
|
|
145
|
+
}>, rows: Record<string, unknown>[], deps: DimensionLabelDeps): Promise<void>;
|
|
146
|
+
/**
|
|
147
|
+
* Pick the display field for an object from its field map, by convention:
|
|
148
|
+
* an explicit `name`/`title`/`label` field, else the first text-like field.
|
|
149
|
+
* Returns `undefined` when nothing suitable exists.
|
|
150
|
+
*/
|
|
151
|
+
declare function pickDisplayField(fields: Record<string, FieldMetaLite> | undefined): string | undefined;
|
|
152
|
+
|
|
86
153
|
/**
|
|
87
154
|
* Configuration for AnalyticsService.
|
|
88
155
|
*/
|
|
@@ -131,8 +198,13 @@ interface AnalyticsServiceConfig {
|
|
|
131
198
|
* object (exactly what `RLSCompiler` emits). The service binds the active
|
|
132
199
|
* context per query and the strategy compiles the filter into alias-qualified
|
|
133
200
|
* SQL injected into every base and joined table.
|
|
201
|
+
*
|
|
202
|
+
* MAY be async: the production bridge resolves RLS from the `security`
|
|
203
|
+
* service's `getReadFilter`, which can hit the database. The service
|
|
204
|
+
* pre-resolves the scope for every base + joined object of a query (before
|
|
205
|
+
* the synchronous SQL builder runs), so a sync return still works unchanged.
|
|
134
206
|
*/
|
|
135
|
-
getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined
|
|
207
|
+
getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined | Promise<FilterCondition | null | undefined>;
|
|
136
208
|
/**
|
|
137
209
|
* ADR-0021 D-C — join allowlist per cube (the dataset's declared `include`).
|
|
138
210
|
* Joins outside this set are rejected by the strategy. Compiled datasets
|
|
@@ -148,6 +220,14 @@ interface AnalyticsServiceConfig {
|
|
|
148
220
|
relationshipResolver?: RelationshipResolver;
|
|
149
221
|
/** Pre-defined datasets to compile + register at construction (ADR-0021). */
|
|
150
222
|
datasets?: Dataset[];
|
|
223
|
+
/**
|
|
224
|
+
* ADR-0021 — resolve raw dimension values to human display labels. When
|
|
225
|
+
* provided, `queryDataset` post-processes result rows so a `select` dimension
|
|
226
|
+
* shows its option label (not the stored value) and a `lookup`/`master_detail`
|
|
227
|
+
* dimension shows the related record's display name (not the FK id). Injected
|
|
228
|
+
* by the plugin from the `data` engine; omit to keep raw values.
|
|
229
|
+
*/
|
|
230
|
+
labelResolver?: DimensionLabelDeps;
|
|
151
231
|
}
|
|
152
232
|
/**
|
|
153
233
|
* AnalyticsService — Multi-driver analytics orchestrator.
|
|
@@ -177,6 +257,8 @@ declare class AnalyticsService implements IAnalyticsService {
|
|
|
177
257
|
private readonly datasetRegistry;
|
|
178
258
|
/** Optional object-graph resolver used when compiling datasets. */
|
|
179
259
|
private readonly relationshipResolver?;
|
|
260
|
+
/** Optional dimension display-label resolver (select options / lookup names). */
|
|
261
|
+
private readonly labelResolver?;
|
|
180
262
|
readonly cubeRegistry: CubeRegistry;
|
|
181
263
|
private readonly logger;
|
|
182
264
|
constructor(config?: AnalyticsServiceConfig);
|
|
@@ -186,6 +268,20 @@ declare class AnalyticsService implements IAnalyticsService {
|
|
|
186
268
|
* `getReadScope(objectName)` that already knows the active tenant.
|
|
187
269
|
*/
|
|
188
270
|
private callCtx;
|
|
271
|
+
/**
|
|
272
|
+
* Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
|
|
273
|
+
* AND every joined object of the query's cube, keyed by object name. This is
|
|
274
|
+
* the async pre-pass that lets the synchronous strategy enforce scoping even
|
|
275
|
+
* when the provider (security `getReadFilter`) resolves asynchronously.
|
|
276
|
+
*
|
|
277
|
+
* The object set is `cube.sql` (base) plus every `cube.joins[*].name` — a
|
|
278
|
+
* SUPERSET of what the strategy actually scans (the strategy only joins along
|
|
279
|
+
* declared relationships), so no scanned object is ever left unscoped.
|
|
280
|
+
*
|
|
281
|
+
* Fail-closed: if the provider throws for an object, the whole query is
|
|
282
|
+
* rejected rather than emitting SQL with that object unscoped.
|
|
283
|
+
*/
|
|
284
|
+
private resolveReadScopes;
|
|
189
285
|
/**
|
|
190
286
|
* Execute an analytical query by delegating to the first capable strategy.
|
|
191
287
|
*/
|
|
@@ -270,7 +366,7 @@ interface AnalyticsServicePluginOptions {
|
|
|
270
366
|
* emits). When omitted, the plugin auto-bridges to a registered `'security'`
|
|
271
367
|
* service exposing `getReadFilter(object, context)` if one is present.
|
|
272
368
|
*/
|
|
273
|
-
getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined
|
|
369
|
+
getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined | Promise<FilterCondition | null | undefined>;
|
|
274
370
|
/**
|
|
275
371
|
* ADR-0021 D-C — join allowlist per cube (the dataset's declared `include`).
|
|
276
372
|
* Typically wired from the dataset registry's compiled `allowedRelationships`.
|
|
@@ -476,4 +572,4 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
|
|
|
476
572
|
private buildFieldMeta;
|
|
477
573
|
}
|
|
478
574
|
|
|
479
|
-
export { AnalyticsService, type AnalyticsServiceConfig, AnalyticsServicePlugin, type AnalyticsServicePluginOptions, type CompareTo, type CompiledDataset, CubeRegistry, DatasetExecutor, type DerivedMeasureSpec, NativeSQLStrategy, ObjectQLStrategy, type RelationshipResolver, combineFilters, compileDataset, compileScopedFilterToSql, evaluateDerivedMeasures, mergeByDimensions, shiftRange };
|
|
575
|
+
export { AnalyticsService, type AnalyticsServiceConfig, AnalyticsServicePlugin, type AnalyticsServicePluginOptions, type CompareTo, type CompiledDataset, CubeRegistry, DatasetExecutor, type DerivedMeasureSpec, type DimensionLabelDeps, type FieldMetaLite, NativeSQLStrategy, ObjectQLStrategy, type RelationshipResolver, combineFilters, compileDataset, compileScopedFilterToSql, evaluateDerivedMeasures, mergeByDimensions, pickDisplayField, resolveDimensionLabels, shiftRange };
|
package/dist/index.d.ts
CHANGED
|
@@ -83,6 +83,73 @@ interface CompiledDataset {
|
|
|
83
83
|
type RelationshipResolver = (baseObject: string, relationshipName: string) => string | undefined;
|
|
84
84
|
declare function compileDataset(dataset: Dataset, resolver?: RelationshipResolver): CompiledDataset;
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Dimension display-label resolution (ADR-0021).
|
|
88
|
+
*
|
|
89
|
+
* Analytics groups by the raw stored value of a dimension field. For two field
|
|
90
|
+
* kinds that value is NOT human-readable:
|
|
91
|
+
*
|
|
92
|
+
* - **select** — grouped by the stored option `value` (e.g. `backlog`), but the
|
|
93
|
+
* user-facing text is the option `label` (e.g. `Backlog`).
|
|
94
|
+
* - **lookup / master_detail** — grouped by the foreign-key `id` (e.g.
|
|
95
|
+
* `8eqtuKI4G9IhUsPS`), but the user-facing text is the related record's
|
|
96
|
+
* display field (its name/title).
|
|
97
|
+
*
|
|
98
|
+
* `resolveDimensionLabels` post-processes the result rows IN PLACE, replacing the
|
|
99
|
+
* raw value at `row[dimension.name]` with its display label when one is found.
|
|
100
|
+
* Unresolved values are left untouched so an orphaned id still renders as itself
|
|
101
|
+
* rather than blanking out. Date / number / plain-string dimensions are no-ops.
|
|
102
|
+
*
|
|
103
|
+
* The resolution LOGIC lives here (and is unit-tested); the low-level capabilities
|
|
104
|
+
* — reading an object's field map and fetching id→label pairs — are injected via
|
|
105
|
+
* {@link DimensionLabelDeps} so this module stays free of any engine dependency.
|
|
106
|
+
*/
|
|
107
|
+
/** The minimal field shape this resolver needs. */
|
|
108
|
+
interface FieldMetaLite {
|
|
109
|
+
type?: string;
|
|
110
|
+
/** Lookup / master_detail target object name. */
|
|
111
|
+
reference?: string;
|
|
112
|
+
/** Select options — the value→label source. */
|
|
113
|
+
options?: Array<{
|
|
114
|
+
value: unknown;
|
|
115
|
+
label?: string;
|
|
116
|
+
}>;
|
|
117
|
+
}
|
|
118
|
+
/** Capabilities the resolver needs from the runtime (injected by the plugin). */
|
|
119
|
+
interface DimensionLabelDeps {
|
|
120
|
+
/** Return the field map for an object, or `undefined` if unknown. */
|
|
121
|
+
getObjectFields(objectName: string): Record<string, FieldMetaLite> | undefined;
|
|
122
|
+
/**
|
|
123
|
+
* Fetch a map of `id → display label` for the given ids of a target object.
|
|
124
|
+
* The implementation chooses the target's display field. Returning an empty
|
|
125
|
+
* map (e.g. no display field, no data access) leaves the ids unresolved.
|
|
126
|
+
*/
|
|
127
|
+
fetchRecordLabels(targetObject: string, ids: unknown[]): Promise<Map<unknown, string>>;
|
|
128
|
+
}
|
|
129
|
+
/** Date-dimension granularity (mirrors the dataset `dateGranularity` enum). */
|
|
130
|
+
type DateGranularity = 'day' | 'week' | 'month' | 'quarter' | 'year';
|
|
131
|
+
/**
|
|
132
|
+
* Replace raw dimension values with display labels, in place.
|
|
133
|
+
*
|
|
134
|
+
* @param baseObject - the dataset's base object (where the dimension fields live)
|
|
135
|
+
* @param dims - selected dimensions as `{ name, field, type?, dateGranularity? }`
|
|
136
|
+
* (row key = `name`)
|
|
137
|
+
* @param rows - result rows, mutated in place
|
|
138
|
+
* @param deps - injected runtime capabilities
|
|
139
|
+
*/
|
|
140
|
+
declare function resolveDimensionLabels(baseObject: string, dims: Array<{
|
|
141
|
+
name: string;
|
|
142
|
+
field: string;
|
|
143
|
+
type?: string;
|
|
144
|
+
dateGranularity?: DateGranularity | string;
|
|
145
|
+
}>, rows: Record<string, unknown>[], deps: DimensionLabelDeps): Promise<void>;
|
|
146
|
+
/**
|
|
147
|
+
* Pick the display field for an object from its field map, by convention:
|
|
148
|
+
* an explicit `name`/`title`/`label` field, else the first text-like field.
|
|
149
|
+
* Returns `undefined` when nothing suitable exists.
|
|
150
|
+
*/
|
|
151
|
+
declare function pickDisplayField(fields: Record<string, FieldMetaLite> | undefined): string | undefined;
|
|
152
|
+
|
|
86
153
|
/**
|
|
87
154
|
* Configuration for AnalyticsService.
|
|
88
155
|
*/
|
|
@@ -131,8 +198,13 @@ interface AnalyticsServiceConfig {
|
|
|
131
198
|
* object (exactly what `RLSCompiler` emits). The service binds the active
|
|
132
199
|
* context per query and the strategy compiles the filter into alias-qualified
|
|
133
200
|
* SQL injected into every base and joined table.
|
|
201
|
+
*
|
|
202
|
+
* MAY be async: the production bridge resolves RLS from the `security`
|
|
203
|
+
* service's `getReadFilter`, which can hit the database. The service
|
|
204
|
+
* pre-resolves the scope for every base + joined object of a query (before
|
|
205
|
+
* the synchronous SQL builder runs), so a sync return still works unchanged.
|
|
134
206
|
*/
|
|
135
|
-
getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined
|
|
207
|
+
getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined | Promise<FilterCondition | null | undefined>;
|
|
136
208
|
/**
|
|
137
209
|
* ADR-0021 D-C — join allowlist per cube (the dataset's declared `include`).
|
|
138
210
|
* Joins outside this set are rejected by the strategy. Compiled datasets
|
|
@@ -148,6 +220,14 @@ interface AnalyticsServiceConfig {
|
|
|
148
220
|
relationshipResolver?: RelationshipResolver;
|
|
149
221
|
/** Pre-defined datasets to compile + register at construction (ADR-0021). */
|
|
150
222
|
datasets?: Dataset[];
|
|
223
|
+
/**
|
|
224
|
+
* ADR-0021 — resolve raw dimension values to human display labels. When
|
|
225
|
+
* provided, `queryDataset` post-processes result rows so a `select` dimension
|
|
226
|
+
* shows its option label (not the stored value) and a `lookup`/`master_detail`
|
|
227
|
+
* dimension shows the related record's display name (not the FK id). Injected
|
|
228
|
+
* by the plugin from the `data` engine; omit to keep raw values.
|
|
229
|
+
*/
|
|
230
|
+
labelResolver?: DimensionLabelDeps;
|
|
151
231
|
}
|
|
152
232
|
/**
|
|
153
233
|
* AnalyticsService — Multi-driver analytics orchestrator.
|
|
@@ -177,6 +257,8 @@ declare class AnalyticsService implements IAnalyticsService {
|
|
|
177
257
|
private readonly datasetRegistry;
|
|
178
258
|
/** Optional object-graph resolver used when compiling datasets. */
|
|
179
259
|
private readonly relationshipResolver?;
|
|
260
|
+
/** Optional dimension display-label resolver (select options / lookup names). */
|
|
261
|
+
private readonly labelResolver?;
|
|
180
262
|
readonly cubeRegistry: CubeRegistry;
|
|
181
263
|
private readonly logger;
|
|
182
264
|
constructor(config?: AnalyticsServiceConfig);
|
|
@@ -186,6 +268,20 @@ declare class AnalyticsService implements IAnalyticsService {
|
|
|
186
268
|
* `getReadScope(objectName)` that already knows the active tenant.
|
|
187
269
|
*/
|
|
188
270
|
private callCtx;
|
|
271
|
+
/**
|
|
272
|
+
* Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
|
|
273
|
+
* AND every joined object of the query's cube, keyed by object name. This is
|
|
274
|
+
* the async pre-pass that lets the synchronous strategy enforce scoping even
|
|
275
|
+
* when the provider (security `getReadFilter`) resolves asynchronously.
|
|
276
|
+
*
|
|
277
|
+
* The object set is `cube.sql` (base) plus every `cube.joins[*].name` — a
|
|
278
|
+
* SUPERSET of what the strategy actually scans (the strategy only joins along
|
|
279
|
+
* declared relationships), so no scanned object is ever left unscoped.
|
|
280
|
+
*
|
|
281
|
+
* Fail-closed: if the provider throws for an object, the whole query is
|
|
282
|
+
* rejected rather than emitting SQL with that object unscoped.
|
|
283
|
+
*/
|
|
284
|
+
private resolveReadScopes;
|
|
189
285
|
/**
|
|
190
286
|
* Execute an analytical query by delegating to the first capable strategy.
|
|
191
287
|
*/
|
|
@@ -270,7 +366,7 @@ interface AnalyticsServicePluginOptions {
|
|
|
270
366
|
* emits). When omitted, the plugin auto-bridges to a registered `'security'`
|
|
271
367
|
* service exposing `getReadFilter(object, context)` if one is present.
|
|
272
368
|
*/
|
|
273
|
-
getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined
|
|
369
|
+
getReadScope?: (objectName: string, context?: ExecutionContext) => FilterCondition | null | undefined | Promise<FilterCondition | null | undefined>;
|
|
274
370
|
/**
|
|
275
371
|
* ADR-0021 D-C — join allowlist per cube (the dataset's declared `include`).
|
|
276
372
|
* Typically wired from the dataset registry's compiled `allowedRelationships`.
|
|
@@ -476,4 +572,4 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
|
|
|
476
572
|
private buildFieldMeta;
|
|
477
573
|
}
|
|
478
574
|
|
|
479
|
-
export { AnalyticsService, type AnalyticsServiceConfig, AnalyticsServicePlugin, type AnalyticsServicePluginOptions, type CompareTo, type CompiledDataset, CubeRegistry, DatasetExecutor, type DerivedMeasureSpec, NativeSQLStrategy, ObjectQLStrategy, type RelationshipResolver, combineFilters, compileDataset, compileScopedFilterToSql, evaluateDerivedMeasures, mergeByDimensions, shiftRange };
|
|
575
|
+
export { AnalyticsService, type AnalyticsServiceConfig, AnalyticsServicePlugin, type AnalyticsServicePluginOptions, type CompareTo, type CompiledDataset, CubeRegistry, DatasetExecutor, type DerivedMeasureSpec, type DimensionLabelDeps, type FieldMetaLite, NativeSQLStrategy, ObjectQLStrategy, type RelationshipResolver, combineFilters, compileDataset, compileScopedFilterToSql, evaluateDerivedMeasures, mergeByDimensions, pickDisplayField, resolveDimensionLabels, shiftRange };
|
package/dist/index.js
CHANGED
|
@@ -596,12 +596,22 @@ var ObjectQLStrategy = class {
|
|
|
596
596
|
async execute(query, ctx) {
|
|
597
597
|
const cube = ctx.getCube(query.cube);
|
|
598
598
|
const objectName = this.extractObjectName(cube);
|
|
599
|
+
const granByDim = /* @__PURE__ */ new Map();
|
|
600
|
+
for (const td of query.timeDimensions ?? []) {
|
|
601
|
+
if (td.granularity) granByDim.set(td.dimension, td.granularity);
|
|
602
|
+
}
|
|
599
603
|
const groupBy = [];
|
|
600
604
|
if (query.dimensions && query.dimensions.length > 0) {
|
|
601
605
|
for (const dim of query.dimensions) {
|
|
602
|
-
|
|
606
|
+
const field = this.resolveFieldName(cube, dim, "dimension");
|
|
607
|
+
const gran = granByDim.get(dim);
|
|
608
|
+
groupBy.push(gran ? { field, dateGranularity: gran } : field);
|
|
609
|
+
granByDim.delete(dim);
|
|
603
610
|
}
|
|
604
611
|
}
|
|
612
|
+
for (const [dim, gran] of granByDim) {
|
|
613
|
+
groupBy.push({ field: this.resolveFieldName(cube, dim, "dimension"), dateGranularity: gran });
|
|
614
|
+
}
|
|
605
615
|
const aggregations = [];
|
|
606
616
|
if (query.measures && query.measures.length > 0) {
|
|
607
617
|
for (const measure of query.measures) {
|
|
@@ -614,10 +624,16 @@ var ObjectQLStrategy = class {
|
|
|
614
624
|
if (normalizedFilters.length > 0) {
|
|
615
625
|
for (const f of normalizedFilters) {
|
|
616
626
|
const fieldName = this.resolveFieldName(cube, f.member, "any");
|
|
617
|
-
|
|
627
|
+
const converted = this.convertFilter(f.operator, f.values);
|
|
628
|
+
const existing = filter[fieldName];
|
|
629
|
+
const mergeable = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
630
|
+
filter[fieldName] = mergeable(existing) && mergeable(converted) ? { ...existing, ...converted } : converted;
|
|
618
631
|
}
|
|
619
632
|
}
|
|
620
633
|
const rows = await ctx.executeAggregate(objectName, {
|
|
634
|
+
// Structured groupBy items ({field, dateGranularity}) pass through the
|
|
635
|
+
// executeAggregate bridge to engine.aggregate, which buckets them. The
|
|
636
|
+
// contract types groupBy as string[]; the cast carries the richer shape.
|
|
621
637
|
groupBy: groupBy.length > 0 ? groupBy : void 0,
|
|
622
638
|
aggregations: aggregations.length > 0 ? aggregations : void 0,
|
|
623
639
|
filter: Object.keys(filter).length > 0 ? filter : void 0
|
|
@@ -1030,7 +1046,17 @@ var DatasetExecutor = class {
|
|
|
1030
1046
|
timezone: opts.selection.timezone ?? "UTC"
|
|
1031
1047
|
};
|
|
1032
1048
|
if (opts.where) q.where = opts.where;
|
|
1033
|
-
|
|
1049
|
+
const selTimeDims = opts.selection.timeDimensions ?? [];
|
|
1050
|
+
const selDims = new Set(selTimeDims.map((t) => t.dimension));
|
|
1051
|
+
const explicitTimeDims = [];
|
|
1052
|
+
for (const name of opts.dimensions) {
|
|
1053
|
+
const cd = compiled.cube.dimensions[name];
|
|
1054
|
+
if (cd?.type === "time" && cd.granularities?.length === 1 && !selDims.has(name)) {
|
|
1055
|
+
explicitTimeDims.push({ dimension: name, granularity: String(cd.granularities[0]) });
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
const mergedTimeDims = [...selTimeDims, ...explicitTimeDims];
|
|
1059
|
+
if (mergedTimeDims.length > 0) q.timeDimensions = mergedTimeDims;
|
|
1034
1060
|
if (opts.selection.order) q.order = opts.selection.order;
|
|
1035
1061
|
if (opts.selection.limit != null) q.limit = opts.selection.limit;
|
|
1036
1062
|
if (opts.selection.offset != null) q.offset = opts.selection.offset;
|
|
@@ -1085,6 +1111,88 @@ function mergeByDimensions(base, extra, dimensions, valueColumns) {
|
|
|
1085
1111
|
return base;
|
|
1086
1112
|
}
|
|
1087
1113
|
|
|
1114
|
+
// src/dimension-labels.ts
|
|
1115
|
+
var LOOKUP_TYPES = /* @__PURE__ */ new Set(["lookup", "master_detail"]);
|
|
1116
|
+
var pad = (n) => String(n).padStart(2, "0");
|
|
1117
|
+
function formatDateBucket(value, granularity) {
|
|
1118
|
+
if (value == null || value instanceof Date === false) {
|
|
1119
|
+
if (typeof value !== "number" && typeof value !== "string") return value;
|
|
1120
|
+
}
|
|
1121
|
+
let d;
|
|
1122
|
+
if (value instanceof Date) d = value;
|
|
1123
|
+
else if (typeof value === "number") d = new Date(value);
|
|
1124
|
+
else {
|
|
1125
|
+
const s = String(value).trim();
|
|
1126
|
+
d = /^\d+$/.test(s) ? new Date(Number(s) < 1e12 ? Number(s) * 1e3 : Number(s)) : new Date(s);
|
|
1127
|
+
}
|
|
1128
|
+
if (Number.isNaN(d.getTime())) return value;
|
|
1129
|
+
const y = d.getUTCFullYear();
|
|
1130
|
+
const m = d.getUTCMonth();
|
|
1131
|
+
switch (granularity) {
|
|
1132
|
+
case "year":
|
|
1133
|
+
return String(y);
|
|
1134
|
+
case "quarter":
|
|
1135
|
+
return `${y}-Q${Math.floor(m / 3) + 1}`;
|
|
1136
|
+
case "month":
|
|
1137
|
+
return `${y}-${pad(m + 1)}`;
|
|
1138
|
+
case "week":
|
|
1139
|
+
case "day":
|
|
1140
|
+
default:
|
|
1141
|
+
return `${y}-${pad(m + 1)}-${pad(d.getUTCDate())}`;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
async function resolveDimensionLabels(baseObject, dims, rows, deps) {
|
|
1145
|
+
if (!rows.length || !dims.length) return;
|
|
1146
|
+
const fields = deps.getObjectFields(baseObject);
|
|
1147
|
+
if (!fields) return;
|
|
1148
|
+
for (const dim of dims) {
|
|
1149
|
+
const meta = fields[dim.field];
|
|
1150
|
+
if (dim.type === "date" || meta && meta.type === "date") {
|
|
1151
|
+
for (const row of rows) {
|
|
1152
|
+
const formatted = formatDateBucket(row[dim.name], dim.dateGranularity);
|
|
1153
|
+
if (formatted != null) row[dim.name] = formatted;
|
|
1154
|
+
}
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1157
|
+
if (!meta) continue;
|
|
1158
|
+
if (Array.isArray(meta.options) && meta.options.length > 0) {
|
|
1159
|
+
const labelByValue = /* @__PURE__ */ new Map();
|
|
1160
|
+
for (const opt of meta.options) {
|
|
1161
|
+
if (opt && opt.label != null) labelByValue.set(opt.value, String(opt.label));
|
|
1162
|
+
}
|
|
1163
|
+
if (labelByValue.size === 0) continue;
|
|
1164
|
+
for (const row of rows) {
|
|
1165
|
+
const raw = row[dim.name];
|
|
1166
|
+
const label = labelByValue.get(raw);
|
|
1167
|
+
if (label != null) row[dim.name] = label;
|
|
1168
|
+
}
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
if (meta.type && LOOKUP_TYPES.has(meta.type) && meta.reference) {
|
|
1172
|
+
const ids = Array.from(
|
|
1173
|
+
new Set(rows.map((r) => r[dim.name]).filter((v) => v != null))
|
|
1174
|
+
);
|
|
1175
|
+
if (ids.length === 0) continue;
|
|
1176
|
+
const labelById = await deps.fetchRecordLabels(meta.reference, ids);
|
|
1177
|
+
if (!labelById || labelById.size === 0) continue;
|
|
1178
|
+
for (const row of rows) {
|
|
1179
|
+
const label = labelById.get(row[dim.name]);
|
|
1180
|
+
if (label != null) row[dim.name] = label;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
function pickDisplayField(fields) {
|
|
1186
|
+
if (!fields) return void 0;
|
|
1187
|
+
for (const preferred of ["name", "title", "label"]) {
|
|
1188
|
+
if (fields[preferred]) return preferred;
|
|
1189
|
+
}
|
|
1190
|
+
for (const [name, meta] of Object.entries(fields)) {
|
|
1191
|
+
if (meta.type === "text" || meta.type === "string") return name;
|
|
1192
|
+
}
|
|
1193
|
+
return void 0;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1088
1196
|
// src/analytics-service.ts
|
|
1089
1197
|
var DEFAULT_CAPABILITIES = {
|
|
1090
1198
|
nativeSql: false,
|
|
@@ -1102,6 +1210,7 @@ var AnalyticsService = class {
|
|
|
1102
1210
|
}
|
|
1103
1211
|
this.readScopeProvider = config.getReadScope;
|
|
1104
1212
|
this.relationshipResolver = config.relationshipResolver;
|
|
1213
|
+
this.labelResolver = config.labelResolver;
|
|
1105
1214
|
if (config.datasets) {
|
|
1106
1215
|
for (const ds of config.datasets) {
|
|
1107
1216
|
try {
|
|
@@ -1139,13 +1248,60 @@ var AnalyticsService = class {
|
|
|
1139
1248
|
* current request's ExecutionContext (ADR-0021 D-C). The strategy then sees a
|
|
1140
1249
|
* `getReadScope(objectName)` that already knows the active tenant.
|
|
1141
1250
|
*/
|
|
1142
|
-
callCtx(context) {
|
|
1251
|
+
async callCtx(query, context) {
|
|
1143
1252
|
if (!this.readScopeProvider) return this.baseCtx;
|
|
1253
|
+
const scopes = await this.resolveReadScopes(query, context);
|
|
1144
1254
|
return {
|
|
1145
1255
|
...this.baseCtx,
|
|
1146
|
-
getReadScope: (objectName) =>
|
|
1256
|
+
getReadScope: (objectName) => scopes.get(objectName) ?? null
|
|
1147
1257
|
};
|
|
1148
1258
|
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
|
|
1261
|
+
* AND every joined object of the query's cube, keyed by object name. This is
|
|
1262
|
+
* the async pre-pass that lets the synchronous strategy enforce scoping even
|
|
1263
|
+
* when the provider (security `getReadFilter`) resolves asynchronously.
|
|
1264
|
+
*
|
|
1265
|
+
* The object set is `cube.sql` (base) plus every `cube.joins[*].name` — a
|
|
1266
|
+
* SUPERSET of what the strategy actually scans (the strategy only joins along
|
|
1267
|
+
* declared relationships), so no scanned object is ever left unscoped.
|
|
1268
|
+
*
|
|
1269
|
+
* Fail-closed: if the provider throws for an object, the whole query is
|
|
1270
|
+
* rejected rather than emitting SQL with that object unscoped.
|
|
1271
|
+
*/
|
|
1272
|
+
async resolveReadScopes(query, context) {
|
|
1273
|
+
const map = /* @__PURE__ */ new Map();
|
|
1274
|
+
const provider = this.readScopeProvider;
|
|
1275
|
+
if (!provider || !query.cube) return map;
|
|
1276
|
+
const cube = this.cubeRegistry.get(query.cube);
|
|
1277
|
+
if (!cube) return map;
|
|
1278
|
+
const objects = /* @__PURE__ */ new Set();
|
|
1279
|
+
if (typeof cube.sql === "string" && cube.sql.trim()) {
|
|
1280
|
+
objects.add(cube.sql.trim());
|
|
1281
|
+
}
|
|
1282
|
+
const joins = cube.joins;
|
|
1283
|
+
if (joins) {
|
|
1284
|
+
for (const [alias, j] of Object.entries(joins)) {
|
|
1285
|
+
objects.add(j?.name ?? alias);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
for (const object of objects) {
|
|
1289
|
+
let filter;
|
|
1290
|
+
try {
|
|
1291
|
+
filter = await provider(object, context);
|
|
1292
|
+
} catch (e) {
|
|
1293
|
+
this.logger.error?.(
|
|
1294
|
+
`[Analytics] read-scope resolution failed for object "${object}" \u2014 rejecting query (fail-closed, ADR-0021 D-C)`,
|
|
1295
|
+
e instanceof Error ? e : new Error(String(e))
|
|
1296
|
+
);
|
|
1297
|
+
throw new Error(
|
|
1298
|
+
`[Analytics] read-scope resolution failed for "${object}"; query denied (fail-closed).`
|
|
1299
|
+
);
|
|
1300
|
+
}
|
|
1301
|
+
if (filter != null) map.set(object, filter);
|
|
1302
|
+
}
|
|
1303
|
+
return map;
|
|
1304
|
+
}
|
|
1149
1305
|
/**
|
|
1150
1306
|
* Execute an analytical query by delegating to the first capable strategy.
|
|
1151
1307
|
*/
|
|
@@ -1154,7 +1310,7 @@ var AnalyticsService = class {
|
|
|
1154
1310
|
throw new Error("Cube name is required in analytics query");
|
|
1155
1311
|
}
|
|
1156
1312
|
this.ensureCube(query);
|
|
1157
|
-
const ctx = this.callCtx(context);
|
|
1313
|
+
const ctx = await this.callCtx(query, context);
|
|
1158
1314
|
const strategy = this.resolveStrategy(query, ctx);
|
|
1159
1315
|
this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
1160
1316
|
return strategy.execute(query, ctx);
|
|
@@ -1179,7 +1335,27 @@ var AnalyticsService = class {
|
|
|
1179
1335
|
async queryDataset(dataset, selection, context) {
|
|
1180
1336
|
const compiled = this.registerDataset(dataset);
|
|
1181
1337
|
this.logger.debug(`[Analytics] queryDataset "${dataset.name}" (object=${dataset.object}, include=${(dataset.include ?? []).join(",") || "\u2014"})`);
|
|
1182
|
-
|
|
1338
|
+
const result = await new DatasetExecutor(this).execute(compiled, selection, context);
|
|
1339
|
+
if (this.labelResolver && selection.dimensions?.length) {
|
|
1340
|
+
const dims = selection.dimensions.map((name) => dataset.dimensions?.find((d) => d.name === name)).filter((d) => !!d?.field).map((d) => ({ name: d.name, field: d.field, type: d.type, dateGranularity: d.dateGranularity }));
|
|
1341
|
+
if (dims.length) {
|
|
1342
|
+
try {
|
|
1343
|
+
await resolveDimensionLabels(dataset.object, dims, result.rows, this.labelResolver);
|
|
1344
|
+
} catch (e) {
|
|
1345
|
+
this.logger?.warn?.(`[Analytics] dimension label resolution failed for "${dataset.name}": ${String(e?.message ?? e)}`);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
if (result.fields?.length && dataset.measures?.length) {
|
|
1350
|
+
const measureByName = new Map(dataset.measures.map((m) => [m.name, m]));
|
|
1351
|
+
for (const f of result.fields) {
|
|
1352
|
+
const m = measureByName.get(f.name) ?? measureByName.get(f.name.replace(/__compare$/, ""));
|
|
1353
|
+
if (!m) continue;
|
|
1354
|
+
if (f.label == null && typeof m.label === "string") f.label = m.label;
|
|
1355
|
+
if (f.format == null && m.format) f.format = m.format;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
return result;
|
|
1183
1359
|
}
|
|
1184
1360
|
/**
|
|
1185
1361
|
* Get cube metadata for discovery.
|
|
@@ -1209,7 +1385,7 @@ var AnalyticsService = class {
|
|
|
1209
1385
|
throw new Error("Cube name is required for SQL generation");
|
|
1210
1386
|
}
|
|
1211
1387
|
this.ensureCube(query);
|
|
1212
|
-
const ctx = this.callCtx(context);
|
|
1388
|
+
const ctx = await this.callCtx(query, context);
|
|
1213
1389
|
const strategy = this.resolveStrategy(query, ctx);
|
|
1214
1390
|
this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
1215
1391
|
return strategy.generateSql(query, ctx);
|
|
@@ -1479,6 +1655,31 @@ var AnalyticsServicePlugin = class {
|
|
|
1479
1655
|
}
|
|
1480
1656
|
return engine ? void 0 : relationshipName;
|
|
1481
1657
|
};
|
|
1658
|
+
const dataEngine = () => {
|
|
1659
|
+
try {
|
|
1660
|
+
const svc = ctx.getService("data");
|
|
1661
|
+
return svc && typeof svc.getObject === "function" ? svc : void 0;
|
|
1662
|
+
} catch {
|
|
1663
|
+
return void 0;
|
|
1664
|
+
}
|
|
1665
|
+
};
|
|
1666
|
+
const labelResolver = {
|
|
1667
|
+
getObjectFields: (objectName) => dataEngine()?.getObject?.(objectName)?.fields,
|
|
1668
|
+
fetchRecordLabels: async (targetObject, ids) => {
|
|
1669
|
+
const map = /* @__PURE__ */ new Map();
|
|
1670
|
+
const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
|
|
1671
|
+
if (!displayField || !executeAggregate || ids.length === 0) return map;
|
|
1672
|
+
const rows = await executeAggregate(targetObject, {
|
|
1673
|
+
groupBy: ["id", displayField],
|
|
1674
|
+
aggregations: [{ field: "id", method: "count", alias: "_c" }],
|
|
1675
|
+
filter: { id: { $in: ids } }
|
|
1676
|
+
});
|
|
1677
|
+
for (const r of rows) {
|
|
1678
|
+
if (r.id != null && r[displayField] != null) map.set(r.id, String(r[displayField]));
|
|
1679
|
+
}
|
|
1680
|
+
return map;
|
|
1681
|
+
}
|
|
1682
|
+
};
|
|
1482
1683
|
const config = {
|
|
1483
1684
|
cubes: this.options.cubes,
|
|
1484
1685
|
logger: ctx.logger,
|
|
@@ -1488,7 +1689,8 @@ var AnalyticsServicePlugin = class {
|
|
|
1488
1689
|
fallbackService,
|
|
1489
1690
|
getReadScope,
|
|
1490
1691
|
getAllowedRelationships: this.options.getAllowedRelationships,
|
|
1491
|
-
relationshipResolver
|
|
1692
|
+
relationshipResolver,
|
|
1693
|
+
labelResolver
|
|
1492
1694
|
};
|
|
1493
1695
|
if (autoBridgedReadScope) {
|
|
1494
1696
|
ctx.logger.info('[Analytics] Auto-bridged getReadScope \u2192 "security" service (getReadFilter)');
|
|
@@ -1539,6 +1741,8 @@ export {
|
|
|
1539
1741
|
compileScopedFilterToSql,
|
|
1540
1742
|
evaluateDerivedMeasures,
|
|
1541
1743
|
mergeByDimensions,
|
|
1744
|
+
pickDisplayField,
|
|
1745
|
+
resolveDimensionLabels,
|
|
1542
1746
|
shiftRange
|
|
1543
1747
|
};
|
|
1544
1748
|
//# sourceMappingURL=index.js.map
|