@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.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,99 @@ 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
|
+
async callCtx(query, context) {
|
|
1143
|
+
if (!this.readScopeProvider) return this.baseCtx;
|
|
1144
|
+
const scopes = await this.resolveReadScopes(query, context);
|
|
1145
|
+
return {
|
|
1146
|
+
...this.baseCtx,
|
|
1147
|
+
getReadScope: (objectName) => scopes.get(objectName) ?? null
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
|
|
1152
|
+
* AND every joined object of the query's cube, keyed by object name. This is
|
|
1153
|
+
* the async pre-pass that lets the synchronous strategy enforce scoping even
|
|
1154
|
+
* when the provider (security `getReadFilter`) resolves asynchronously.
|
|
1155
|
+
*
|
|
1156
|
+
* The object set is `cube.sql` (base) plus every `cube.joins[*].name` — a
|
|
1157
|
+
* SUPERSET of what the strategy actually scans (the strategy only joins along
|
|
1158
|
+
* declared relationships), so no scanned object is ever left unscoped.
|
|
1159
|
+
*
|
|
1160
|
+
* Fail-closed: if the provider throws for an object, the whole query is
|
|
1161
|
+
* rejected rather than emitting SQL with that object unscoped.
|
|
1162
|
+
*/
|
|
1163
|
+
async resolveReadScopes(query, context) {
|
|
1164
|
+
const map = /* @__PURE__ */ new Map();
|
|
1165
|
+
const provider = this.readScopeProvider;
|
|
1166
|
+
if (!provider || !query.cube) return map;
|
|
1167
|
+
const cube = this.cubeRegistry.get(query.cube);
|
|
1168
|
+
if (!cube) return map;
|
|
1169
|
+
const objects = /* @__PURE__ */ new Set();
|
|
1170
|
+
if (typeof cube.sql === "string" && cube.sql.trim()) {
|
|
1171
|
+
objects.add(cube.sql.trim());
|
|
1172
|
+
}
|
|
1173
|
+
const joins = cube.joins;
|
|
1174
|
+
if (joins) {
|
|
1175
|
+
for (const [alias, j] of Object.entries(joins)) {
|
|
1176
|
+
objects.add(j?.name ?? alias);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
for (const object of objects) {
|
|
1180
|
+
let filter;
|
|
1181
|
+
try {
|
|
1182
|
+
filter = await provider(object, context);
|
|
1183
|
+
} catch (e) {
|
|
1184
|
+
this.logger.error?.(
|
|
1185
|
+
`[Analytics] read-scope resolution failed for object "${object}" \u2014 rejecting query (fail-closed, ADR-0021 D-C)`,
|
|
1186
|
+
e instanceof Error ? e : new Error(String(e))
|
|
1187
|
+
);
|
|
1188
|
+
throw new Error(
|
|
1189
|
+
`[Analytics] read-scope resolution failed for "${object}"; query denied (fail-closed).`
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
if (filter != null) map.set(object, filter);
|
|
1193
|
+
}
|
|
1194
|
+
return map;
|
|
1195
|
+
}
|
|
670
1196
|
/**
|
|
671
1197
|
* Execute an analytical query by delegating to the first capable strategy.
|
|
672
1198
|
*/
|
|
673
|
-
async query(query) {
|
|
1199
|
+
async query(query, context) {
|
|
674
1200
|
if (!query.cube) {
|
|
675
1201
|
throw new Error("Cube name is required in analytics query");
|
|
676
1202
|
}
|
|
677
1203
|
this.ensureCube(query);
|
|
678
|
-
const
|
|
1204
|
+
const ctx = await this.callCtx(query, context);
|
|
1205
|
+
const strategy = this.resolveStrategy(query, ctx);
|
|
679
1206
|
this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
680
|
-
return strategy.execute(query,
|
|
1207
|
+
return strategy.execute(query, ctx);
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Compile a `dataset` (ADR-0021) and register its Cube + join allowlist so it
|
|
1211
|
+
* can be queried by name. Idempotent (re-registering overwrites). Returns the
|
|
1212
|
+
* compiled dataset.
|
|
1213
|
+
*/
|
|
1214
|
+
registerDataset(dataset) {
|
|
1215
|
+
const compiled = compileDataset(dataset, this.relationshipResolver);
|
|
1216
|
+
this.cubeRegistry.register(compiled.cube);
|
|
1217
|
+
this.datasetRegistry.set(dataset.name, compiled);
|
|
1218
|
+
return compiled;
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Execute a semantic-layer dataset (ADR-0021). Compiles the dataset (saved or
|
|
1222
|
+
* inline draft — Studio preview), registers its Cube + join allowlist, then
|
|
1223
|
+
* runs the selection through the `DatasetExecutor` with the request context so
|
|
1224
|
+
* tenant/RLS scoping (D-C) is applied. See {@link IAnalyticsService.queryDataset}.
|
|
1225
|
+
*/
|
|
1226
|
+
async queryDataset(dataset, selection, context) {
|
|
1227
|
+
const compiled = this.registerDataset(dataset);
|
|
1228
|
+
this.logger.debug(`[Analytics] queryDataset "${dataset.name}" (object=${dataset.object}, include=${(dataset.include ?? []).join(",") || "\u2014"})`);
|
|
1229
|
+
return new DatasetExecutor(this).execute(compiled, selection, context);
|
|
681
1230
|
}
|
|
682
1231
|
/**
|
|
683
1232
|
* Get cube metadata for discovery.
|
|
@@ -702,14 +1251,15 @@ var AnalyticsService = class {
|
|
|
702
1251
|
/**
|
|
703
1252
|
* Generate SQL for a query without executing it (dry-run).
|
|
704
1253
|
*/
|
|
705
|
-
async generateSql(query) {
|
|
1254
|
+
async generateSql(query, context) {
|
|
706
1255
|
if (!query.cube) {
|
|
707
1256
|
throw new Error("Cube name is required for SQL generation");
|
|
708
1257
|
}
|
|
709
1258
|
this.ensureCube(query);
|
|
710
|
-
const
|
|
1259
|
+
const ctx = await this.callCtx(query, context);
|
|
1260
|
+
const strategy = this.resolveStrategy(query, ctx);
|
|
711
1261
|
this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
712
|
-
return strategy.generateSql(query,
|
|
1262
|
+
return strategy.generateSql(query, ctx);
|
|
713
1263
|
}
|
|
714
1264
|
// ── Internal ─────────────────────────────────────────────────────
|
|
715
1265
|
/**
|
|
@@ -802,9 +1352,9 @@ var AnalyticsService = class {
|
|
|
802
1352
|
/**
|
|
803
1353
|
* Walk the strategy chain and return the first strategy that can handle the query.
|
|
804
1354
|
*/
|
|
805
|
-
resolveStrategy(query) {
|
|
1355
|
+
resolveStrategy(query, ctx) {
|
|
806
1356
|
for (const strategy of this.strategies) {
|
|
807
|
-
if (strategy.canHandle(query,
|
|
1357
|
+
if (strategy.canHandle(query, ctx)) {
|
|
808
1358
|
return strategy;
|
|
809
1359
|
}
|
|
810
1360
|
}
|
|
@@ -944,14 +1494,56 @@ var AnalyticsServicePlugin = class {
|
|
|
944
1494
|
objectqlAggregate: !!executeAggregate,
|
|
945
1495
|
inMemory: false
|
|
946
1496
|
}));
|
|
1497
|
+
let getReadScope = this.options.getReadScope;
|
|
1498
|
+
let autoBridgedReadScope = false;
|
|
1499
|
+
if (!getReadScope) {
|
|
1500
|
+
const trySecurity = () => {
|
|
1501
|
+
try {
|
|
1502
|
+
const svc = ctx.getService("security");
|
|
1503
|
+
return svc && typeof svc.getReadFilter === "function" ? svc : void 0;
|
|
1504
|
+
} catch {
|
|
1505
|
+
return void 0;
|
|
1506
|
+
}
|
|
1507
|
+
};
|
|
1508
|
+
if (trySecurity()) {
|
|
1509
|
+
getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context);
|
|
1510
|
+
autoBridgedReadScope = true;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
const relationshipResolver = (baseObject, relationshipName) => {
|
|
1514
|
+
const engine = (() => {
|
|
1515
|
+
try {
|
|
1516
|
+
const svc = ctx.getService("data");
|
|
1517
|
+
return svc && typeof svc.getObject === "function" ? svc : void 0;
|
|
1518
|
+
} catch {
|
|
1519
|
+
return void 0;
|
|
1520
|
+
}
|
|
1521
|
+
})();
|
|
1522
|
+
const obj = engine?.getObject?.(baseObject);
|
|
1523
|
+
const field = obj?.fields?.[relationshipName];
|
|
1524
|
+
if (field && (field.type === "lookup" || field.type === "master_detail") && field.reference) {
|
|
1525
|
+
return field.reference;
|
|
1526
|
+
}
|
|
1527
|
+
return engine ? void 0 : relationshipName;
|
|
1528
|
+
};
|
|
947
1529
|
const config = {
|
|
948
1530
|
cubes: this.options.cubes,
|
|
949
1531
|
logger: ctx.logger,
|
|
950
1532
|
queryCapabilities,
|
|
951
1533
|
executeRawSql,
|
|
952
1534
|
executeAggregate,
|
|
953
|
-
fallbackService
|
|
1535
|
+
fallbackService,
|
|
1536
|
+
getReadScope,
|
|
1537
|
+
getAllowedRelationships: this.options.getAllowedRelationships,
|
|
1538
|
+
relationshipResolver
|
|
954
1539
|
};
|
|
1540
|
+
if (autoBridgedReadScope) {
|
|
1541
|
+
ctx.logger.info('[Analytics] Auto-bridged getReadScope \u2192 "security" service (getReadFilter)');
|
|
1542
|
+
} else if (!getReadScope) {
|
|
1543
|
+
ctx.logger.warn(
|
|
1544
|
+
'[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.'
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
955
1547
|
if (autoBridged) {
|
|
956
1548
|
ctx.logger.info('[Analytics] Auto-bridged executeAggregate \u2192 "data" service (IDataEngine)');
|
|
957
1549
|
}
|
|
@@ -986,7 +1578,14 @@ export {
|
|
|
986
1578
|
AnalyticsService,
|
|
987
1579
|
AnalyticsServicePlugin,
|
|
988
1580
|
CubeRegistry,
|
|
1581
|
+
DatasetExecutor,
|
|
989
1582
|
NativeSQLStrategy,
|
|
990
|
-
ObjectQLStrategy
|
|
1583
|
+
ObjectQLStrategy,
|
|
1584
|
+
combineFilters,
|
|
1585
|
+
compileDataset,
|
|
1586
|
+
compileScopedFilterToSql,
|
|
1587
|
+
evaluateDerivedMeasures,
|
|
1588
|
+
mergeByDimensions,
|
|
1589
|
+
shiftRange
|
|
991
1590
|
};
|
|
992
1591
|
//# sourceMappingURL=index.js.map
|