@objectstack/service-analytics 7.9.0 → 8.0.1
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 +625 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +184 -7
- package/dist/index.d.ts +184 -7
- package/dist/index.js +617 -18
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -23,8 +23,15 @@ __export(index_exports, {
|
|
|
23
23
|
AnalyticsService: () => AnalyticsService,
|
|
24
24
|
AnalyticsServicePlugin: () => AnalyticsServicePlugin,
|
|
25
25
|
CubeRegistry: () => CubeRegistry,
|
|
26
|
+
DatasetExecutor: () => DatasetExecutor,
|
|
26
27
|
NativeSQLStrategy: () => NativeSQLStrategy,
|
|
27
|
-
ObjectQLStrategy: () => ObjectQLStrategy
|
|
28
|
+
ObjectQLStrategy: () => ObjectQLStrategy,
|
|
29
|
+
combineFilters: () => combineFilters,
|
|
30
|
+
compileDataset: () => compileDataset,
|
|
31
|
+
compileScopedFilterToSql: () => compileScopedFilterToSql,
|
|
32
|
+
evaluateDerivedMeasures: () => evaluateDerivedMeasures,
|
|
33
|
+
mergeByDimensions: () => mergeByDimensions,
|
|
34
|
+
shiftRange: () => shiftRange
|
|
28
35
|
});
|
|
29
36
|
module.exports = __toCommonJS(index_exports);
|
|
30
37
|
|
|
@@ -232,6 +239,115 @@ function coerceFilterValueForSql(s) {
|
|
|
232
239
|
return s;
|
|
233
240
|
}
|
|
234
241
|
|
|
242
|
+
// src/read-scope-sql.ts
|
|
243
|
+
var IDENT = /^[a-z_][a-z0-9_]*$/i;
|
|
244
|
+
function quoteIdent(name, kind) {
|
|
245
|
+
if (typeof name !== "string" || !IDENT.test(name)) {
|
|
246
|
+
throw new Error(`[read-scope-sql] unsafe ${kind} identifier "${String(name)}" \u2014 refusing to build read scope (fail-closed).`);
|
|
247
|
+
}
|
|
248
|
+
return `"${name}"`;
|
|
249
|
+
}
|
|
250
|
+
function compileScopedFilterToSql(filter, alias) {
|
|
251
|
+
const quotedAlias = quoteIdent(alias, "alias");
|
|
252
|
+
const params = [];
|
|
253
|
+
const sql = compileNode(filter, quotedAlias, params);
|
|
254
|
+
return { sql, params };
|
|
255
|
+
}
|
|
256
|
+
function compileNode(node, qAlias, params) {
|
|
257
|
+
if (node === null || typeof node !== "object" || Array.isArray(node)) {
|
|
258
|
+
throw new Error("[read-scope-sql] read scope must be a filter object (fail-closed).");
|
|
259
|
+
}
|
|
260
|
+
const clauses = [];
|
|
261
|
+
for (const [key, value] of Object.entries(node)) {
|
|
262
|
+
if (key === "$and" || key === "$or") {
|
|
263
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
264
|
+
throw new Error(`[read-scope-sql] "${key}" requires a non-empty array (fail-closed).`);
|
|
265
|
+
}
|
|
266
|
+
const parts = value.map((child) => compileNode(child, qAlias, params)).filter((s) => s.length > 0);
|
|
267
|
+
if (parts.length === 0) continue;
|
|
268
|
+
const joiner = key === "$and" ? " AND " : " OR ";
|
|
269
|
+
clauses.push(`(${parts.join(joiner)})`);
|
|
270
|
+
} else if (key === "$not") {
|
|
271
|
+
const inner = compileNode(value, qAlias, params);
|
|
272
|
+
if (inner) clauses.push(`NOT (${inner})`);
|
|
273
|
+
} else if (key.startsWith("$")) {
|
|
274
|
+
throw new Error(`[read-scope-sql] unsupported top-level operator "${key}" (fail-closed).`);
|
|
275
|
+
} else {
|
|
276
|
+
clauses.push(compileField(key, value, qAlias, params));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return clauses.join(" AND ");
|
|
280
|
+
}
|
|
281
|
+
function compileField(field, value, qAlias, params) {
|
|
282
|
+
const col = `${qAlias}.${quoteIdent(field, "field")}`;
|
|
283
|
+
if (value === null) return `${col} IS NULL`;
|
|
284
|
+
if (typeof value !== "object" || value instanceof Date) {
|
|
285
|
+
params.push(value);
|
|
286
|
+
return `${col} = ?`;
|
|
287
|
+
}
|
|
288
|
+
if (Array.isArray(value)) {
|
|
289
|
+
throw new Error(`[read-scope-sql] bare array value for "${field}" \u2014 use { $in: [...] } (fail-closed).`);
|
|
290
|
+
}
|
|
291
|
+
const ops = value;
|
|
292
|
+
const keys = Object.keys(ops);
|
|
293
|
+
if (keys.length === 0 || keys.some((k) => !k.startsWith("$"))) {
|
|
294
|
+
throw new Error(`[read-scope-sql] "${field}" has a nested/relation value which is not supported in a read scope (fail-closed).`);
|
|
295
|
+
}
|
|
296
|
+
const parts = [];
|
|
297
|
+
for (const op of keys) {
|
|
298
|
+
parts.push(compileOperator(col, op, ops[op], field, params));
|
|
299
|
+
}
|
|
300
|
+
return parts.length === 1 ? parts[0] : `(${parts.join(" AND ")})`;
|
|
301
|
+
}
|
|
302
|
+
function bind(params, v) {
|
|
303
|
+
params.push(v);
|
|
304
|
+
return "?";
|
|
305
|
+
}
|
|
306
|
+
function compileOperator(col, op, val, field, params) {
|
|
307
|
+
switch (op) {
|
|
308
|
+
case "$eq":
|
|
309
|
+
return val === null ? `${col} IS NULL` : `${col} = ${bind(params, val)}`;
|
|
310
|
+
case "$ne":
|
|
311
|
+
return val === null ? `${col} IS NOT NULL` : `${col} <> ${bind(params, val)}`;
|
|
312
|
+
case "$gt":
|
|
313
|
+
return `${col} > ${bind(params, val)}`;
|
|
314
|
+
case "$gte":
|
|
315
|
+
return `${col} >= ${bind(params, val)}`;
|
|
316
|
+
case "$lt":
|
|
317
|
+
return `${col} < ${bind(params, val)}`;
|
|
318
|
+
case "$lte":
|
|
319
|
+
return `${col} <= ${bind(params, val)}`;
|
|
320
|
+
case "$in": {
|
|
321
|
+
if (!Array.isArray(val)) throw new Error(`[read-scope-sql] $in for "${field}" needs an array (fail-closed).`);
|
|
322
|
+
if (val.length === 0) return "1 = 0";
|
|
323
|
+
return `${col} IN (${val.map((v) => bind(params, v)).join(", ")})`;
|
|
324
|
+
}
|
|
325
|
+
case "$nin": {
|
|
326
|
+
if (!Array.isArray(val)) throw new Error(`[read-scope-sql] $nin for "${field}" needs an array (fail-closed).`);
|
|
327
|
+
if (val.length === 0) return "1 = 1";
|
|
328
|
+
return `${col} NOT IN (${val.map((v) => bind(params, v)).join(", ")})`;
|
|
329
|
+
}
|
|
330
|
+
case "$between": {
|
|
331
|
+
if (!Array.isArray(val) || val.length !== 2) throw new Error(`[read-scope-sql] $between for "${field}" needs [min,max] (fail-closed).`);
|
|
332
|
+
return `${col} BETWEEN ${bind(params, val[0])} AND ${bind(params, val[1])}`;
|
|
333
|
+
}
|
|
334
|
+
case "$contains":
|
|
335
|
+
return `${col} LIKE ${bind(params, `%${String(val)}%`)}`;
|
|
336
|
+
case "$notContains":
|
|
337
|
+
return `${col} NOT LIKE ${bind(params, `%${String(val)}%`)}`;
|
|
338
|
+
case "$startsWith":
|
|
339
|
+
return `${col} LIKE ${bind(params, `${String(val)}%`)}`;
|
|
340
|
+
case "$endsWith":
|
|
341
|
+
return `${col} LIKE ${bind(params, `%${String(val)}`)}`;
|
|
342
|
+
case "$null":
|
|
343
|
+
return val ? `${col} IS NULL` : `${col} IS NOT NULL`;
|
|
344
|
+
case "$exists":
|
|
345
|
+
return val ? `${col} IS NOT NULL` : `${col} IS NULL`;
|
|
346
|
+
default:
|
|
347
|
+
throw new Error(`[read-scope-sql] unsupported operator "${op}" on "${field}" (fail-closed).`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
235
351
|
// src/strategies/native-sql-strategy.ts
|
|
236
352
|
var NativeSQLStrategy = class {
|
|
237
353
|
constructor() {
|
|
@@ -295,6 +411,21 @@ var NativeSQLStrategy = class {
|
|
|
295
411
|
}
|
|
296
412
|
}
|
|
297
413
|
}
|
|
414
|
+
const allowed = ctx.getAllowedRelationships?.(query.cube);
|
|
415
|
+
if (allowed) {
|
|
416
|
+
for (const alias of joins.keys()) {
|
|
417
|
+
if (!allowed.has(alias)) {
|
|
418
|
+
throw new Error(
|
|
419
|
+
`[NativeSQLStrategy] join "${alias}" is not backed by a declared relationship on cube "${query.cube}". v1 only joins along relationships listed in the dataset's \`include\`.`
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
this.applyReadScope(this.extractObjectName(cube), tableName, ctx, whereClauses, params);
|
|
425
|
+
for (const alias of joins.keys()) {
|
|
426
|
+
const joinedObject = cube.joins?.[alias]?.name ?? alias;
|
|
427
|
+
this.applyReadScope(joinedObject, alias, ctx, whereClauses, params);
|
|
428
|
+
}
|
|
298
429
|
let sql = `SELECT ${selectClauses.join(", ")} FROM "${tableName}"`;
|
|
299
430
|
if (joins.size > 0) {
|
|
300
431
|
sql += " " + Array.from(joins.values()).join(" ");
|
|
@@ -318,6 +449,28 @@ var NativeSQLStrategy = class {
|
|
|
318
449
|
return { sql, params };
|
|
319
450
|
}
|
|
320
451
|
// ── Helpers ──────────────────────────────────────────────────────
|
|
452
|
+
/**
|
|
453
|
+
* ADR-0021 D-C — inject an object's read scope (tenant + RLS predicate) into
|
|
454
|
+
* the WHERE clause. The scope is a canonical `FilterCondition` (what the
|
|
455
|
+
* RLSCompiler emits); `compileScopedFilterToSql` turns it into alias-qualified,
|
|
456
|
+
* parameterized SQL (fail-closed — it throws rather than drop a predicate).
|
|
457
|
+
* The `?` placeholders are then renumbered into the strategy's `$N` scheme.
|
|
458
|
+
* No-op when the runtime provides no scope hook (the caller is then
|
|
459
|
+
* responsible for isolation — see contract note).
|
|
460
|
+
*/
|
|
461
|
+
applyReadScope(objectName, alias, ctx, whereClauses, params) {
|
|
462
|
+
if (typeof ctx.getReadScope !== "function") return;
|
|
463
|
+
const filter = ctx.getReadScope(objectName);
|
|
464
|
+
if (filter === void 0 || filter === null) return;
|
|
465
|
+
const { sql, params: scopeParams } = compileScopedFilterToSql(filter, alias);
|
|
466
|
+
if (!sql) return;
|
|
467
|
+
let i = 0;
|
|
468
|
+
const rendered = sql.replace(/\?/g, () => {
|
|
469
|
+
params.push(scopeParams[i++]);
|
|
470
|
+
return `$${params.length}`;
|
|
471
|
+
});
|
|
472
|
+
whereClauses.push(`(${rendered})`);
|
|
473
|
+
}
|
|
321
474
|
/**
|
|
322
475
|
* Resolve a dimension/measure/filter SQL expression that may reference a
|
|
323
476
|
* related table via dot notation (e.g. `account.industry`).
|
|
@@ -336,15 +489,17 @@ var NativeSQLStrategy = class {
|
|
|
336
489
|
* Returns the qualified SQL reference (e.g. `"account"."industry"`).
|
|
337
490
|
* Pure column references (no dot) are returned as-is.
|
|
338
491
|
*/
|
|
339
|
-
qualifyAndRegisterJoin(rawSql, parentTable, joins) {
|
|
492
|
+
qualifyAndRegisterJoin(rawSql, parentTable, joins, cube) {
|
|
340
493
|
if (!rawSql.includes(".")) return rawSql;
|
|
341
494
|
const [alias, ...rest] = rawSql.split(".");
|
|
342
495
|
if (!alias || rest.length === 0) return rawSql;
|
|
343
496
|
const column = rest.join(".");
|
|
344
497
|
if (!joins.has(alias)) {
|
|
498
|
+
const joinTable = cube?.joins?.[alias]?.name ?? alias;
|
|
499
|
+
const tableRef = joinTable === alias ? `"${alias}"` : `"${joinTable}" "${alias}"`;
|
|
345
500
|
joins.set(
|
|
346
501
|
alias,
|
|
347
|
-
`LEFT JOIN
|
|
502
|
+
`LEFT JOIN ${tableRef} ON "${parentTable}"."${alias}" = "${alias}"."id"`
|
|
348
503
|
);
|
|
349
504
|
}
|
|
350
505
|
return `"${alias}"."${column}"`;
|
|
@@ -383,12 +538,12 @@ var NativeSQLStrategy = class {
|
|
|
383
538
|
resolveDimensionSql(cube, member, parentTable, joins) {
|
|
384
539
|
const dim = this.lookupMember(cube, member, "dimension");
|
|
385
540
|
const raw = dim ? dim.sql : member.includes(".") ? member.split(".")[1] : member;
|
|
386
|
-
return this.qualifyAndRegisterJoin(raw, parentTable, joins);
|
|
541
|
+
return this.qualifyAndRegisterJoin(raw, parentTable, joins, cube);
|
|
387
542
|
}
|
|
388
543
|
resolveMeasureSql(cube, member, parentTable, joins) {
|
|
389
544
|
const measure = this.lookupMember(cube, member, "measure");
|
|
390
545
|
if (!measure) return `COUNT(*)`;
|
|
391
|
-
const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins);
|
|
546
|
+
const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
|
|
392
547
|
switch (measure.type) {
|
|
393
548
|
case "count":
|
|
394
549
|
return "COUNT(*)";
|
|
@@ -408,9 +563,9 @@ var NativeSQLStrategy = class {
|
|
|
408
563
|
}
|
|
409
564
|
resolveFieldSql(cube, member, parentTable, joins) {
|
|
410
565
|
const dim = this.lookupMember(cube, member, "dimension");
|
|
411
|
-
if (dim) return this.qualifyAndRegisterJoin(dim.sql, parentTable, joins);
|
|
566
|
+
if (dim) return this.qualifyAndRegisterJoin(dim.sql, parentTable, joins, cube);
|
|
412
567
|
const measure = this.lookupMember(cube, member, "measure");
|
|
413
|
-
if (measure) return this.qualifyAndRegisterJoin(measure.sql, parentTable, joins);
|
|
568
|
+
if (measure) return this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
|
|
414
569
|
const fieldName = member.includes(".") ? member.split(".")[1] : member;
|
|
415
570
|
return fieldName;
|
|
416
571
|
}
|
|
@@ -664,6 +819,309 @@ var ObjectQLStrategy = class {
|
|
|
664
819
|
}
|
|
665
820
|
};
|
|
666
821
|
|
|
822
|
+
// src/dataset-compiler.ts
|
|
823
|
+
var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set(["array_agg", "string_agg"]);
|
|
824
|
+
function aggregateToMetricType(m) {
|
|
825
|
+
if (UNSUPPORTED_AGGREGATES.has(m.aggregate)) {
|
|
826
|
+
throw new Error(
|
|
827
|
+
`[dataset-compiler] measure "${m.name}" uses aggregate "${m.aggregate}" which is not supported by the v1 dataset runtime (supported: count, sum, avg, min, max, count_distinct).`
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
return m.aggregate;
|
|
831
|
+
}
|
|
832
|
+
function dimensionType(d) {
|
|
833
|
+
switch (d.type) {
|
|
834
|
+
case "date":
|
|
835
|
+
return "time";
|
|
836
|
+
case "number":
|
|
837
|
+
return "number";
|
|
838
|
+
case "boolean":
|
|
839
|
+
return "boolean";
|
|
840
|
+
case "lookup":
|
|
841
|
+
return "string";
|
|
842
|
+
case "string":
|
|
843
|
+
return "string";
|
|
844
|
+
default:
|
|
845
|
+
return "string";
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
function relationshipPrefix(field) {
|
|
849
|
+
const idx = field.indexOf(".");
|
|
850
|
+
return idx > 0 ? field.slice(0, idx) : null;
|
|
851
|
+
}
|
|
852
|
+
function compileDataset(dataset, resolver) {
|
|
853
|
+
const include = dataset.include ?? [];
|
|
854
|
+
const allowedRelationships = new Set(include);
|
|
855
|
+
const joins = {};
|
|
856
|
+
for (const rel of include) {
|
|
857
|
+
let targetTable = rel;
|
|
858
|
+
if (resolver) {
|
|
859
|
+
const resolved = resolver(dataset.object, rel);
|
|
860
|
+
if (!resolved) {
|
|
861
|
+
throw new Error(
|
|
862
|
+
`[dataset-compiler] dataset "${dataset.name}" includes relationship "${rel}" which does not exist on object "${dataset.object}".`
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
targetTable = resolved;
|
|
866
|
+
}
|
|
867
|
+
joins[rel] = {
|
|
868
|
+
name: targetTable,
|
|
869
|
+
relationship: "many_to_one",
|
|
870
|
+
sql: `${dataset.object}.${rel} = ${rel}.id`
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
const assertDeclared = (field, ownerKind, ownerName) => {
|
|
874
|
+
const prefix = relationshipPrefix(field);
|
|
875
|
+
if (prefix && !allowedRelationships.has(prefix)) {
|
|
876
|
+
throw new Error(
|
|
877
|
+
`[dataset-compiler] ${ownerKind} "${ownerName}" references relationship "${prefix}" via "${field}", but "${prefix}" is not declared in the dataset's \`include\`. v1 only joins along declared relationships.`
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
const dimensions = {};
|
|
882
|
+
for (const d of dataset.dimensions) {
|
|
883
|
+
assertDeclared(d.field, "dimension", d.name);
|
|
884
|
+
const dim = {
|
|
885
|
+
name: d.name,
|
|
886
|
+
label: typeof d.label === "string" ? d.label : d.name,
|
|
887
|
+
type: dimensionType(d),
|
|
888
|
+
sql: d.field
|
|
889
|
+
};
|
|
890
|
+
if (dim.type === "time") {
|
|
891
|
+
dim.granularities = d.dateGranularity ? [d.dateGranularity] : ["day", "week", "month", "quarter", "year"];
|
|
892
|
+
}
|
|
893
|
+
dimensions[d.name] = dim;
|
|
894
|
+
}
|
|
895
|
+
const measures = {};
|
|
896
|
+
const derived = [];
|
|
897
|
+
const measureFilters = {};
|
|
898
|
+
for (const m of dataset.measures) {
|
|
899
|
+
if (m.derived) {
|
|
900
|
+
derived.push({ name: m.name, op: m.derived.op, of: m.derived.of });
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
if (m.field) assertDeclared(m.field, "measure", m.name);
|
|
904
|
+
const metric = {
|
|
905
|
+
name: m.name,
|
|
906
|
+
label: typeof m.label === "string" ? m.label : m.name,
|
|
907
|
+
type: aggregateToMetricType(m),
|
|
908
|
+
// `count` with no field aggregates over rows (*).
|
|
909
|
+
sql: m.field ?? "*"
|
|
910
|
+
};
|
|
911
|
+
if (typeof m.format === "string") metric.format = m.format;
|
|
912
|
+
measures[m.name] = metric;
|
|
913
|
+
if (m.filter) measureFilters[m.name] = m.filter;
|
|
914
|
+
}
|
|
915
|
+
const cube = {
|
|
916
|
+
name: dataset.name,
|
|
917
|
+
title: typeof dataset.label === "string" ? dataset.label : dataset.name,
|
|
918
|
+
sql: dataset.object,
|
|
919
|
+
measures,
|
|
920
|
+
dimensions,
|
|
921
|
+
public: false
|
|
922
|
+
};
|
|
923
|
+
if (Object.keys(joins).length > 0) cube.joins = joins;
|
|
924
|
+
return {
|
|
925
|
+
cube,
|
|
926
|
+
allowedRelationships,
|
|
927
|
+
derived,
|
|
928
|
+
filter: dataset.filter,
|
|
929
|
+
measureFilters
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// src/dataset-executor.ts
|
|
934
|
+
function combineFilters(a, b) {
|
|
935
|
+
if (a && b) return { $and: [a, b] };
|
|
936
|
+
return a ?? b;
|
|
937
|
+
}
|
|
938
|
+
function evaluateDerivedMeasures(rows, derived) {
|
|
939
|
+
if (derived.length === 0) return rows;
|
|
940
|
+
return rows.map((row) => {
|
|
941
|
+
const out = { ...row };
|
|
942
|
+
for (const d of derived) {
|
|
943
|
+
out[d.name] = computeDerived(d, out);
|
|
944
|
+
}
|
|
945
|
+
return out;
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
function num(v) {
|
|
949
|
+
if (v == null) return null;
|
|
950
|
+
const n = typeof v === "number" ? v : Number(v);
|
|
951
|
+
return Number.isFinite(n) ? n : null;
|
|
952
|
+
}
|
|
953
|
+
function computeDerived(d, row) {
|
|
954
|
+
const vals = d.of.map((name) => num(row[name]));
|
|
955
|
+
if (vals.some((v) => v === null)) return null;
|
|
956
|
+
const nums = vals;
|
|
957
|
+
switch (d.op) {
|
|
958
|
+
case "ratio": {
|
|
959
|
+
if (nums.length < 2 || nums[1] === 0) return null;
|
|
960
|
+
return nums[0] / nums[1];
|
|
961
|
+
}
|
|
962
|
+
case "difference":
|
|
963
|
+
return nums.slice(1).reduce((acc, v) => acc - v, nums[0]);
|
|
964
|
+
case "sum":
|
|
965
|
+
return nums.reduce((acc, v) => acc + v, 0);
|
|
966
|
+
case "product":
|
|
967
|
+
return nums.reduce((acc, v) => acc * v, 1);
|
|
968
|
+
default:
|
|
969
|
+
return null;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
function parseUTC(date) {
|
|
973
|
+
const ms = Date.parse(date.length === 10 ? `${date}T00:00:00Z` : date);
|
|
974
|
+
if (Number.isNaN(ms)) throw new Error(`[dataset-executor] invalid date in dateRange: "${date}"`);
|
|
975
|
+
return ms;
|
|
976
|
+
}
|
|
977
|
+
var DAY_MS = 864e5;
|
|
978
|
+
function toISODate(ms) {
|
|
979
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
980
|
+
}
|
|
981
|
+
function shiftYear(date, years) {
|
|
982
|
+
const d = new Date(parseUTC(date));
|
|
983
|
+
d.setUTCFullYear(d.getUTCFullYear() + years);
|
|
984
|
+
return toISODate(d.getTime());
|
|
985
|
+
}
|
|
986
|
+
function shiftRange(range, kind) {
|
|
987
|
+
const [start, end] = range;
|
|
988
|
+
if (kind === "previousYear") {
|
|
989
|
+
return [shiftYear(start, -1), shiftYear(end, -1)];
|
|
990
|
+
}
|
|
991
|
+
const startMs = parseUTC(start);
|
|
992
|
+
const endMs = parseUTC(end);
|
|
993
|
+
const lengthDays = Math.round((endMs - startMs) / DAY_MS) + 1;
|
|
994
|
+
const prevEndMs = startMs - DAY_MS;
|
|
995
|
+
const prevStartMs = prevEndMs - (lengthDays - 1) * DAY_MS;
|
|
996
|
+
return [toISODate(prevStartMs), toISODate(prevEndMs)];
|
|
997
|
+
}
|
|
998
|
+
var DatasetExecutor = class {
|
|
999
|
+
constructor(service) {
|
|
1000
|
+
this.service = service;
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Execute a dataset selection and return the shaped rows (+ field metadata).
|
|
1004
|
+
*
|
|
1005
|
+
* @param context - The request's ExecutionContext, threaded into every
|
|
1006
|
+
* underlying `IAnalyticsService.query` so the tenant/RLS read scope is
|
|
1007
|
+
* applied per request (ADR-0021 D-C).
|
|
1008
|
+
*/
|
|
1009
|
+
async execute(compiled, selection, context) {
|
|
1010
|
+
const derivedByName = new Map(compiled.derived.map((d) => [d.name, d]));
|
|
1011
|
+
const selectedDerived = selection.measures.map((m) => derivedByName.get(m)).filter((d) => !!d);
|
|
1012
|
+
const baseMeasures = /* @__PURE__ */ new Set();
|
|
1013
|
+
for (const m of selection.measures) {
|
|
1014
|
+
if (!derivedByName.has(m)) baseMeasures.add(m);
|
|
1015
|
+
}
|
|
1016
|
+
for (const d of selectedDerived) {
|
|
1017
|
+
for (const dep of d.of) baseMeasures.add(dep);
|
|
1018
|
+
}
|
|
1019
|
+
const unfiltered = [];
|
|
1020
|
+
const filtered = [];
|
|
1021
|
+
for (const m of baseMeasures) {
|
|
1022
|
+
(compiled.measureFilters[m] ? filtered : unfiltered).push(m);
|
|
1023
|
+
}
|
|
1024
|
+
const baseFilter = combineFilters(compiled.filter, selection.runtimeFilter);
|
|
1025
|
+
const dimensions = selection.dimensions ?? [];
|
|
1026
|
+
let result;
|
|
1027
|
+
if (unfiltered.length > 0 || filtered.length === 0) {
|
|
1028
|
+
result = await this.service.query(this.buildQuery(compiled, {
|
|
1029
|
+
measures: unfiltered,
|
|
1030
|
+
dimensions,
|
|
1031
|
+
where: baseFilter,
|
|
1032
|
+
selection
|
|
1033
|
+
}), context);
|
|
1034
|
+
} else {
|
|
1035
|
+
result = { rows: [], fields: [] };
|
|
1036
|
+
}
|
|
1037
|
+
for (const m of filtered) {
|
|
1038
|
+
const mFilter = combineFilters(baseFilter, compiled.measureFilters[m]);
|
|
1039
|
+
const sub = await this.service.query(this.buildQuery(compiled, {
|
|
1040
|
+
measures: [m],
|
|
1041
|
+
dimensions,
|
|
1042
|
+
where: mFilter,
|
|
1043
|
+
selection
|
|
1044
|
+
}), context);
|
|
1045
|
+
result.rows = mergeByDimensions(result.rows, sub.rows, dimensions, [m]);
|
|
1046
|
+
result.fields.push({ name: m, type: "number" });
|
|
1047
|
+
}
|
|
1048
|
+
if (selection.compareTo) {
|
|
1049
|
+
const compareRows = await this.runCompare(compiled, selection, [...baseMeasures], dimensions, baseFilter, context);
|
|
1050
|
+
result.rows = mergeByDimensions(
|
|
1051
|
+
result.rows,
|
|
1052
|
+
compareRows,
|
|
1053
|
+
dimensions,
|
|
1054
|
+
[...baseMeasures].map((m) => `${m}__compare`)
|
|
1055
|
+
);
|
|
1056
|
+
for (const m of baseMeasures) result.fields.push({ name: `${m}__compare`, type: "number" });
|
|
1057
|
+
}
|
|
1058
|
+
result.rows = evaluateDerivedMeasures(result.rows, selectedDerived);
|
|
1059
|
+
for (const d of selectedDerived) result.fields.push({ name: d.name, type: "number" });
|
|
1060
|
+
return result;
|
|
1061
|
+
}
|
|
1062
|
+
buildQuery(compiled, opts) {
|
|
1063
|
+
const q = {
|
|
1064
|
+
cube: compiled.cube.name,
|
|
1065
|
+
measures: opts.measures,
|
|
1066
|
+
dimensions: opts.dimensions,
|
|
1067
|
+
timezone: opts.selection.timezone ?? "UTC"
|
|
1068
|
+
};
|
|
1069
|
+
if (opts.where) q.where = opts.where;
|
|
1070
|
+
if (opts.selection.timeDimensions) q.timeDimensions = opts.selection.timeDimensions;
|
|
1071
|
+
if (opts.selection.order) q.order = opts.selection.order;
|
|
1072
|
+
if (opts.selection.limit != null) q.limit = opts.selection.limit;
|
|
1073
|
+
if (opts.selection.offset != null) q.offset = opts.selection.offset;
|
|
1074
|
+
return q;
|
|
1075
|
+
}
|
|
1076
|
+
async runCompare(compiled, selection, measures, dimensions, baseFilter, context) {
|
|
1077
|
+
const cmp = selection.compareTo;
|
|
1078
|
+
const td = (selection.timeDimensions ?? []).find((t) => t.dimension === cmp.dimension);
|
|
1079
|
+
if (!td || !td.dateRange) {
|
|
1080
|
+
throw new Error(
|
|
1081
|
+
`[dataset-executor] compareTo requires a timeDimension "${cmp.dimension}" with a dateRange.`
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
const range = Array.isArray(td.dateRange) ? [td.dateRange[0], td.dateRange[1] ?? td.dateRange[0]] : [td.dateRange, td.dateRange];
|
|
1085
|
+
const shifted = shiftRange(range, cmp.kind);
|
|
1086
|
+
const shiftedTd = (selection.timeDimensions ?? []).map(
|
|
1087
|
+
(t) => t.dimension === cmp.dimension ? { ...t, dateRange: shifted } : t
|
|
1088
|
+
);
|
|
1089
|
+
const sub = await this.service.query({
|
|
1090
|
+
cube: compiled.cube.name,
|
|
1091
|
+
measures,
|
|
1092
|
+
dimensions,
|
|
1093
|
+
where: baseFilter,
|
|
1094
|
+
timeDimensions: shiftedTd,
|
|
1095
|
+
timezone: selection.timezone ?? "UTC"
|
|
1096
|
+
}, context);
|
|
1097
|
+
return sub.rows.map((row) => {
|
|
1098
|
+
const out = {};
|
|
1099
|
+
for (const dim of dimensions) out[dim] = row[dim];
|
|
1100
|
+
for (const m of measures) out[`${m}__compare`] = row[m];
|
|
1101
|
+
return out;
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1105
|
+
function mergeByDimensions(base, extra, dimensions, valueColumns) {
|
|
1106
|
+
const keyOf = (row) => dimensions.map((d) => String(row[d] ?? "")).join("");
|
|
1107
|
+
const index = /* @__PURE__ */ new Map();
|
|
1108
|
+
for (const row of base) index.set(keyOf(row), row);
|
|
1109
|
+
for (const row of extra) {
|
|
1110
|
+
const key = keyOf(row);
|
|
1111
|
+
const target = index.get(key);
|
|
1112
|
+
if (target) {
|
|
1113
|
+
for (const c of valueColumns) target[c] = row[c];
|
|
1114
|
+
} else {
|
|
1115
|
+
const fresh = {};
|
|
1116
|
+
for (const d of dimensions) fresh[d] = row[d];
|
|
1117
|
+
for (const c of valueColumns) fresh[c] = row[c];
|
|
1118
|
+
index.set(key, fresh);
|
|
1119
|
+
base.push(fresh);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
return base;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
667
1125
|
// src/analytics-service.ts
|
|
668
1126
|
var DEFAULT_CAPABILITIES = {
|
|
669
1127
|
nativeSql: false,
|
|
@@ -672,17 +1130,33 @@ var DEFAULT_CAPABILITIES = {
|
|
|
672
1130
|
};
|
|
673
1131
|
var AnalyticsService = class {
|
|
674
1132
|
constructor(config = {}) {
|
|
1133
|
+
/** Compiled datasets by name — feeds the join allowlist (D-C) and queryDataset. */
|
|
1134
|
+
this.datasetRegistry = /* @__PURE__ */ new Map();
|
|
675
1135
|
this.logger = config.logger || (0, import_core.createLogger)({ level: "info", format: "pretty" });
|
|
676
1136
|
this.cubeRegistry = new CubeRegistry();
|
|
677
1137
|
if (config.cubes) {
|
|
678
1138
|
this.cubeRegistry.registerAll(config.cubes);
|
|
679
1139
|
}
|
|
680
|
-
this.
|
|
1140
|
+
this.readScopeProvider = config.getReadScope;
|
|
1141
|
+
this.relationshipResolver = config.relationshipResolver;
|
|
1142
|
+
if (config.datasets) {
|
|
1143
|
+
for (const ds of config.datasets) {
|
|
1144
|
+
try {
|
|
1145
|
+
this.registerDataset(ds);
|
|
1146
|
+
} catch (e) {
|
|
1147
|
+
this.logger?.warn?.(`[Analytics] Failed to register dataset "${ds?.name}": ${String(e?.message ?? e)}`);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
this.baseCtx = {
|
|
681
1152
|
getCube: (name) => this.cubeRegistry.get(name),
|
|
682
1153
|
queryCapabilities: config.queryCapabilities || (() => DEFAULT_CAPABILITIES),
|
|
683
1154
|
executeRawSql: config.executeRawSql,
|
|
684
1155
|
executeAggregate: config.executeAggregate,
|
|
685
|
-
fallbackService: config.fallbackService
|
|
1156
|
+
fallbackService: config.fallbackService,
|
|
1157
|
+
// Prefer a compiled dataset's declared relationships (D-C join allowlist);
|
|
1158
|
+
// fall back to any explicitly-configured provider for legacy cubes.
|
|
1159
|
+
getAllowedRelationships: (cubeName) => this.datasetRegistry.get(cubeName)?.allowedRelationships ?? config.getAllowedRelationships?.(cubeName)
|
|
686
1160
|
};
|
|
687
1161
|
const builtIn = [
|
|
688
1162
|
new NativeSQLStrategy(),
|
|
@@ -697,17 +1171,99 @@ var AnalyticsService = class {
|
|
|
697
1171
|
`[Analytics] Initialized with ${this.cubeRegistry.size} cubes, ${this.strategies.length} strategies: ${this.strategies.map((s) => s.name).join(" \u2192 ")}`
|
|
698
1172
|
);
|
|
699
1173
|
}
|
|
1174
|
+
/**
|
|
1175
|
+
* Build a per-call StrategyContext that binds the read-scope provider to the
|
|
1176
|
+
* current request's ExecutionContext (ADR-0021 D-C). The strategy then sees a
|
|
1177
|
+
* `getReadScope(objectName)` that already knows the active tenant.
|
|
1178
|
+
*/
|
|
1179
|
+
async callCtx(query, context) {
|
|
1180
|
+
if (!this.readScopeProvider) return this.baseCtx;
|
|
1181
|
+
const scopes = await this.resolveReadScopes(query, context);
|
|
1182
|
+
return {
|
|
1183
|
+
...this.baseCtx,
|
|
1184
|
+
getReadScope: (objectName) => scopes.get(objectName) ?? null
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
|
|
1189
|
+
* AND every joined object of the query's cube, keyed by object name. This is
|
|
1190
|
+
* the async pre-pass that lets the synchronous strategy enforce scoping even
|
|
1191
|
+
* when the provider (security `getReadFilter`) resolves asynchronously.
|
|
1192
|
+
*
|
|
1193
|
+
* The object set is `cube.sql` (base) plus every `cube.joins[*].name` — a
|
|
1194
|
+
* SUPERSET of what the strategy actually scans (the strategy only joins along
|
|
1195
|
+
* declared relationships), so no scanned object is ever left unscoped.
|
|
1196
|
+
*
|
|
1197
|
+
* Fail-closed: if the provider throws for an object, the whole query is
|
|
1198
|
+
* rejected rather than emitting SQL with that object unscoped.
|
|
1199
|
+
*/
|
|
1200
|
+
async resolveReadScopes(query, context) {
|
|
1201
|
+
const map = /* @__PURE__ */ new Map();
|
|
1202
|
+
const provider = this.readScopeProvider;
|
|
1203
|
+
if (!provider || !query.cube) return map;
|
|
1204
|
+
const cube = this.cubeRegistry.get(query.cube);
|
|
1205
|
+
if (!cube) return map;
|
|
1206
|
+
const objects = /* @__PURE__ */ new Set();
|
|
1207
|
+
if (typeof cube.sql === "string" && cube.sql.trim()) {
|
|
1208
|
+
objects.add(cube.sql.trim());
|
|
1209
|
+
}
|
|
1210
|
+
const joins = cube.joins;
|
|
1211
|
+
if (joins) {
|
|
1212
|
+
for (const [alias, j] of Object.entries(joins)) {
|
|
1213
|
+
objects.add(j?.name ?? alias);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
for (const object of objects) {
|
|
1217
|
+
let filter;
|
|
1218
|
+
try {
|
|
1219
|
+
filter = await provider(object, context);
|
|
1220
|
+
} catch (e) {
|
|
1221
|
+
this.logger.error?.(
|
|
1222
|
+
`[Analytics] read-scope resolution failed for object "${object}" \u2014 rejecting query (fail-closed, ADR-0021 D-C)`,
|
|
1223
|
+
e instanceof Error ? e : new Error(String(e))
|
|
1224
|
+
);
|
|
1225
|
+
throw new Error(
|
|
1226
|
+
`[Analytics] read-scope resolution failed for "${object}"; query denied (fail-closed).`
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
if (filter != null) map.set(object, filter);
|
|
1230
|
+
}
|
|
1231
|
+
return map;
|
|
1232
|
+
}
|
|
700
1233
|
/**
|
|
701
1234
|
* Execute an analytical query by delegating to the first capable strategy.
|
|
702
1235
|
*/
|
|
703
|
-
async query(query) {
|
|
1236
|
+
async query(query, context) {
|
|
704
1237
|
if (!query.cube) {
|
|
705
1238
|
throw new Error("Cube name is required in analytics query");
|
|
706
1239
|
}
|
|
707
1240
|
this.ensureCube(query);
|
|
708
|
-
const
|
|
1241
|
+
const ctx = await this.callCtx(query, context);
|
|
1242
|
+
const strategy = this.resolveStrategy(query, ctx);
|
|
709
1243
|
this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
710
|
-
return strategy.execute(query,
|
|
1244
|
+
return strategy.execute(query, ctx);
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Compile a `dataset` (ADR-0021) and register its Cube + join allowlist so it
|
|
1248
|
+
* can be queried by name. Idempotent (re-registering overwrites). Returns the
|
|
1249
|
+
* compiled dataset.
|
|
1250
|
+
*/
|
|
1251
|
+
registerDataset(dataset) {
|
|
1252
|
+
const compiled = compileDataset(dataset, this.relationshipResolver);
|
|
1253
|
+
this.cubeRegistry.register(compiled.cube);
|
|
1254
|
+
this.datasetRegistry.set(dataset.name, compiled);
|
|
1255
|
+
return compiled;
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Execute a semantic-layer dataset (ADR-0021). Compiles the dataset (saved or
|
|
1259
|
+
* inline draft — Studio preview), registers its Cube + join allowlist, then
|
|
1260
|
+
* runs the selection through the `DatasetExecutor` with the request context so
|
|
1261
|
+
* tenant/RLS scoping (D-C) is applied. See {@link IAnalyticsService.queryDataset}.
|
|
1262
|
+
*/
|
|
1263
|
+
async queryDataset(dataset, selection, context) {
|
|
1264
|
+
const compiled = this.registerDataset(dataset);
|
|
1265
|
+
this.logger.debug(`[Analytics] queryDataset "${dataset.name}" (object=${dataset.object}, include=${(dataset.include ?? []).join(",") || "\u2014"})`);
|
|
1266
|
+
return new DatasetExecutor(this).execute(compiled, selection, context);
|
|
711
1267
|
}
|
|
712
1268
|
/**
|
|
713
1269
|
* Get cube metadata for discovery.
|
|
@@ -732,14 +1288,15 @@ var AnalyticsService = class {
|
|
|
732
1288
|
/**
|
|
733
1289
|
* Generate SQL for a query without executing it (dry-run).
|
|
734
1290
|
*/
|
|
735
|
-
async generateSql(query) {
|
|
1291
|
+
async generateSql(query, context) {
|
|
736
1292
|
if (!query.cube) {
|
|
737
1293
|
throw new Error("Cube name is required for SQL generation");
|
|
738
1294
|
}
|
|
739
1295
|
this.ensureCube(query);
|
|
740
|
-
const
|
|
1296
|
+
const ctx = await this.callCtx(query, context);
|
|
1297
|
+
const strategy = this.resolveStrategy(query, ctx);
|
|
741
1298
|
this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
742
|
-
return strategy.generateSql(query,
|
|
1299
|
+
return strategy.generateSql(query, ctx);
|
|
743
1300
|
}
|
|
744
1301
|
// ── Internal ─────────────────────────────────────────────────────
|
|
745
1302
|
/**
|
|
@@ -832,9 +1389,9 @@ var AnalyticsService = class {
|
|
|
832
1389
|
/**
|
|
833
1390
|
* Walk the strategy chain and return the first strategy that can handle the query.
|
|
834
1391
|
*/
|
|
835
|
-
resolveStrategy(query) {
|
|
1392
|
+
resolveStrategy(query, ctx) {
|
|
836
1393
|
for (const strategy of this.strategies) {
|
|
837
|
-
if (strategy.canHandle(query,
|
|
1394
|
+
if (strategy.canHandle(query, ctx)) {
|
|
838
1395
|
return strategy;
|
|
839
1396
|
}
|
|
840
1397
|
}
|
|
@@ -974,14 +1531,56 @@ var AnalyticsServicePlugin = class {
|
|
|
974
1531
|
objectqlAggregate: !!executeAggregate,
|
|
975
1532
|
inMemory: false
|
|
976
1533
|
}));
|
|
1534
|
+
let getReadScope = this.options.getReadScope;
|
|
1535
|
+
let autoBridgedReadScope = false;
|
|
1536
|
+
if (!getReadScope) {
|
|
1537
|
+
const trySecurity = () => {
|
|
1538
|
+
try {
|
|
1539
|
+
const svc = ctx.getService("security");
|
|
1540
|
+
return svc && typeof svc.getReadFilter === "function" ? svc : void 0;
|
|
1541
|
+
} catch {
|
|
1542
|
+
return void 0;
|
|
1543
|
+
}
|
|
1544
|
+
};
|
|
1545
|
+
if (trySecurity()) {
|
|
1546
|
+
getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context);
|
|
1547
|
+
autoBridgedReadScope = true;
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
const relationshipResolver = (baseObject, relationshipName) => {
|
|
1551
|
+
const engine = (() => {
|
|
1552
|
+
try {
|
|
1553
|
+
const svc = ctx.getService("data");
|
|
1554
|
+
return svc && typeof svc.getObject === "function" ? svc : void 0;
|
|
1555
|
+
} catch {
|
|
1556
|
+
return void 0;
|
|
1557
|
+
}
|
|
1558
|
+
})();
|
|
1559
|
+
const obj = engine?.getObject?.(baseObject);
|
|
1560
|
+
const field = obj?.fields?.[relationshipName];
|
|
1561
|
+
if (field && (field.type === "lookup" || field.type === "master_detail") && field.reference) {
|
|
1562
|
+
return field.reference;
|
|
1563
|
+
}
|
|
1564
|
+
return engine ? void 0 : relationshipName;
|
|
1565
|
+
};
|
|
977
1566
|
const config = {
|
|
978
1567
|
cubes: this.options.cubes,
|
|
979
1568
|
logger: ctx.logger,
|
|
980
1569
|
queryCapabilities,
|
|
981
1570
|
executeRawSql,
|
|
982
1571
|
executeAggregate,
|
|
983
|
-
fallbackService
|
|
1572
|
+
fallbackService,
|
|
1573
|
+
getReadScope,
|
|
1574
|
+
getAllowedRelationships: this.options.getAllowedRelationships,
|
|
1575
|
+
relationshipResolver
|
|
984
1576
|
};
|
|
1577
|
+
if (autoBridgedReadScope) {
|
|
1578
|
+
ctx.logger.info('[Analytics] Auto-bridged getReadScope \u2192 "security" service (getReadFilter)');
|
|
1579
|
+
} else if (!getReadScope) {
|
|
1580
|
+
ctx.logger.warn(
|
|
1581
|
+
'[Analytics] No getReadScope configured and no "security" service with getReadFilter found \u2014 the raw-SQL analytics path will NOT enforce tenant/RLS scoping on joined objects (ADR-0021 D-C). Supply getReadScope or register a security service in multi-tenant deployments.'
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
985
1584
|
if (autoBridged) {
|
|
986
1585
|
ctx.logger.info('[Analytics] Auto-bridged executeAggregate \u2192 "data" service (IDataEngine)');
|
|
987
1586
|
}
|
|
@@ -1017,7 +1616,14 @@ var AnalyticsServicePlugin = class {
|
|
|
1017
1616
|
AnalyticsService,
|
|
1018
1617
|
AnalyticsServicePlugin,
|
|
1019
1618
|
CubeRegistry,
|
|
1619
|
+
DatasetExecutor,
|
|
1020
1620
|
NativeSQLStrategy,
|
|
1021
|
-
ObjectQLStrategy
|
|
1621
|
+
ObjectQLStrategy,
|
|
1622
|
+
combineFilters,
|
|
1623
|
+
compileDataset,
|
|
1624
|
+
compileScopedFilterToSql,
|
|
1625
|
+
evaluateDerivedMeasures,
|
|
1626
|
+
mergeByDimensions,
|
|
1627
|
+
shiftRange
|
|
1022
1628
|
});
|
|
1023
1629
|
//# sourceMappingURL=index.cjs.map
|