@objectstack/service-analytics 4.0.4 → 4.1.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 +439 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +61 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.js +439 -47
- package/dist/index.js.map +1 -1
- package/package.json +31 -6
- package/.turbo/turbo-build.log +0 -22
- package/CHANGELOG.md +0 -65
- package/src/__tests__/analytics-service.test.ts +0 -469
- package/src/analytics-service.ts +0 -231
- package/src/cube-registry.ts +0 -147
- package/src/index.ts +0 -19
- package/src/plugin.ts +0 -133
- package/src/strategies/native-sql-strategy.ts +0 -184
- package/src/strategies/objectql-strategy.ts +0 -178
- package/src/strategies/types.ts +0 -11
- package/tsconfig.json +0 -17
package/dist/index.cjs
CHANGED
|
@@ -146,6 +146,92 @@ var CubeRegistry = class {
|
|
|
146
146
|
}
|
|
147
147
|
};
|
|
148
148
|
|
|
149
|
+
// src/strategies/filter-normalizer.ts
|
|
150
|
+
var MONGO_TO_CUBE_OP = {
|
|
151
|
+
$eq: "equals",
|
|
152
|
+
$ne: "notEquals",
|
|
153
|
+
$gt: "gt",
|
|
154
|
+
$gte: "gte",
|
|
155
|
+
$lt: "lt",
|
|
156
|
+
$lte: "lte",
|
|
157
|
+
$in: "in",
|
|
158
|
+
$nin: "notIn",
|
|
159
|
+
$contains: "contains",
|
|
160
|
+
$notContains: "notContains",
|
|
161
|
+
$exists: "set"
|
|
162
|
+
};
|
|
163
|
+
function stringifyForCube(v) {
|
|
164
|
+
if (v == null) return "";
|
|
165
|
+
if (typeof v === "boolean") return v ? "1" : "0";
|
|
166
|
+
if (v instanceof Date) return v.toISOString();
|
|
167
|
+
if (typeof v === "object") return JSON.stringify(v);
|
|
168
|
+
return String(v);
|
|
169
|
+
}
|
|
170
|
+
function flattenCondition(cond, out) {
|
|
171
|
+
for (const [key, raw] of Object.entries(cond)) {
|
|
172
|
+
if (raw === void 0) continue;
|
|
173
|
+
if (key === "$and" && Array.isArray(raw)) {
|
|
174
|
+
for (const sub of raw) {
|
|
175
|
+
if (sub && typeof sub === "object") {
|
|
176
|
+
flattenCondition(sub, out);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (key === "$or" || key === "$not") continue;
|
|
182
|
+
if (raw === null) {
|
|
183
|
+
out.push({ member: key, operator: "notSet", values: [] });
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (typeof raw === "object" && !Array.isArray(raw) && !(raw instanceof Date)) {
|
|
187
|
+
const wrapper = raw;
|
|
188
|
+
const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
|
|
189
|
+
if (opKeys.length > 0) {
|
|
190
|
+
for (const opKey of opKeys) {
|
|
191
|
+
const cubeOp = MONGO_TO_CUBE_OP[opKey];
|
|
192
|
+
if (!cubeOp) continue;
|
|
193
|
+
const v = wrapper[opKey];
|
|
194
|
+
const values = Array.isArray(v) ? v.map(stringifyForCube) : [stringifyForCube(v)];
|
|
195
|
+
out.push({ member: key, operator: cubeOp, values });
|
|
196
|
+
}
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
for (const [nestedKey, nestedVal] of Object.entries(wrapper)) {
|
|
200
|
+
flattenCondition({ [`${key}.${nestedKey}`]: nestedVal }, out);
|
|
201
|
+
}
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (Array.isArray(raw)) {
|
|
205
|
+
out.push({ member: key, operator: "in", values: raw.map(stringifyForCube) });
|
|
206
|
+
} else {
|
|
207
|
+
out.push({ member: key, operator: "equals", values: [stringifyForCube(raw)] });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function normalizeAnalyticsFilters(query) {
|
|
212
|
+
if (!query || typeof query !== "object") return [];
|
|
213
|
+
const out = [];
|
|
214
|
+
const where = query.where;
|
|
215
|
+
if (where && typeof where === "object" && !Array.isArray(where)) {
|
|
216
|
+
flattenCondition(where, out);
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
function coerceFilterValueForSql(s) {
|
|
221
|
+
if (s === "true") return 1;
|
|
222
|
+
if (s === "false") return 0;
|
|
223
|
+
if (s === "null") return null;
|
|
224
|
+
if (/^-?\d+$/.test(s)) {
|
|
225
|
+
const n = Number(s);
|
|
226
|
+
if (Number.isFinite(n)) return n;
|
|
227
|
+
}
|
|
228
|
+
if (/^-?\d+\.\d+$/.test(s)) {
|
|
229
|
+
const n = Number(s);
|
|
230
|
+
if (Number.isFinite(n)) return n;
|
|
231
|
+
}
|
|
232
|
+
return s;
|
|
233
|
+
}
|
|
234
|
+
|
|
149
235
|
// src/strategies/native-sql-strategy.ts
|
|
150
236
|
var NativeSQLStrategy = class {
|
|
151
237
|
constructor() {
|
|
@@ -173,30 +259,33 @@ var NativeSQLStrategy = class {
|
|
|
173
259
|
const params = [];
|
|
174
260
|
const selectClauses = [];
|
|
175
261
|
const groupByClauses = [];
|
|
262
|
+
const tableName = this.extractObjectName(cube);
|
|
263
|
+
const joins = /* @__PURE__ */ new Map();
|
|
176
264
|
if (query.dimensions && query.dimensions.length > 0) {
|
|
177
265
|
for (const dim of query.dimensions) {
|
|
178
|
-
const colExpr = this.resolveDimensionSql(cube, dim);
|
|
266
|
+
const colExpr = this.resolveDimensionSql(cube, dim, tableName, joins);
|
|
179
267
|
selectClauses.push(`${colExpr} AS "${dim}"`);
|
|
180
268
|
groupByClauses.push(colExpr);
|
|
181
269
|
}
|
|
182
270
|
}
|
|
183
271
|
if (query.measures && query.measures.length > 0) {
|
|
184
272
|
for (const measure of query.measures) {
|
|
185
|
-
const aggExpr = this.resolveMeasureSql(cube, measure);
|
|
273
|
+
const aggExpr = this.resolveMeasureSql(cube, measure, tableName, joins);
|
|
186
274
|
selectClauses.push(`${aggExpr} AS "${measure}"`);
|
|
187
275
|
}
|
|
188
276
|
}
|
|
189
277
|
const whereClauses = [];
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
278
|
+
const normalizedFilters = normalizeAnalyticsFilters(query);
|
|
279
|
+
if (normalizedFilters.length > 0) {
|
|
280
|
+
for (const filter of normalizedFilters) {
|
|
281
|
+
const colExpr = this.resolveFieldSql(cube, filter.member, tableName, joins);
|
|
193
282
|
const clause = this.buildFilterClause(colExpr, filter.operator, filter.values, params);
|
|
194
283
|
if (clause) whereClauses.push(clause);
|
|
195
284
|
}
|
|
196
285
|
}
|
|
197
286
|
if (query.timeDimensions && query.timeDimensions.length > 0) {
|
|
198
287
|
for (const td of query.timeDimensions) {
|
|
199
|
-
const colExpr = this.resolveFieldSql(cube, td.dimension);
|
|
288
|
+
const colExpr = this.resolveFieldSql(cube, td.dimension, tableName, joins);
|
|
200
289
|
if (td.dateRange) {
|
|
201
290
|
const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
|
|
202
291
|
if (range.length === 2) {
|
|
@@ -206,8 +295,10 @@ var NativeSQLStrategy = class {
|
|
|
206
295
|
}
|
|
207
296
|
}
|
|
208
297
|
}
|
|
209
|
-
const tableName = this.extractObjectName(cube);
|
|
210
298
|
let sql = `SELECT ${selectClauses.join(", ")} FROM "${tableName}"`;
|
|
299
|
+
if (joins.size > 0) {
|
|
300
|
+
sql += " " + Array.from(joins.values()).join(" ");
|
|
301
|
+
}
|
|
211
302
|
if (whereClauses.length > 0) {
|
|
212
303
|
sql += ` WHERE ${whereClauses.join(" AND ")}`;
|
|
213
304
|
}
|
|
@@ -227,16 +318,77 @@ var NativeSQLStrategy = class {
|
|
|
227
318
|
return { sql, params };
|
|
228
319
|
}
|
|
229
320
|
// ── Helpers ──────────────────────────────────────────────────────
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
321
|
+
/**
|
|
322
|
+
* Resolve a dimension/measure/filter SQL expression that may reference a
|
|
323
|
+
* related table via dot notation (e.g. `account.industry`).
|
|
324
|
+
*
|
|
325
|
+
* When the resolved `sql` contains a dot, treat the prefix as a lookup
|
|
326
|
+
* field on the cube's table and synthesise a `LEFT JOIN` against the
|
|
327
|
+
* related table. The convention (matching the auto-cube generator and
|
|
328
|
+
* ObjectStack object schemas) is:
|
|
329
|
+
*
|
|
330
|
+
* <parentTable>.<lookupField> = <lookupField>.id
|
|
331
|
+
*
|
|
332
|
+
* i.e. the lookup field name on the parent table equals the related
|
|
333
|
+
* table name. This holds for all `Field.lookup({ object: '...' })`
|
|
334
|
+
* declarations where the field is named after its target object.
|
|
335
|
+
*
|
|
336
|
+
* Returns the qualified SQL reference (e.g. `"account"."industry"`).
|
|
337
|
+
* Pure column references (no dot) are returned as-is.
|
|
338
|
+
*/
|
|
339
|
+
qualifyAndRegisterJoin(rawSql, parentTable, joins) {
|
|
340
|
+
if (!rawSql.includes(".")) return rawSql;
|
|
341
|
+
const [alias, ...rest] = rawSql.split(".");
|
|
342
|
+
if (!alias || rest.length === 0) return rawSql;
|
|
343
|
+
const column = rest.join(".");
|
|
344
|
+
if (!joins.has(alias)) {
|
|
345
|
+
joins.set(
|
|
346
|
+
alias,
|
|
347
|
+
`LEFT JOIN "${alias}" ON "${parentTable}"."${alias}" = "${alias}"."id"`
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
return `"${alias}"."${column}"`;
|
|
234
351
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
352
|
+
/**
|
|
353
|
+
* Resolve a member reference (dimension, measure, or filter field) to its
|
|
354
|
+
* cube definition.
|
|
355
|
+
*
|
|
356
|
+
* Accepts three naming conventions:
|
|
357
|
+
* 1. `<cube>.<field>` — the canonical analytics qualifier (stripped to `<field>`).
|
|
358
|
+
* 2. `<lookup>.<field>` — a relation traversal (e.g. `account.industry`).
|
|
359
|
+
* First tried as the literal key, then as the underscore-flattened
|
|
360
|
+
* key (`account_industry`), and finally returned as a synthetic
|
|
361
|
+
* definition whose `sql` is the dotted reference so the JOIN
|
|
362
|
+
* machinery can pick it up.
|
|
363
|
+
* 3. `<field>` — a bare field name on the cube's table.
|
|
364
|
+
*/
|
|
365
|
+
lookupMember(cube, member, kind) {
|
|
366
|
+
const bag = kind === "dimension" ? cube.dimensions : cube.measures;
|
|
367
|
+
if (bag[member]) return bag[member];
|
|
368
|
+
if (member.includes(".")) {
|
|
369
|
+
const [first, ...rest] = member.split(".");
|
|
370
|
+
const tail = rest.join(".");
|
|
371
|
+
if (first === cube.name && bag[tail]) return bag[tail];
|
|
372
|
+
if (bag[tail]) return bag[tail];
|
|
373
|
+
const flat = member.replace(/\./g, "_");
|
|
374
|
+
if (bag[flat]) return bag[flat];
|
|
375
|
+
if (kind === "dimension") {
|
|
376
|
+
return { sql: member, type: "string" };
|
|
377
|
+
}
|
|
378
|
+
} else if (bag[member]) {
|
|
379
|
+
return bag[member];
|
|
380
|
+
}
|
|
381
|
+
return void 0;
|
|
382
|
+
}
|
|
383
|
+
resolveDimensionSql(cube, member, parentTable, joins) {
|
|
384
|
+
const dim = this.lookupMember(cube, member, "dimension");
|
|
385
|
+
const raw = dim ? dim.sql : member.includes(".") ? member.split(".")[1] : member;
|
|
386
|
+
return this.qualifyAndRegisterJoin(raw, parentTable, joins);
|
|
387
|
+
}
|
|
388
|
+
resolveMeasureSql(cube, member, parentTable, joins) {
|
|
389
|
+
const measure = this.lookupMember(cube, member, "measure");
|
|
238
390
|
if (!measure) return `COUNT(*)`;
|
|
239
|
-
const col = measure.sql;
|
|
391
|
+
const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins);
|
|
240
392
|
switch (measure.type) {
|
|
241
393
|
case "count":
|
|
242
394
|
return "COUNT(*)";
|
|
@@ -254,12 +406,12 @@ var NativeSQLStrategy = class {
|
|
|
254
406
|
return `COUNT(*)`;
|
|
255
407
|
}
|
|
256
408
|
}
|
|
257
|
-
resolveFieldSql(cube, member) {
|
|
409
|
+
resolveFieldSql(cube, member, parentTable, joins) {
|
|
410
|
+
const dim = this.lookupMember(cube, member, "dimension");
|
|
411
|
+
if (dim) return this.qualifyAndRegisterJoin(dim.sql, parentTable, joins);
|
|
412
|
+
const measure = this.lookupMember(cube, member, "measure");
|
|
413
|
+
if (measure) return this.qualifyAndRegisterJoin(measure.sql, parentTable, joins);
|
|
258
414
|
const fieldName = member.includes(".") ? member.split(".")[1] : member;
|
|
259
|
-
const dim = cube.dimensions[fieldName];
|
|
260
|
-
if (dim) return dim.sql;
|
|
261
|
-
const measure = cube.measures[fieldName];
|
|
262
|
-
if (measure) return measure.sql;
|
|
263
415
|
return fieldName;
|
|
264
416
|
}
|
|
265
417
|
buildFilterClause(col, operator, values, params) {
|
|
@@ -275,12 +427,20 @@ var NativeSQLStrategy = class {
|
|
|
275
427
|
};
|
|
276
428
|
if (operator === "set") return `${col} IS NOT NULL`;
|
|
277
429
|
if (operator === "notSet") return `${col} IS NULL`;
|
|
430
|
+
if (operator === "in" || operator === "notIn") {
|
|
431
|
+
if (!values || values.length === 0) return null;
|
|
432
|
+
const placeholders = values.map((v) => {
|
|
433
|
+
params.push(coerceFilterValueForSql(v));
|
|
434
|
+
return `$${params.length}`;
|
|
435
|
+
}).join(", ");
|
|
436
|
+
return `${col} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
|
|
437
|
+
}
|
|
278
438
|
const sqlOp = opMap[operator];
|
|
279
439
|
if (!sqlOp || !values || values.length === 0) return null;
|
|
280
440
|
if (operator === "contains" || operator === "notContains") {
|
|
281
441
|
params.push(`%${values[0]}%`);
|
|
282
442
|
} else {
|
|
283
|
-
params.push(values[0]);
|
|
443
|
+
params.push(coerceFilterValueForSql(values[0]));
|
|
284
444
|
}
|
|
285
445
|
return `${col} ${sqlOp} $${params.length}`;
|
|
286
446
|
}
|
|
@@ -291,8 +451,7 @@ var NativeSQLStrategy = class {
|
|
|
291
451
|
const fields = [];
|
|
292
452
|
if (query.dimensions) {
|
|
293
453
|
for (const dim of query.dimensions) {
|
|
294
|
-
const
|
|
295
|
-
const d = cube.dimensions[fieldName];
|
|
454
|
+
const d = this.lookupMember(cube, dim, "dimension");
|
|
296
455
|
fields.push({ name: dim, type: d?.type || "string" });
|
|
297
456
|
}
|
|
298
457
|
}
|
|
@@ -333,8 +492,9 @@ var ObjectQLStrategy = class {
|
|
|
333
492
|
}
|
|
334
493
|
}
|
|
335
494
|
const filter = {};
|
|
336
|
-
|
|
337
|
-
|
|
495
|
+
const normalizedFilters = normalizeAnalyticsFilters(query);
|
|
496
|
+
if (normalizedFilters.length > 0) {
|
|
497
|
+
for (const f of normalizedFilters) {
|
|
338
498
|
const fieldName = this.resolveFieldName(cube, f.member, "any");
|
|
339
499
|
filter[fieldName] = this.convertFilter(f.operator, f.values);
|
|
340
500
|
}
|
|
@@ -391,48 +551,97 @@ var ObjectQLStrategy = class {
|
|
|
391
551
|
return { sql, params: [] };
|
|
392
552
|
}
|
|
393
553
|
// ── Helpers ──────────────────────────────────────────────────────
|
|
554
|
+
/**
|
|
555
|
+
* Resolve a member ref to a `{ sql, type? }` definition.
|
|
556
|
+
*
|
|
557
|
+
* Mirrors `NativeSQLStrategy.lookupMember` so the two strategies
|
|
558
|
+
* accept the same naming conventions:
|
|
559
|
+
* 1. `<cube>.<field>` — canonical analytics qualifier.
|
|
560
|
+
* 2. `<lookup>.<field>` — relation traversal (e.g. `account.industry`).
|
|
561
|
+
* Tries literal key, then underscore-flattened key, then falls
|
|
562
|
+
* back to a synthetic dim whose `sql` is the dotted path so the
|
|
563
|
+
* ObjectQL aggregate engine can traverse it via the lookup field.
|
|
564
|
+
* 3. `<field>` — bare column on the cube's table.
|
|
565
|
+
*/
|
|
566
|
+
lookupMember(cube, member, kind) {
|
|
567
|
+
const bag = kind === "dimension" ? cube.dimensions : cube.measures;
|
|
568
|
+
if (bag[member]) return bag[member];
|
|
569
|
+
if (member.includes(".")) {
|
|
570
|
+
const [first, ...rest] = member.split(".");
|
|
571
|
+
const tail = rest.join(".");
|
|
572
|
+
if (first === cube.name && bag[tail]) return bag[tail];
|
|
573
|
+
if (bag[tail]) return bag[tail];
|
|
574
|
+
const flat = member.replace(/\./g, "_");
|
|
575
|
+
if (bag[flat]) return bag[flat];
|
|
576
|
+
if (kind === "dimension") return { sql: member, type: "string" };
|
|
577
|
+
} else if (bag[member]) {
|
|
578
|
+
return bag[member];
|
|
579
|
+
}
|
|
580
|
+
return void 0;
|
|
581
|
+
}
|
|
394
582
|
resolveFieldName(cube, member, kind) {
|
|
395
|
-
const fieldName = member.includes(".") ? member.split(".")[1] : member;
|
|
396
583
|
if (kind === "dimension" || kind === "any") {
|
|
397
|
-
const dim = cube
|
|
584
|
+
const dim = this.lookupMember(cube, member, "dimension");
|
|
398
585
|
if (dim) return dim.sql.replace(/^\$/, "");
|
|
399
586
|
}
|
|
400
587
|
if (kind === "measure" || kind === "any") {
|
|
401
|
-
const measure = cube
|
|
588
|
+
const measure = this.lookupMember(cube, member, "measure");
|
|
402
589
|
if (measure) return measure.sql.replace(/^\$/, "");
|
|
403
590
|
}
|
|
404
|
-
return
|
|
591
|
+
return member.includes(".") ? member.split(".")[1] : member;
|
|
405
592
|
}
|
|
406
593
|
resolveMeasureAggregation(cube, measureName) {
|
|
594
|
+
const direct = this.lookupMember(cube, measureName, "measure");
|
|
595
|
+
if (direct) {
|
|
596
|
+
return {
|
|
597
|
+
field: direct.sql.replace(/^\$/, ""),
|
|
598
|
+
method: direct.type === "count_distinct" ? "count_distinct" : direct.type
|
|
599
|
+
};
|
|
600
|
+
}
|
|
407
601
|
const fieldName = measureName.includes(".") ? measureName.split(".")[1] : measureName;
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
602
|
+
const aggTypes = ["count", "sum", "avg", "min", "max", "count_distinct"];
|
|
603
|
+
for (const type of aggTypes) {
|
|
604
|
+
const suffix = `_${type}`;
|
|
605
|
+
if (fieldName.endsWith(suffix)) {
|
|
606
|
+
const baseField = fieldName.slice(0, -suffix.length);
|
|
607
|
+
const candidate = cube.measures[baseField];
|
|
608
|
+
if (candidate && candidate.type === type) {
|
|
609
|
+
return {
|
|
610
|
+
field: candidate.sql.replace(/^\$/, ""),
|
|
611
|
+
method: candidate.type === "count_distinct" ? "count_distinct" : candidate.type
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return { field: "*", method: "count" };
|
|
414
617
|
}
|
|
415
618
|
convertFilter(operator, values) {
|
|
416
619
|
if (operator === "set") return { $ne: null };
|
|
417
620
|
if (operator === "notSet") return null;
|
|
418
621
|
if (!values || values.length === 0) return void 0;
|
|
622
|
+
const v0 = coerceFilterValueForSql(values[0]);
|
|
623
|
+
const all = values.map(coerceFilterValueForSql);
|
|
419
624
|
switch (operator) {
|
|
420
625
|
case "equals":
|
|
421
|
-
return
|
|
626
|
+
return v0;
|
|
422
627
|
case "notEquals":
|
|
423
|
-
return { $ne:
|
|
628
|
+
return { $ne: v0 };
|
|
424
629
|
case "gt":
|
|
425
|
-
return { $gt:
|
|
630
|
+
return { $gt: v0 };
|
|
426
631
|
case "gte":
|
|
427
|
-
return { $gte:
|
|
632
|
+
return { $gte: v0 };
|
|
428
633
|
case "lt":
|
|
429
|
-
return { $lt:
|
|
634
|
+
return { $lt: v0 };
|
|
430
635
|
case "lte":
|
|
431
|
-
return { $lte:
|
|
636
|
+
return { $lte: v0 };
|
|
432
637
|
case "contains":
|
|
433
638
|
return { $regex: values[0] };
|
|
639
|
+
case "in":
|
|
640
|
+
return { $in: all };
|
|
641
|
+
case "notIn":
|
|
642
|
+
return { $nin: all };
|
|
434
643
|
default:
|
|
435
|
-
return
|
|
644
|
+
return v0;
|
|
436
645
|
}
|
|
437
646
|
}
|
|
438
647
|
extractObjectName(cube) {
|
|
@@ -442,8 +651,7 @@ var ObjectQLStrategy = class {
|
|
|
442
651
|
const fields = [];
|
|
443
652
|
if (query.dimensions) {
|
|
444
653
|
for (const dim of query.dimensions) {
|
|
445
|
-
const
|
|
446
|
-
const d = cube.dimensions[fieldName];
|
|
654
|
+
const d = this.lookupMember(cube, dim, "dimension");
|
|
447
655
|
fields.push({ name: dim, type: d?.type || "string" });
|
|
448
656
|
}
|
|
449
657
|
}
|
|
@@ -496,6 +704,7 @@ var AnalyticsService = class {
|
|
|
496
704
|
if (!query.cube) {
|
|
497
705
|
throw new Error("Cube name is required in analytics query");
|
|
498
706
|
}
|
|
707
|
+
this.ensureCube(query);
|
|
499
708
|
const strategy = this.resolveStrategy(query);
|
|
500
709
|
this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
501
710
|
return strategy.execute(query, this.strategyCtx);
|
|
@@ -527,11 +736,99 @@ var AnalyticsService = class {
|
|
|
527
736
|
if (!query.cube) {
|
|
528
737
|
throw new Error("Cube name is required for SQL generation");
|
|
529
738
|
}
|
|
739
|
+
this.ensureCube(query);
|
|
530
740
|
const strategy = this.resolveStrategy(query);
|
|
531
741
|
this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
532
742
|
return strategy.generateSql(query, this.strategyCtx);
|
|
533
743
|
}
|
|
534
744
|
// ── Internal ─────────────────────────────────────────────────────
|
|
745
|
+
/**
|
|
746
|
+
* Ensure a cube exists for the given query and that it knows about every
|
|
747
|
+
* measure referenced by the query.
|
|
748
|
+
*
|
|
749
|
+
* - If no cube is registered for `query.cube`, infer a minimal cube from
|
|
750
|
+
* the query so downstream strategies (which assume `cube.sql` exists)
|
|
751
|
+
* don't crash.
|
|
752
|
+
* - If a cube exists but the query references measures that aren't in
|
|
753
|
+
* `cube.measures` (e.g. `amount_sum`, `amount_avg` emitted by dashboard
|
|
754
|
+
* widget translators), inject suffix-inferred Metric entries so the
|
|
755
|
+
* strategies pick the right aggregation function and field.
|
|
756
|
+
*/
|
|
757
|
+
ensureCube(query) {
|
|
758
|
+
const name = query.cube;
|
|
759
|
+
let cube = this.cubeRegistry.get(name);
|
|
760
|
+
if (!cube) {
|
|
761
|
+
cube = this.inferCubeFromQuery(query);
|
|
762
|
+
this.cubeRegistry.register(cube);
|
|
763
|
+
this.logger.warn(
|
|
764
|
+
`[Analytics] No cube registered for "${name}"; auto-inferred a minimal cube (sql="${name}", measures=${Object.keys(cube.measures).join(",") || "(none)"}, dimensions=${Object.keys(cube.dimensions).join(",") || "(none)"}). Define an explicit Cube in your stack for full control.`
|
|
765
|
+
);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
const stripPrefix = (m) => m.includes(".") ? m.split(".").slice(1).join(".") : m;
|
|
769
|
+
const extraMeasures = {};
|
|
770
|
+
for (const m of query.measures || []) {
|
|
771
|
+
const key = stripPrefix(m);
|
|
772
|
+
if (cube.measures[key] || extraMeasures[key]) continue;
|
|
773
|
+
extraMeasures[key] = inferMeasure(key);
|
|
774
|
+
}
|
|
775
|
+
if (Object.keys(extraMeasures).length > 0) {
|
|
776
|
+
const augmented = {
|
|
777
|
+
...cube,
|
|
778
|
+
measures: { ...cube.measures, ...extraMeasures }
|
|
779
|
+
};
|
|
780
|
+
this.cubeRegistry.register(augmented);
|
|
781
|
+
this.logger.debug(
|
|
782
|
+
`[Analytics] Augmented cube "${name}" with inferred measures: ${Object.keys(extraMeasures).join(",")}`
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
/** Build a minimal Cube from the fields referenced by an AnalyticsQuery. */
|
|
787
|
+
inferCubeFromQuery(query) {
|
|
788
|
+
const cubeName = query.cube;
|
|
789
|
+
const measures = {};
|
|
790
|
+
const dimensions = {};
|
|
791
|
+
const stripPrefix = (m) => m.includes(".") ? m.split(".").slice(1).join(".") : m;
|
|
792
|
+
measures.count = { name: "count", label: "Count", type: "count", sql: "*" };
|
|
793
|
+
for (const m of query.measures || []) {
|
|
794
|
+
const key = stripPrefix(m);
|
|
795
|
+
if (measures[key]) continue;
|
|
796
|
+
const inferred = inferMeasure(key);
|
|
797
|
+
measures[key] = inferred;
|
|
798
|
+
}
|
|
799
|
+
for (const d of query.dimensions || []) {
|
|
800
|
+
const key = stripPrefix(d);
|
|
801
|
+
if (dimensions[key]) continue;
|
|
802
|
+
dimensions[key] = { name: key, label: key, type: "string", sql: key };
|
|
803
|
+
}
|
|
804
|
+
if (query.where && typeof query.where === "object" && !Array.isArray(query.where)) {
|
|
805
|
+
for (const key of Object.keys(query.where)) {
|
|
806
|
+
if (key.startsWith("$")) continue;
|
|
807
|
+
const stripped = stripPrefix(key);
|
|
808
|
+
if (dimensions[stripped] || measures[stripped]) continue;
|
|
809
|
+
dimensions[stripped] = { name: stripped, label: stripped, type: "string", sql: stripped };
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
for (const td of query.timeDimensions || []) {
|
|
813
|
+
const key = stripPrefix(td.dimension);
|
|
814
|
+
if (dimensions[key]) continue;
|
|
815
|
+
dimensions[key] = {
|
|
816
|
+
name: key,
|
|
817
|
+
label: key,
|
|
818
|
+
type: "time",
|
|
819
|
+
sql: key,
|
|
820
|
+
granularities: ["day", "week", "month", "quarter", "year"]
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
return {
|
|
824
|
+
name: cubeName,
|
|
825
|
+
title: cubeName,
|
|
826
|
+
sql: cubeName,
|
|
827
|
+
measures,
|
|
828
|
+
dimensions,
|
|
829
|
+
public: false
|
|
830
|
+
};
|
|
831
|
+
}
|
|
535
832
|
/**
|
|
536
833
|
* Walk the strategy chain and return the first strategy that can handle the query.
|
|
537
834
|
*/
|
|
@@ -546,6 +843,26 @@ var AnalyticsService = class {
|
|
|
546
843
|
);
|
|
547
844
|
}
|
|
548
845
|
};
|
|
846
|
+
function inferMeasure(key) {
|
|
847
|
+
if (key === "count") {
|
|
848
|
+
return { name: "count", label: "Count", type: "count", sql: "*" };
|
|
849
|
+
}
|
|
850
|
+
const suffixes = [
|
|
851
|
+
["_count_distinct", "count_distinct"],
|
|
852
|
+
["_sum", "sum"],
|
|
853
|
+
["_avg", "avg"],
|
|
854
|
+
["_average", "avg"],
|
|
855
|
+
["_min", "min"],
|
|
856
|
+
["_max", "max"]
|
|
857
|
+
];
|
|
858
|
+
for (const [suffix, type] of suffixes) {
|
|
859
|
+
if (key.endsWith(suffix)) {
|
|
860
|
+
const field = key.slice(0, -suffix.length) || "*";
|
|
861
|
+
return { name: key, label: key, type, sql: field };
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return { name: key, label: key, type: "sum", sql: key };
|
|
865
|
+
}
|
|
549
866
|
var FallbackDelegateStrategy = class {
|
|
550
867
|
constructor() {
|
|
551
868
|
this.name = "FallbackDelegateStrategy";
|
|
@@ -588,14 +905,89 @@ var AnalyticsServicePlugin = class {
|
|
|
588
905
|
}
|
|
589
906
|
} catch {
|
|
590
907
|
}
|
|
908
|
+
let executeAggregate = this.options.executeAggregate;
|
|
909
|
+
let autoBridged = false;
|
|
910
|
+
if (!executeAggregate) {
|
|
911
|
+
const tryGetDataEngine = () => {
|
|
912
|
+
try {
|
|
913
|
+
const svc = ctx.getService("data");
|
|
914
|
+
return svc && typeof svc.aggregate === "function" ? svc : void 0;
|
|
915
|
+
} catch {
|
|
916
|
+
return void 0;
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
if (!tryGetDataEngine()) {
|
|
920
|
+
ctx.logger.warn(
|
|
921
|
+
'[Analytics] No "data" service registered yet at init; will retry per-query. Register ObjectQLPlugin or pass executeAggregate.'
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
executeAggregate = async (objectName, { groupBy, aggregations, filter }) => {
|
|
925
|
+
const engine = tryGetDataEngine();
|
|
926
|
+
if (!engine) {
|
|
927
|
+
throw new Error(
|
|
928
|
+
'[Analytics] Cannot execute aggregate: no IDataEngine ("data") service is registered. Add ObjectQLPlugin to the kernel or supply AnalyticsServicePlugin({ executeAggregate }).'
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
const rows = await engine.aggregate(objectName, {
|
|
932
|
+
where: filter,
|
|
933
|
+
groupBy,
|
|
934
|
+
aggregations: aggregations?.map((a) => ({
|
|
935
|
+
function: a.method,
|
|
936
|
+
field: a.field,
|
|
937
|
+
alias: a.alias
|
|
938
|
+
}))
|
|
939
|
+
});
|
|
940
|
+
return rows;
|
|
941
|
+
};
|
|
942
|
+
autoBridged = true;
|
|
943
|
+
}
|
|
944
|
+
let executeRawSql = this.options.executeRawSql;
|
|
945
|
+
let autoBridgedRawSql = false;
|
|
946
|
+
if (!executeRawSql) {
|
|
947
|
+
const tryGetExecutor = () => {
|
|
948
|
+
try {
|
|
949
|
+
const svc = ctx.getService("data");
|
|
950
|
+
return svc && typeof svc.execute === "function" ? svc : void 0;
|
|
951
|
+
} catch {
|
|
952
|
+
return void 0;
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
executeRawSql = async (_objectName, sql, params) => {
|
|
956
|
+
const engine = tryGetExecutor();
|
|
957
|
+
if (!engine || !engine.execute) {
|
|
958
|
+
throw new Error(
|
|
959
|
+
'[Analytics] Cannot execute raw SQL: no IDataEngine ("data") service with execute() is registered.'
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
const knexSql = sql.replace(/\$(\d+)/g, "?");
|
|
963
|
+
const result = await engine.execute(knexSql, { args: params });
|
|
964
|
+
if (Array.isArray(result)) return result;
|
|
965
|
+
if (result && typeof result === "object" && "rows" in result) {
|
|
966
|
+
return result.rows;
|
|
967
|
+
}
|
|
968
|
+
return [];
|
|
969
|
+
};
|
|
970
|
+
autoBridgedRawSql = true;
|
|
971
|
+
}
|
|
972
|
+
const queryCapabilities = this.options.queryCapabilities ?? (() => ({
|
|
973
|
+
nativeSql: !!executeRawSql,
|
|
974
|
+
objectqlAggregate: !!executeAggregate,
|
|
975
|
+
inMemory: false
|
|
976
|
+
}));
|
|
591
977
|
const config = {
|
|
592
978
|
cubes: this.options.cubes,
|
|
593
979
|
logger: ctx.logger,
|
|
594
|
-
queryCapabilities
|
|
595
|
-
executeRawSql
|
|
596
|
-
executeAggregate
|
|
980
|
+
queryCapabilities,
|
|
981
|
+
executeRawSql,
|
|
982
|
+
executeAggregate,
|
|
597
983
|
fallbackService
|
|
598
984
|
};
|
|
985
|
+
if (autoBridged) {
|
|
986
|
+
ctx.logger.info('[Analytics] Auto-bridged executeAggregate \u2192 "data" service (IDataEngine)');
|
|
987
|
+
}
|
|
988
|
+
if (autoBridgedRawSql) {
|
|
989
|
+
ctx.logger.info('[Analytics] Auto-bridged executeRawSql \u2192 "data" service (IDataEngine.execute)');
|
|
990
|
+
}
|
|
599
991
|
this.service = new AnalyticsService(config);
|
|
600
992
|
if (fallbackService) {
|
|
601
993
|
ctx.replaceService("analytics", this.service);
|