@objectstack/service-analytics 7.8.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 +578 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +165 -7
- package/dist/index.d.ts +165 -7
- package/dist/index.js +570 -18
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -202,6 +202,115 @@ function coerceFilterValueForSql(s) {
|
|
|
202
202
|
return s;
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
// src/read-scope-sql.ts
|
|
206
|
+
var IDENT = /^[a-z_][a-z0-9_]*$/i;
|
|
207
|
+
function quoteIdent(name, kind) {
|
|
208
|
+
if (typeof name !== "string" || !IDENT.test(name)) {
|
|
209
|
+
throw new Error(`[read-scope-sql] unsafe ${kind} identifier "${String(name)}" \u2014 refusing to build read scope (fail-closed).`);
|
|
210
|
+
}
|
|
211
|
+
return `"${name}"`;
|
|
212
|
+
}
|
|
213
|
+
function compileScopedFilterToSql(filter, alias) {
|
|
214
|
+
const quotedAlias = quoteIdent(alias, "alias");
|
|
215
|
+
const params = [];
|
|
216
|
+
const sql = compileNode(filter, quotedAlias, params);
|
|
217
|
+
return { sql, params };
|
|
218
|
+
}
|
|
219
|
+
function compileNode(node, qAlias, params) {
|
|
220
|
+
if (node === null || typeof node !== "object" || Array.isArray(node)) {
|
|
221
|
+
throw new Error("[read-scope-sql] read scope must be a filter object (fail-closed).");
|
|
222
|
+
}
|
|
223
|
+
const clauses = [];
|
|
224
|
+
for (const [key, value] of Object.entries(node)) {
|
|
225
|
+
if (key === "$and" || key === "$or") {
|
|
226
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
227
|
+
throw new Error(`[read-scope-sql] "${key}" requires a non-empty array (fail-closed).`);
|
|
228
|
+
}
|
|
229
|
+
const parts = value.map((child) => compileNode(child, qAlias, params)).filter((s) => s.length > 0);
|
|
230
|
+
if (parts.length === 0) continue;
|
|
231
|
+
const joiner = key === "$and" ? " AND " : " OR ";
|
|
232
|
+
clauses.push(`(${parts.join(joiner)})`);
|
|
233
|
+
} else if (key === "$not") {
|
|
234
|
+
const inner = compileNode(value, qAlias, params);
|
|
235
|
+
if (inner) clauses.push(`NOT (${inner})`);
|
|
236
|
+
} else if (key.startsWith("$")) {
|
|
237
|
+
throw new Error(`[read-scope-sql] unsupported top-level operator "${key}" (fail-closed).`);
|
|
238
|
+
} else {
|
|
239
|
+
clauses.push(compileField(key, value, qAlias, params));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return clauses.join(" AND ");
|
|
243
|
+
}
|
|
244
|
+
function compileField(field, value, qAlias, params) {
|
|
245
|
+
const col = `${qAlias}.${quoteIdent(field, "field")}`;
|
|
246
|
+
if (value === null) return `${col} IS NULL`;
|
|
247
|
+
if (typeof value !== "object" || value instanceof Date) {
|
|
248
|
+
params.push(value);
|
|
249
|
+
return `${col} = ?`;
|
|
250
|
+
}
|
|
251
|
+
if (Array.isArray(value)) {
|
|
252
|
+
throw new Error(`[read-scope-sql] bare array value for "${field}" \u2014 use { $in: [...] } (fail-closed).`);
|
|
253
|
+
}
|
|
254
|
+
const ops = value;
|
|
255
|
+
const keys = Object.keys(ops);
|
|
256
|
+
if (keys.length === 0 || keys.some((k) => !k.startsWith("$"))) {
|
|
257
|
+
throw new Error(`[read-scope-sql] "${field}" has a nested/relation value which is not supported in a read scope (fail-closed).`);
|
|
258
|
+
}
|
|
259
|
+
const parts = [];
|
|
260
|
+
for (const op of keys) {
|
|
261
|
+
parts.push(compileOperator(col, op, ops[op], field, params));
|
|
262
|
+
}
|
|
263
|
+
return parts.length === 1 ? parts[0] : `(${parts.join(" AND ")})`;
|
|
264
|
+
}
|
|
265
|
+
function bind(params, v) {
|
|
266
|
+
params.push(v);
|
|
267
|
+
return "?";
|
|
268
|
+
}
|
|
269
|
+
function compileOperator(col, op, val, field, params) {
|
|
270
|
+
switch (op) {
|
|
271
|
+
case "$eq":
|
|
272
|
+
return val === null ? `${col} IS NULL` : `${col} = ${bind(params, val)}`;
|
|
273
|
+
case "$ne":
|
|
274
|
+
return val === null ? `${col} IS NOT NULL` : `${col} <> ${bind(params, val)}`;
|
|
275
|
+
case "$gt":
|
|
276
|
+
return `${col} > ${bind(params, val)}`;
|
|
277
|
+
case "$gte":
|
|
278
|
+
return `${col} >= ${bind(params, val)}`;
|
|
279
|
+
case "$lt":
|
|
280
|
+
return `${col} < ${bind(params, val)}`;
|
|
281
|
+
case "$lte":
|
|
282
|
+
return `${col} <= ${bind(params, val)}`;
|
|
283
|
+
case "$in": {
|
|
284
|
+
if (!Array.isArray(val)) throw new Error(`[read-scope-sql] $in for "${field}" needs an array (fail-closed).`);
|
|
285
|
+
if (val.length === 0) return "1 = 0";
|
|
286
|
+
return `${col} IN (${val.map((v) => bind(params, v)).join(", ")})`;
|
|
287
|
+
}
|
|
288
|
+
case "$nin": {
|
|
289
|
+
if (!Array.isArray(val)) throw new Error(`[read-scope-sql] $nin for "${field}" needs an array (fail-closed).`);
|
|
290
|
+
if (val.length === 0) return "1 = 1";
|
|
291
|
+
return `${col} NOT IN (${val.map((v) => bind(params, v)).join(", ")})`;
|
|
292
|
+
}
|
|
293
|
+
case "$between": {
|
|
294
|
+
if (!Array.isArray(val) || val.length !== 2) throw new Error(`[read-scope-sql] $between for "${field}" needs [min,max] (fail-closed).`);
|
|
295
|
+
return `${col} BETWEEN ${bind(params, val[0])} AND ${bind(params, val[1])}`;
|
|
296
|
+
}
|
|
297
|
+
case "$contains":
|
|
298
|
+
return `${col} LIKE ${bind(params, `%${String(val)}%`)}`;
|
|
299
|
+
case "$notContains":
|
|
300
|
+
return `${col} NOT LIKE ${bind(params, `%${String(val)}%`)}`;
|
|
301
|
+
case "$startsWith":
|
|
302
|
+
return `${col} LIKE ${bind(params, `${String(val)}%`)}`;
|
|
303
|
+
case "$endsWith":
|
|
304
|
+
return `${col} LIKE ${bind(params, `%${String(val)}`)}`;
|
|
305
|
+
case "$null":
|
|
306
|
+
return val ? `${col} IS NULL` : `${col} IS NOT NULL`;
|
|
307
|
+
case "$exists":
|
|
308
|
+
return val ? `${col} IS NOT NULL` : `${col} IS NULL`;
|
|
309
|
+
default:
|
|
310
|
+
throw new Error(`[read-scope-sql] unsupported operator "${op}" on "${field}" (fail-closed).`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
205
314
|
// src/strategies/native-sql-strategy.ts
|
|
206
315
|
var NativeSQLStrategy = class {
|
|
207
316
|
constructor() {
|
|
@@ -265,6 +374,21 @@ var NativeSQLStrategy = class {
|
|
|
265
374
|
}
|
|
266
375
|
}
|
|
267
376
|
}
|
|
377
|
+
const allowed = ctx.getAllowedRelationships?.(query.cube);
|
|
378
|
+
if (allowed) {
|
|
379
|
+
for (const alias of joins.keys()) {
|
|
380
|
+
if (!allowed.has(alias)) {
|
|
381
|
+
throw new Error(
|
|
382
|
+
`[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\`.`
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
this.applyReadScope(this.extractObjectName(cube), tableName, ctx, whereClauses, params);
|
|
388
|
+
for (const alias of joins.keys()) {
|
|
389
|
+
const joinedObject = cube.joins?.[alias]?.name ?? alias;
|
|
390
|
+
this.applyReadScope(joinedObject, alias, ctx, whereClauses, params);
|
|
391
|
+
}
|
|
268
392
|
let sql = `SELECT ${selectClauses.join(", ")} FROM "${tableName}"`;
|
|
269
393
|
if (joins.size > 0) {
|
|
270
394
|
sql += " " + Array.from(joins.values()).join(" ");
|
|
@@ -288,6 +412,28 @@ var NativeSQLStrategy = class {
|
|
|
288
412
|
return { sql, params };
|
|
289
413
|
}
|
|
290
414
|
// ── Helpers ──────────────────────────────────────────────────────
|
|
415
|
+
/**
|
|
416
|
+
* ADR-0021 D-C — inject an object's read scope (tenant + RLS predicate) into
|
|
417
|
+
* the WHERE clause. The scope is a canonical `FilterCondition` (what the
|
|
418
|
+
* RLSCompiler emits); `compileScopedFilterToSql` turns it into alias-qualified,
|
|
419
|
+
* parameterized SQL (fail-closed — it throws rather than drop a predicate).
|
|
420
|
+
* The `?` placeholders are then renumbered into the strategy's `$N` scheme.
|
|
421
|
+
* No-op when the runtime provides no scope hook (the caller is then
|
|
422
|
+
* responsible for isolation — see contract note).
|
|
423
|
+
*/
|
|
424
|
+
applyReadScope(objectName, alias, ctx, whereClauses, params) {
|
|
425
|
+
if (typeof ctx.getReadScope !== "function") return;
|
|
426
|
+
const filter = ctx.getReadScope(objectName);
|
|
427
|
+
if (filter === void 0 || filter === null) return;
|
|
428
|
+
const { sql, params: scopeParams } = compileScopedFilterToSql(filter, alias);
|
|
429
|
+
if (!sql) return;
|
|
430
|
+
let i = 0;
|
|
431
|
+
const rendered = sql.replace(/\?/g, () => {
|
|
432
|
+
params.push(scopeParams[i++]);
|
|
433
|
+
return `$${params.length}`;
|
|
434
|
+
});
|
|
435
|
+
whereClauses.push(`(${rendered})`);
|
|
436
|
+
}
|
|
291
437
|
/**
|
|
292
438
|
* Resolve a dimension/measure/filter SQL expression that may reference a
|
|
293
439
|
* related table via dot notation (e.g. `account.industry`).
|
|
@@ -306,15 +452,17 @@ var NativeSQLStrategy = class {
|
|
|
306
452
|
* Returns the qualified SQL reference (e.g. `"account"."industry"`).
|
|
307
453
|
* Pure column references (no dot) are returned as-is.
|
|
308
454
|
*/
|
|
309
|
-
qualifyAndRegisterJoin(rawSql, parentTable, joins) {
|
|
455
|
+
qualifyAndRegisterJoin(rawSql, parentTable, joins, cube) {
|
|
310
456
|
if (!rawSql.includes(".")) return rawSql;
|
|
311
457
|
const [alias, ...rest] = rawSql.split(".");
|
|
312
458
|
if (!alias || rest.length === 0) return rawSql;
|
|
313
459
|
const column = rest.join(".");
|
|
314
460
|
if (!joins.has(alias)) {
|
|
461
|
+
const joinTable = cube?.joins?.[alias]?.name ?? alias;
|
|
462
|
+
const tableRef = joinTable === alias ? `"${alias}"` : `"${joinTable}" "${alias}"`;
|
|
315
463
|
joins.set(
|
|
316
464
|
alias,
|
|
317
|
-
`LEFT JOIN
|
|
465
|
+
`LEFT JOIN ${tableRef} ON "${parentTable}"."${alias}" = "${alias}"."id"`
|
|
318
466
|
);
|
|
319
467
|
}
|
|
320
468
|
return `"${alias}"."${column}"`;
|
|
@@ -353,12 +501,12 @@ var NativeSQLStrategy = class {
|
|
|
353
501
|
resolveDimensionSql(cube, member, parentTable, joins) {
|
|
354
502
|
const dim = this.lookupMember(cube, member, "dimension");
|
|
355
503
|
const raw = dim ? dim.sql : member.includes(".") ? member.split(".")[1] : member;
|
|
356
|
-
return this.qualifyAndRegisterJoin(raw, parentTable, joins);
|
|
504
|
+
return this.qualifyAndRegisterJoin(raw, parentTable, joins, cube);
|
|
357
505
|
}
|
|
358
506
|
resolveMeasureSql(cube, member, parentTable, joins) {
|
|
359
507
|
const measure = this.lookupMember(cube, member, "measure");
|
|
360
508
|
if (!measure) return `COUNT(*)`;
|
|
361
|
-
const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins);
|
|
509
|
+
const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
|
|
362
510
|
switch (measure.type) {
|
|
363
511
|
case "count":
|
|
364
512
|
return "COUNT(*)";
|
|
@@ -378,9 +526,9 @@ var NativeSQLStrategy = class {
|
|
|
378
526
|
}
|
|
379
527
|
resolveFieldSql(cube, member, parentTable, joins) {
|
|
380
528
|
const dim = this.lookupMember(cube, member, "dimension");
|
|
381
|
-
if (dim) return this.qualifyAndRegisterJoin(dim.sql, parentTable, joins);
|
|
529
|
+
if (dim) return this.qualifyAndRegisterJoin(dim.sql, parentTable, joins, cube);
|
|
382
530
|
const measure = this.lookupMember(cube, member, "measure");
|
|
383
|
-
if (measure) return this.qualifyAndRegisterJoin(measure.sql, parentTable, joins);
|
|
531
|
+
if (measure) return this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
|
|
384
532
|
const fieldName = member.includes(".") ? member.split(".")[1] : member;
|
|
385
533
|
return fieldName;
|
|
386
534
|
}
|
|
@@ -634,6 +782,309 @@ var ObjectQLStrategy = class {
|
|
|
634
782
|
}
|
|
635
783
|
};
|
|
636
784
|
|
|
785
|
+
// src/dataset-compiler.ts
|
|
786
|
+
var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set(["array_agg", "string_agg"]);
|
|
787
|
+
function aggregateToMetricType(m) {
|
|
788
|
+
if (UNSUPPORTED_AGGREGATES.has(m.aggregate)) {
|
|
789
|
+
throw new Error(
|
|
790
|
+
`[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).`
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
return m.aggregate;
|
|
794
|
+
}
|
|
795
|
+
function dimensionType(d) {
|
|
796
|
+
switch (d.type) {
|
|
797
|
+
case "date":
|
|
798
|
+
return "time";
|
|
799
|
+
case "number":
|
|
800
|
+
return "number";
|
|
801
|
+
case "boolean":
|
|
802
|
+
return "boolean";
|
|
803
|
+
case "lookup":
|
|
804
|
+
return "string";
|
|
805
|
+
case "string":
|
|
806
|
+
return "string";
|
|
807
|
+
default:
|
|
808
|
+
return "string";
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
function relationshipPrefix(field) {
|
|
812
|
+
const idx = field.indexOf(".");
|
|
813
|
+
return idx > 0 ? field.slice(0, idx) : null;
|
|
814
|
+
}
|
|
815
|
+
function compileDataset(dataset, resolver) {
|
|
816
|
+
const include = dataset.include ?? [];
|
|
817
|
+
const allowedRelationships = new Set(include);
|
|
818
|
+
const joins = {};
|
|
819
|
+
for (const rel of include) {
|
|
820
|
+
let targetTable = rel;
|
|
821
|
+
if (resolver) {
|
|
822
|
+
const resolved = resolver(dataset.object, rel);
|
|
823
|
+
if (!resolved) {
|
|
824
|
+
throw new Error(
|
|
825
|
+
`[dataset-compiler] dataset "${dataset.name}" includes relationship "${rel}" which does not exist on object "${dataset.object}".`
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
targetTable = resolved;
|
|
829
|
+
}
|
|
830
|
+
joins[rel] = {
|
|
831
|
+
name: targetTable,
|
|
832
|
+
relationship: "many_to_one",
|
|
833
|
+
sql: `${dataset.object}.${rel} = ${rel}.id`
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
const assertDeclared = (field, ownerKind, ownerName) => {
|
|
837
|
+
const prefix = relationshipPrefix(field);
|
|
838
|
+
if (prefix && !allowedRelationships.has(prefix)) {
|
|
839
|
+
throw new Error(
|
|
840
|
+
`[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.`
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
const dimensions = {};
|
|
845
|
+
for (const d of dataset.dimensions) {
|
|
846
|
+
assertDeclared(d.field, "dimension", d.name);
|
|
847
|
+
const dim = {
|
|
848
|
+
name: d.name,
|
|
849
|
+
label: typeof d.label === "string" ? d.label : d.name,
|
|
850
|
+
type: dimensionType(d),
|
|
851
|
+
sql: d.field
|
|
852
|
+
};
|
|
853
|
+
if (dim.type === "time") {
|
|
854
|
+
dim.granularities = d.dateGranularity ? [d.dateGranularity] : ["day", "week", "month", "quarter", "year"];
|
|
855
|
+
}
|
|
856
|
+
dimensions[d.name] = dim;
|
|
857
|
+
}
|
|
858
|
+
const measures = {};
|
|
859
|
+
const derived = [];
|
|
860
|
+
const measureFilters = {};
|
|
861
|
+
for (const m of dataset.measures) {
|
|
862
|
+
if (m.derived) {
|
|
863
|
+
derived.push({ name: m.name, op: m.derived.op, of: m.derived.of });
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
if (m.field) assertDeclared(m.field, "measure", m.name);
|
|
867
|
+
const metric = {
|
|
868
|
+
name: m.name,
|
|
869
|
+
label: typeof m.label === "string" ? m.label : m.name,
|
|
870
|
+
type: aggregateToMetricType(m),
|
|
871
|
+
// `count` with no field aggregates over rows (*).
|
|
872
|
+
sql: m.field ?? "*"
|
|
873
|
+
};
|
|
874
|
+
if (typeof m.format === "string") metric.format = m.format;
|
|
875
|
+
measures[m.name] = metric;
|
|
876
|
+
if (m.filter) measureFilters[m.name] = m.filter;
|
|
877
|
+
}
|
|
878
|
+
const cube = {
|
|
879
|
+
name: dataset.name,
|
|
880
|
+
title: typeof dataset.label === "string" ? dataset.label : dataset.name,
|
|
881
|
+
sql: dataset.object,
|
|
882
|
+
measures,
|
|
883
|
+
dimensions,
|
|
884
|
+
public: false
|
|
885
|
+
};
|
|
886
|
+
if (Object.keys(joins).length > 0) cube.joins = joins;
|
|
887
|
+
return {
|
|
888
|
+
cube,
|
|
889
|
+
allowedRelationships,
|
|
890
|
+
derived,
|
|
891
|
+
filter: dataset.filter,
|
|
892
|
+
measureFilters
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// src/dataset-executor.ts
|
|
897
|
+
function combineFilters(a, b) {
|
|
898
|
+
if (a && b) return { $and: [a, b] };
|
|
899
|
+
return a ?? b;
|
|
900
|
+
}
|
|
901
|
+
function evaluateDerivedMeasures(rows, derived) {
|
|
902
|
+
if (derived.length === 0) return rows;
|
|
903
|
+
return rows.map((row) => {
|
|
904
|
+
const out = { ...row };
|
|
905
|
+
for (const d of derived) {
|
|
906
|
+
out[d.name] = computeDerived(d, out);
|
|
907
|
+
}
|
|
908
|
+
return out;
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
function num(v) {
|
|
912
|
+
if (v == null) return null;
|
|
913
|
+
const n = typeof v === "number" ? v : Number(v);
|
|
914
|
+
return Number.isFinite(n) ? n : null;
|
|
915
|
+
}
|
|
916
|
+
function computeDerived(d, row) {
|
|
917
|
+
const vals = d.of.map((name) => num(row[name]));
|
|
918
|
+
if (vals.some((v) => v === null)) return null;
|
|
919
|
+
const nums = vals;
|
|
920
|
+
switch (d.op) {
|
|
921
|
+
case "ratio": {
|
|
922
|
+
if (nums.length < 2 || nums[1] === 0) return null;
|
|
923
|
+
return nums[0] / nums[1];
|
|
924
|
+
}
|
|
925
|
+
case "difference":
|
|
926
|
+
return nums.slice(1).reduce((acc, v) => acc - v, nums[0]);
|
|
927
|
+
case "sum":
|
|
928
|
+
return nums.reduce((acc, v) => acc + v, 0);
|
|
929
|
+
case "product":
|
|
930
|
+
return nums.reduce((acc, v) => acc * v, 1);
|
|
931
|
+
default:
|
|
932
|
+
return null;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
function parseUTC(date) {
|
|
936
|
+
const ms = Date.parse(date.length === 10 ? `${date}T00:00:00Z` : date);
|
|
937
|
+
if (Number.isNaN(ms)) throw new Error(`[dataset-executor] invalid date in dateRange: "${date}"`);
|
|
938
|
+
return ms;
|
|
939
|
+
}
|
|
940
|
+
var DAY_MS = 864e5;
|
|
941
|
+
function toISODate(ms) {
|
|
942
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
943
|
+
}
|
|
944
|
+
function shiftYear(date, years) {
|
|
945
|
+
const d = new Date(parseUTC(date));
|
|
946
|
+
d.setUTCFullYear(d.getUTCFullYear() + years);
|
|
947
|
+
return toISODate(d.getTime());
|
|
948
|
+
}
|
|
949
|
+
function shiftRange(range, kind) {
|
|
950
|
+
const [start, end] = range;
|
|
951
|
+
if (kind === "previousYear") {
|
|
952
|
+
return [shiftYear(start, -1), shiftYear(end, -1)];
|
|
953
|
+
}
|
|
954
|
+
const startMs = parseUTC(start);
|
|
955
|
+
const endMs = parseUTC(end);
|
|
956
|
+
const lengthDays = Math.round((endMs - startMs) / DAY_MS) + 1;
|
|
957
|
+
const prevEndMs = startMs - DAY_MS;
|
|
958
|
+
const prevStartMs = prevEndMs - (lengthDays - 1) * DAY_MS;
|
|
959
|
+
return [toISODate(prevStartMs), toISODate(prevEndMs)];
|
|
960
|
+
}
|
|
961
|
+
var DatasetExecutor = class {
|
|
962
|
+
constructor(service) {
|
|
963
|
+
this.service = service;
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Execute a dataset selection and return the shaped rows (+ field metadata).
|
|
967
|
+
*
|
|
968
|
+
* @param context - The request's ExecutionContext, threaded into every
|
|
969
|
+
* underlying `IAnalyticsService.query` so the tenant/RLS read scope is
|
|
970
|
+
* applied per request (ADR-0021 D-C).
|
|
971
|
+
*/
|
|
972
|
+
async execute(compiled, selection, context) {
|
|
973
|
+
const derivedByName = new Map(compiled.derived.map((d) => [d.name, d]));
|
|
974
|
+
const selectedDerived = selection.measures.map((m) => derivedByName.get(m)).filter((d) => !!d);
|
|
975
|
+
const baseMeasures = /* @__PURE__ */ new Set();
|
|
976
|
+
for (const m of selection.measures) {
|
|
977
|
+
if (!derivedByName.has(m)) baseMeasures.add(m);
|
|
978
|
+
}
|
|
979
|
+
for (const d of selectedDerived) {
|
|
980
|
+
for (const dep of d.of) baseMeasures.add(dep);
|
|
981
|
+
}
|
|
982
|
+
const unfiltered = [];
|
|
983
|
+
const filtered = [];
|
|
984
|
+
for (const m of baseMeasures) {
|
|
985
|
+
(compiled.measureFilters[m] ? filtered : unfiltered).push(m);
|
|
986
|
+
}
|
|
987
|
+
const baseFilter = combineFilters(compiled.filter, selection.runtimeFilter);
|
|
988
|
+
const dimensions = selection.dimensions ?? [];
|
|
989
|
+
let result;
|
|
990
|
+
if (unfiltered.length > 0 || filtered.length === 0) {
|
|
991
|
+
result = await this.service.query(this.buildQuery(compiled, {
|
|
992
|
+
measures: unfiltered,
|
|
993
|
+
dimensions,
|
|
994
|
+
where: baseFilter,
|
|
995
|
+
selection
|
|
996
|
+
}), context);
|
|
997
|
+
} else {
|
|
998
|
+
result = { rows: [], fields: [] };
|
|
999
|
+
}
|
|
1000
|
+
for (const m of filtered) {
|
|
1001
|
+
const mFilter = combineFilters(baseFilter, compiled.measureFilters[m]);
|
|
1002
|
+
const sub = await this.service.query(this.buildQuery(compiled, {
|
|
1003
|
+
measures: [m],
|
|
1004
|
+
dimensions,
|
|
1005
|
+
where: mFilter,
|
|
1006
|
+
selection
|
|
1007
|
+
}), context);
|
|
1008
|
+
result.rows = mergeByDimensions(result.rows, sub.rows, dimensions, [m]);
|
|
1009
|
+
result.fields.push({ name: m, type: "number" });
|
|
1010
|
+
}
|
|
1011
|
+
if (selection.compareTo) {
|
|
1012
|
+
const compareRows = await this.runCompare(compiled, selection, [...baseMeasures], dimensions, baseFilter, context);
|
|
1013
|
+
result.rows = mergeByDimensions(
|
|
1014
|
+
result.rows,
|
|
1015
|
+
compareRows,
|
|
1016
|
+
dimensions,
|
|
1017
|
+
[...baseMeasures].map((m) => `${m}__compare`)
|
|
1018
|
+
);
|
|
1019
|
+
for (const m of baseMeasures) result.fields.push({ name: `${m}__compare`, type: "number" });
|
|
1020
|
+
}
|
|
1021
|
+
result.rows = evaluateDerivedMeasures(result.rows, selectedDerived);
|
|
1022
|
+
for (const d of selectedDerived) result.fields.push({ name: d.name, type: "number" });
|
|
1023
|
+
return result;
|
|
1024
|
+
}
|
|
1025
|
+
buildQuery(compiled, opts) {
|
|
1026
|
+
const q = {
|
|
1027
|
+
cube: compiled.cube.name,
|
|
1028
|
+
measures: opts.measures,
|
|
1029
|
+
dimensions: opts.dimensions,
|
|
1030
|
+
timezone: opts.selection.timezone ?? "UTC"
|
|
1031
|
+
};
|
|
1032
|
+
if (opts.where) q.where = opts.where;
|
|
1033
|
+
if (opts.selection.timeDimensions) q.timeDimensions = opts.selection.timeDimensions;
|
|
1034
|
+
if (opts.selection.order) q.order = opts.selection.order;
|
|
1035
|
+
if (opts.selection.limit != null) q.limit = opts.selection.limit;
|
|
1036
|
+
if (opts.selection.offset != null) q.offset = opts.selection.offset;
|
|
1037
|
+
return q;
|
|
1038
|
+
}
|
|
1039
|
+
async runCompare(compiled, selection, measures, dimensions, baseFilter, context) {
|
|
1040
|
+
const cmp = selection.compareTo;
|
|
1041
|
+
const td = (selection.timeDimensions ?? []).find((t) => t.dimension === cmp.dimension);
|
|
1042
|
+
if (!td || !td.dateRange) {
|
|
1043
|
+
throw new Error(
|
|
1044
|
+
`[dataset-executor] compareTo requires a timeDimension "${cmp.dimension}" with a dateRange.`
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
const range = Array.isArray(td.dateRange) ? [td.dateRange[0], td.dateRange[1] ?? td.dateRange[0]] : [td.dateRange, td.dateRange];
|
|
1048
|
+
const shifted = shiftRange(range, cmp.kind);
|
|
1049
|
+
const shiftedTd = (selection.timeDimensions ?? []).map(
|
|
1050
|
+
(t) => t.dimension === cmp.dimension ? { ...t, dateRange: shifted } : t
|
|
1051
|
+
);
|
|
1052
|
+
const sub = await this.service.query({
|
|
1053
|
+
cube: compiled.cube.name,
|
|
1054
|
+
measures,
|
|
1055
|
+
dimensions,
|
|
1056
|
+
where: baseFilter,
|
|
1057
|
+
timeDimensions: shiftedTd,
|
|
1058
|
+
timezone: selection.timezone ?? "UTC"
|
|
1059
|
+
}, context);
|
|
1060
|
+
return sub.rows.map((row) => {
|
|
1061
|
+
const out = {};
|
|
1062
|
+
for (const dim of dimensions) out[dim] = row[dim];
|
|
1063
|
+
for (const m of measures) out[`${m}__compare`] = row[m];
|
|
1064
|
+
return out;
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
function mergeByDimensions(base, extra, dimensions, valueColumns) {
|
|
1069
|
+
const keyOf = (row) => dimensions.map((d) => String(row[d] ?? "")).join("");
|
|
1070
|
+
const index = /* @__PURE__ */ new Map();
|
|
1071
|
+
for (const row of base) index.set(keyOf(row), row);
|
|
1072
|
+
for (const row of extra) {
|
|
1073
|
+
const key = keyOf(row);
|
|
1074
|
+
const target = index.get(key);
|
|
1075
|
+
if (target) {
|
|
1076
|
+
for (const c of valueColumns) target[c] = row[c];
|
|
1077
|
+
} else {
|
|
1078
|
+
const fresh = {};
|
|
1079
|
+
for (const d of dimensions) fresh[d] = row[d];
|
|
1080
|
+
for (const c of valueColumns) fresh[c] = row[c];
|
|
1081
|
+
index.set(key, fresh);
|
|
1082
|
+
base.push(fresh);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
return base;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
637
1088
|
// src/analytics-service.ts
|
|
638
1089
|
var DEFAULT_CAPABILITIES = {
|
|
639
1090
|
nativeSql: false,
|
|
@@ -642,17 +1093,33 @@ var DEFAULT_CAPABILITIES = {
|
|
|
642
1093
|
};
|
|
643
1094
|
var AnalyticsService = class {
|
|
644
1095
|
constructor(config = {}) {
|
|
1096
|
+
/** Compiled datasets by name — feeds the join allowlist (D-C) and queryDataset. */
|
|
1097
|
+
this.datasetRegistry = /* @__PURE__ */ new Map();
|
|
645
1098
|
this.logger = config.logger || createLogger({ level: "info", format: "pretty" });
|
|
646
1099
|
this.cubeRegistry = new CubeRegistry();
|
|
647
1100
|
if (config.cubes) {
|
|
648
1101
|
this.cubeRegistry.registerAll(config.cubes);
|
|
649
1102
|
}
|
|
650
|
-
this.
|
|
1103
|
+
this.readScopeProvider = config.getReadScope;
|
|
1104
|
+
this.relationshipResolver = config.relationshipResolver;
|
|
1105
|
+
if (config.datasets) {
|
|
1106
|
+
for (const ds of config.datasets) {
|
|
1107
|
+
try {
|
|
1108
|
+
this.registerDataset(ds);
|
|
1109
|
+
} catch (e) {
|
|
1110
|
+
this.logger?.warn?.(`[Analytics] Failed to register dataset "${ds?.name}": ${String(e?.message ?? e)}`);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
this.baseCtx = {
|
|
651
1115
|
getCube: (name) => this.cubeRegistry.get(name),
|
|
652
1116
|
queryCapabilities: config.queryCapabilities || (() => DEFAULT_CAPABILITIES),
|
|
653
1117
|
executeRawSql: config.executeRawSql,
|
|
654
1118
|
executeAggregate: config.executeAggregate,
|
|
655
|
-
fallbackService: config.fallbackService
|
|
1119
|
+
fallbackService: config.fallbackService,
|
|
1120
|
+
// Prefer a compiled dataset's declared relationships (D-C join allowlist);
|
|
1121
|
+
// fall back to any explicitly-configured provider for legacy cubes.
|
|
1122
|
+
getAllowedRelationships: (cubeName) => this.datasetRegistry.get(cubeName)?.allowedRelationships ?? config.getAllowedRelationships?.(cubeName)
|
|
656
1123
|
};
|
|
657
1124
|
const builtIn = [
|
|
658
1125
|
new NativeSQLStrategy(),
|
|
@@ -667,17 +1134,52 @@ var AnalyticsService = class {
|
|
|
667
1134
|
`[Analytics] Initialized with ${this.cubeRegistry.size} cubes, ${this.strategies.length} strategies: ${this.strategies.map((s) => s.name).join(" \u2192 ")}`
|
|
668
1135
|
);
|
|
669
1136
|
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Build a per-call StrategyContext that binds the read-scope provider to the
|
|
1139
|
+
* current request's ExecutionContext (ADR-0021 D-C). The strategy then sees a
|
|
1140
|
+
* `getReadScope(objectName)` that already knows the active tenant.
|
|
1141
|
+
*/
|
|
1142
|
+
callCtx(context) {
|
|
1143
|
+
if (!this.readScopeProvider) return this.baseCtx;
|
|
1144
|
+
return {
|
|
1145
|
+
...this.baseCtx,
|
|
1146
|
+
getReadScope: (objectName) => this.readScopeProvider(objectName, context)
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
670
1149
|
/**
|
|
671
1150
|
* Execute an analytical query by delegating to the first capable strategy.
|
|
672
1151
|
*/
|
|
673
|
-
async query(query) {
|
|
1152
|
+
async query(query, context) {
|
|
674
1153
|
if (!query.cube) {
|
|
675
1154
|
throw new Error("Cube name is required in analytics query");
|
|
676
1155
|
}
|
|
677
1156
|
this.ensureCube(query);
|
|
678
|
-
const
|
|
1157
|
+
const ctx = this.callCtx(context);
|
|
1158
|
+
const strategy = this.resolveStrategy(query, ctx);
|
|
679
1159
|
this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
680
|
-
return strategy.execute(query,
|
|
1160
|
+
return strategy.execute(query, ctx);
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Compile a `dataset` (ADR-0021) and register its Cube + join allowlist so it
|
|
1164
|
+
* can be queried by name. Idempotent (re-registering overwrites). Returns the
|
|
1165
|
+
* compiled dataset.
|
|
1166
|
+
*/
|
|
1167
|
+
registerDataset(dataset) {
|
|
1168
|
+
const compiled = compileDataset(dataset, this.relationshipResolver);
|
|
1169
|
+
this.cubeRegistry.register(compiled.cube);
|
|
1170
|
+
this.datasetRegistry.set(dataset.name, compiled);
|
|
1171
|
+
return compiled;
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* Execute a semantic-layer dataset (ADR-0021). Compiles the dataset (saved or
|
|
1175
|
+
* inline draft — Studio preview), registers its Cube + join allowlist, then
|
|
1176
|
+
* runs the selection through the `DatasetExecutor` with the request context so
|
|
1177
|
+
* tenant/RLS scoping (D-C) is applied. See {@link IAnalyticsService.queryDataset}.
|
|
1178
|
+
*/
|
|
1179
|
+
async queryDataset(dataset, selection, context) {
|
|
1180
|
+
const compiled = this.registerDataset(dataset);
|
|
1181
|
+
this.logger.debug(`[Analytics] queryDataset "${dataset.name}" (object=${dataset.object}, include=${(dataset.include ?? []).join(",") || "\u2014"})`);
|
|
1182
|
+
return new DatasetExecutor(this).execute(compiled, selection, context);
|
|
681
1183
|
}
|
|
682
1184
|
/**
|
|
683
1185
|
* Get cube metadata for discovery.
|
|
@@ -702,14 +1204,15 @@ var AnalyticsService = class {
|
|
|
702
1204
|
/**
|
|
703
1205
|
* Generate SQL for a query without executing it (dry-run).
|
|
704
1206
|
*/
|
|
705
|
-
async generateSql(query) {
|
|
1207
|
+
async generateSql(query, context) {
|
|
706
1208
|
if (!query.cube) {
|
|
707
1209
|
throw new Error("Cube name is required for SQL generation");
|
|
708
1210
|
}
|
|
709
1211
|
this.ensureCube(query);
|
|
710
|
-
const
|
|
1212
|
+
const ctx = this.callCtx(context);
|
|
1213
|
+
const strategy = this.resolveStrategy(query, ctx);
|
|
711
1214
|
this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
712
|
-
return strategy.generateSql(query,
|
|
1215
|
+
return strategy.generateSql(query, ctx);
|
|
713
1216
|
}
|
|
714
1217
|
// ── Internal ─────────────────────────────────────────────────────
|
|
715
1218
|
/**
|
|
@@ -802,9 +1305,9 @@ var AnalyticsService = class {
|
|
|
802
1305
|
/**
|
|
803
1306
|
* Walk the strategy chain and return the first strategy that can handle the query.
|
|
804
1307
|
*/
|
|
805
|
-
resolveStrategy(query) {
|
|
1308
|
+
resolveStrategy(query, ctx) {
|
|
806
1309
|
for (const strategy of this.strategies) {
|
|
807
|
-
if (strategy.canHandle(query,
|
|
1310
|
+
if (strategy.canHandle(query, ctx)) {
|
|
808
1311
|
return strategy;
|
|
809
1312
|
}
|
|
810
1313
|
}
|
|
@@ -944,14 +1447,56 @@ var AnalyticsServicePlugin = class {
|
|
|
944
1447
|
objectqlAggregate: !!executeAggregate,
|
|
945
1448
|
inMemory: false
|
|
946
1449
|
}));
|
|
1450
|
+
let getReadScope = this.options.getReadScope;
|
|
1451
|
+
let autoBridgedReadScope = false;
|
|
1452
|
+
if (!getReadScope) {
|
|
1453
|
+
const trySecurity = () => {
|
|
1454
|
+
try {
|
|
1455
|
+
const svc = ctx.getService("security");
|
|
1456
|
+
return svc && typeof svc.getReadFilter === "function" ? svc : void 0;
|
|
1457
|
+
} catch {
|
|
1458
|
+
return void 0;
|
|
1459
|
+
}
|
|
1460
|
+
};
|
|
1461
|
+
if (trySecurity()) {
|
|
1462
|
+
getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context);
|
|
1463
|
+
autoBridgedReadScope = true;
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
const relationshipResolver = (baseObject, relationshipName) => {
|
|
1467
|
+
const engine = (() => {
|
|
1468
|
+
try {
|
|
1469
|
+
const svc = ctx.getService("data");
|
|
1470
|
+
return svc && typeof svc.getObject === "function" ? svc : void 0;
|
|
1471
|
+
} catch {
|
|
1472
|
+
return void 0;
|
|
1473
|
+
}
|
|
1474
|
+
})();
|
|
1475
|
+
const obj = engine?.getObject?.(baseObject);
|
|
1476
|
+
const field = obj?.fields?.[relationshipName];
|
|
1477
|
+
if (field && (field.type === "lookup" || field.type === "master_detail") && field.reference) {
|
|
1478
|
+
return field.reference;
|
|
1479
|
+
}
|
|
1480
|
+
return engine ? void 0 : relationshipName;
|
|
1481
|
+
};
|
|
947
1482
|
const config = {
|
|
948
1483
|
cubes: this.options.cubes,
|
|
949
1484
|
logger: ctx.logger,
|
|
950
1485
|
queryCapabilities,
|
|
951
1486
|
executeRawSql,
|
|
952
1487
|
executeAggregate,
|
|
953
|
-
fallbackService
|
|
1488
|
+
fallbackService,
|
|
1489
|
+
getReadScope,
|
|
1490
|
+
getAllowedRelationships: this.options.getAllowedRelationships,
|
|
1491
|
+
relationshipResolver
|
|
954
1492
|
};
|
|
1493
|
+
if (autoBridgedReadScope) {
|
|
1494
|
+
ctx.logger.info('[Analytics] Auto-bridged getReadScope \u2192 "security" service (getReadFilter)');
|
|
1495
|
+
} else if (!getReadScope) {
|
|
1496
|
+
ctx.logger.warn(
|
|
1497
|
+
'[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.'
|
|
1498
|
+
);
|
|
1499
|
+
}
|
|
955
1500
|
if (autoBridged) {
|
|
956
1501
|
ctx.logger.info('[Analytics] Auto-bridged executeAggregate \u2192 "data" service (IDataEngine)');
|
|
957
1502
|
}
|
|
@@ -986,7 +1531,14 @@ export {
|
|
|
986
1531
|
AnalyticsService,
|
|
987
1532
|
AnalyticsServicePlugin,
|
|
988
1533
|
CubeRegistry,
|
|
1534
|
+
DatasetExecutor,
|
|
989
1535
|
NativeSQLStrategy,
|
|
990
|
-
ObjectQLStrategy
|
|
1536
|
+
ObjectQLStrategy,
|
|
1537
|
+
combineFilters,
|
|
1538
|
+
compileDataset,
|
|
1539
|
+
compileScopedFilterToSql,
|
|
1540
|
+
evaluateDerivedMeasures,
|
|
1541
|
+
mergeByDimensions,
|
|
1542
|
+
shiftRange
|
|
991
1543
|
};
|
|
992
1544
|
//# sourceMappingURL=index.js.map
|