@objectstack/service-analytics 7.9.0 → 8.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 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 "${alias}" ON "${parentTable}"."${alias}" = "${alias}"."id"`
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.strategyCtx = {
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,52 @@ 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
+ callCtx(context) {
1180
+ if (!this.readScopeProvider) return this.baseCtx;
1181
+ return {
1182
+ ...this.baseCtx,
1183
+ getReadScope: (objectName) => this.readScopeProvider(objectName, context)
1184
+ };
1185
+ }
700
1186
  /**
701
1187
  * Execute an analytical query by delegating to the first capable strategy.
702
1188
  */
703
- async query(query) {
1189
+ async query(query, context) {
704
1190
  if (!query.cube) {
705
1191
  throw new Error("Cube name is required in analytics query");
706
1192
  }
707
1193
  this.ensureCube(query);
708
- const strategy = this.resolveStrategy(query);
1194
+ const ctx = this.callCtx(context);
1195
+ const strategy = this.resolveStrategy(query, ctx);
709
1196
  this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
710
- return strategy.execute(query, this.strategyCtx);
1197
+ return strategy.execute(query, ctx);
1198
+ }
1199
+ /**
1200
+ * Compile a `dataset` (ADR-0021) and register its Cube + join allowlist so it
1201
+ * can be queried by name. Idempotent (re-registering overwrites). Returns the
1202
+ * compiled dataset.
1203
+ */
1204
+ registerDataset(dataset) {
1205
+ const compiled = compileDataset(dataset, this.relationshipResolver);
1206
+ this.cubeRegistry.register(compiled.cube);
1207
+ this.datasetRegistry.set(dataset.name, compiled);
1208
+ return compiled;
1209
+ }
1210
+ /**
1211
+ * Execute a semantic-layer dataset (ADR-0021). Compiles the dataset (saved or
1212
+ * inline draft — Studio preview), registers its Cube + join allowlist, then
1213
+ * runs the selection through the `DatasetExecutor` with the request context so
1214
+ * tenant/RLS scoping (D-C) is applied. See {@link IAnalyticsService.queryDataset}.
1215
+ */
1216
+ async queryDataset(dataset, selection, context) {
1217
+ const compiled = this.registerDataset(dataset);
1218
+ this.logger.debug(`[Analytics] queryDataset "${dataset.name}" (object=${dataset.object}, include=${(dataset.include ?? []).join(",") || "\u2014"})`);
1219
+ return new DatasetExecutor(this).execute(compiled, selection, context);
711
1220
  }
712
1221
  /**
713
1222
  * Get cube metadata for discovery.
@@ -732,14 +1241,15 @@ var AnalyticsService = class {
732
1241
  /**
733
1242
  * Generate SQL for a query without executing it (dry-run).
734
1243
  */
735
- async generateSql(query) {
1244
+ async generateSql(query, context) {
736
1245
  if (!query.cube) {
737
1246
  throw new Error("Cube name is required for SQL generation");
738
1247
  }
739
1248
  this.ensureCube(query);
740
- const strategy = this.resolveStrategy(query);
1249
+ const ctx = this.callCtx(context);
1250
+ const strategy = this.resolveStrategy(query, ctx);
741
1251
  this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" \u2192 ${strategy.name}`);
742
- return strategy.generateSql(query, this.strategyCtx);
1252
+ return strategy.generateSql(query, ctx);
743
1253
  }
744
1254
  // ── Internal ─────────────────────────────────────────────────────
745
1255
  /**
@@ -832,9 +1342,9 @@ var AnalyticsService = class {
832
1342
  /**
833
1343
  * Walk the strategy chain and return the first strategy that can handle the query.
834
1344
  */
835
- resolveStrategy(query) {
1345
+ resolveStrategy(query, ctx) {
836
1346
  for (const strategy of this.strategies) {
837
- if (strategy.canHandle(query, this.strategyCtx)) {
1347
+ if (strategy.canHandle(query, ctx)) {
838
1348
  return strategy;
839
1349
  }
840
1350
  }
@@ -974,14 +1484,56 @@ var AnalyticsServicePlugin = class {
974
1484
  objectqlAggregate: !!executeAggregate,
975
1485
  inMemory: false
976
1486
  }));
1487
+ let getReadScope = this.options.getReadScope;
1488
+ let autoBridgedReadScope = false;
1489
+ if (!getReadScope) {
1490
+ const trySecurity = () => {
1491
+ try {
1492
+ const svc = ctx.getService("security");
1493
+ return svc && typeof svc.getReadFilter === "function" ? svc : void 0;
1494
+ } catch {
1495
+ return void 0;
1496
+ }
1497
+ };
1498
+ if (trySecurity()) {
1499
+ getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context);
1500
+ autoBridgedReadScope = true;
1501
+ }
1502
+ }
1503
+ const relationshipResolver = (baseObject, relationshipName) => {
1504
+ const engine = (() => {
1505
+ try {
1506
+ const svc = ctx.getService("data");
1507
+ return svc && typeof svc.getObject === "function" ? svc : void 0;
1508
+ } catch {
1509
+ return void 0;
1510
+ }
1511
+ })();
1512
+ const obj = engine?.getObject?.(baseObject);
1513
+ const field = obj?.fields?.[relationshipName];
1514
+ if (field && (field.type === "lookup" || field.type === "master_detail") && field.reference) {
1515
+ return field.reference;
1516
+ }
1517
+ return engine ? void 0 : relationshipName;
1518
+ };
977
1519
  const config = {
978
1520
  cubes: this.options.cubes,
979
1521
  logger: ctx.logger,
980
1522
  queryCapabilities,
981
1523
  executeRawSql,
982
1524
  executeAggregate,
983
- fallbackService
1525
+ fallbackService,
1526
+ getReadScope,
1527
+ getAllowedRelationships: this.options.getAllowedRelationships,
1528
+ relationshipResolver
984
1529
  };
1530
+ if (autoBridgedReadScope) {
1531
+ ctx.logger.info('[Analytics] Auto-bridged getReadScope \u2192 "security" service (getReadFilter)');
1532
+ } else if (!getReadScope) {
1533
+ ctx.logger.warn(
1534
+ '[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.'
1535
+ );
1536
+ }
985
1537
  if (autoBridged) {
986
1538
  ctx.logger.info('[Analytics] Auto-bridged executeAggregate \u2192 "data" service (IDataEngine)');
987
1539
  }
@@ -1017,7 +1569,14 @@ var AnalyticsServicePlugin = class {
1017
1569
  AnalyticsService,
1018
1570
  AnalyticsServicePlugin,
1019
1571
  CubeRegistry,
1572
+ DatasetExecutor,
1020
1573
  NativeSQLStrategy,
1021
- ObjectQLStrategy
1574
+ ObjectQLStrategy,
1575
+ combineFilters,
1576
+ compileDataset,
1577
+ compileScopedFilterToSql,
1578
+ evaluateDerivedMeasures,
1579
+ mergeByDimensions,
1580
+ shiftRange
1022
1581
  });
1023
1582
  //# sourceMappingURL=index.cjs.map