@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.js
CHANGED
|
@@ -116,6 +116,92 @@ var CubeRegistry = class {
|
|
|
116
116
|
}
|
|
117
117
|
};
|
|
118
118
|
|
|
119
|
+
// src/strategies/filter-normalizer.ts
|
|
120
|
+
var MONGO_TO_CUBE_OP = {
|
|
121
|
+
$eq: "equals",
|
|
122
|
+
$ne: "notEquals",
|
|
123
|
+
$gt: "gt",
|
|
124
|
+
$gte: "gte",
|
|
125
|
+
$lt: "lt",
|
|
126
|
+
$lte: "lte",
|
|
127
|
+
$in: "in",
|
|
128
|
+
$nin: "notIn",
|
|
129
|
+
$contains: "contains",
|
|
130
|
+
$notContains: "notContains",
|
|
131
|
+
$exists: "set"
|
|
132
|
+
};
|
|
133
|
+
function stringifyForCube(v) {
|
|
134
|
+
if (v == null) return "";
|
|
135
|
+
if (typeof v === "boolean") return v ? "1" : "0";
|
|
136
|
+
if (v instanceof Date) return v.toISOString();
|
|
137
|
+
if (typeof v === "object") return JSON.stringify(v);
|
|
138
|
+
return String(v);
|
|
139
|
+
}
|
|
140
|
+
function flattenCondition(cond, out) {
|
|
141
|
+
for (const [key, raw] of Object.entries(cond)) {
|
|
142
|
+
if (raw === void 0) continue;
|
|
143
|
+
if (key === "$and" && Array.isArray(raw)) {
|
|
144
|
+
for (const sub of raw) {
|
|
145
|
+
if (sub && typeof sub === "object") {
|
|
146
|
+
flattenCondition(sub, out);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (key === "$or" || key === "$not") continue;
|
|
152
|
+
if (raw === null) {
|
|
153
|
+
out.push({ member: key, operator: "notSet", values: [] });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (typeof raw === "object" && !Array.isArray(raw) && !(raw instanceof Date)) {
|
|
157
|
+
const wrapper = raw;
|
|
158
|
+
const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
|
|
159
|
+
if (opKeys.length > 0) {
|
|
160
|
+
for (const opKey of opKeys) {
|
|
161
|
+
const cubeOp = MONGO_TO_CUBE_OP[opKey];
|
|
162
|
+
if (!cubeOp) continue;
|
|
163
|
+
const v = wrapper[opKey];
|
|
164
|
+
const values = Array.isArray(v) ? v.map(stringifyForCube) : [stringifyForCube(v)];
|
|
165
|
+
out.push({ member: key, operator: cubeOp, values });
|
|
166
|
+
}
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
for (const [nestedKey, nestedVal] of Object.entries(wrapper)) {
|
|
170
|
+
flattenCondition({ [`${key}.${nestedKey}`]: nestedVal }, out);
|
|
171
|
+
}
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (Array.isArray(raw)) {
|
|
175
|
+
out.push({ member: key, operator: "in", values: raw.map(stringifyForCube) });
|
|
176
|
+
} else {
|
|
177
|
+
out.push({ member: key, operator: "equals", values: [stringifyForCube(raw)] });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function normalizeAnalyticsFilters(query) {
|
|
182
|
+
if (!query || typeof query !== "object") return [];
|
|
183
|
+
const out = [];
|
|
184
|
+
const where = query.where;
|
|
185
|
+
if (where && typeof where === "object" && !Array.isArray(where)) {
|
|
186
|
+
flattenCondition(where, out);
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
function coerceFilterValueForSql(s) {
|
|
191
|
+
if (s === "true") return 1;
|
|
192
|
+
if (s === "false") return 0;
|
|
193
|
+
if (s === "null") return null;
|
|
194
|
+
if (/^-?\d+$/.test(s)) {
|
|
195
|
+
const n = Number(s);
|
|
196
|
+
if (Number.isFinite(n)) return n;
|
|
197
|
+
}
|
|
198
|
+
if (/^-?\d+\.\d+$/.test(s)) {
|
|
199
|
+
const n = Number(s);
|
|
200
|
+
if (Number.isFinite(n)) return n;
|
|
201
|
+
}
|
|
202
|
+
return s;
|
|
203
|
+
}
|
|
204
|
+
|
|
119
205
|
// src/strategies/native-sql-strategy.ts
|
|
120
206
|
var NativeSQLStrategy = class {
|
|
121
207
|
constructor() {
|
|
@@ -143,30 +229,33 @@ var NativeSQLStrategy = class {
|
|
|
143
229
|
const params = [];
|
|
144
230
|
const selectClauses = [];
|
|
145
231
|
const groupByClauses = [];
|
|
232
|
+
const tableName = this.extractObjectName(cube);
|
|
233
|
+
const joins = /* @__PURE__ */ new Map();
|
|
146
234
|
if (query.dimensions && query.dimensions.length > 0) {
|
|
147
235
|
for (const dim of query.dimensions) {
|
|
148
|
-
const colExpr = this.resolveDimensionSql(cube, dim);
|
|
236
|
+
const colExpr = this.resolveDimensionSql(cube, dim, tableName, joins);
|
|
149
237
|
selectClauses.push(`${colExpr} AS "${dim}"`);
|
|
150
238
|
groupByClauses.push(colExpr);
|
|
151
239
|
}
|
|
152
240
|
}
|
|
153
241
|
if (query.measures && query.measures.length > 0) {
|
|
154
242
|
for (const measure of query.measures) {
|
|
155
|
-
const aggExpr = this.resolveMeasureSql(cube, measure);
|
|
243
|
+
const aggExpr = this.resolveMeasureSql(cube, measure, tableName, joins);
|
|
156
244
|
selectClauses.push(`${aggExpr} AS "${measure}"`);
|
|
157
245
|
}
|
|
158
246
|
}
|
|
159
247
|
const whereClauses = [];
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
248
|
+
const normalizedFilters = normalizeAnalyticsFilters(query);
|
|
249
|
+
if (normalizedFilters.length > 0) {
|
|
250
|
+
for (const filter of normalizedFilters) {
|
|
251
|
+
const colExpr = this.resolveFieldSql(cube, filter.member, tableName, joins);
|
|
163
252
|
const clause = this.buildFilterClause(colExpr, filter.operator, filter.values, params);
|
|
164
253
|
if (clause) whereClauses.push(clause);
|
|
165
254
|
}
|
|
166
255
|
}
|
|
167
256
|
if (query.timeDimensions && query.timeDimensions.length > 0) {
|
|
168
257
|
for (const td of query.timeDimensions) {
|
|
169
|
-
const colExpr = this.resolveFieldSql(cube, td.dimension);
|
|
258
|
+
const colExpr = this.resolveFieldSql(cube, td.dimension, tableName, joins);
|
|
170
259
|
if (td.dateRange) {
|
|
171
260
|
const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
|
|
172
261
|
if (range.length === 2) {
|
|
@@ -176,8 +265,10 @@ var NativeSQLStrategy = class {
|
|
|
176
265
|
}
|
|
177
266
|
}
|
|
178
267
|
}
|
|
179
|
-
const tableName = this.extractObjectName(cube);
|
|
180
268
|
let sql = `SELECT ${selectClauses.join(", ")} FROM "${tableName}"`;
|
|
269
|
+
if (joins.size > 0) {
|
|
270
|
+
sql += " " + Array.from(joins.values()).join(" ");
|
|
271
|
+
}
|
|
181
272
|
if (whereClauses.length > 0) {
|
|
182
273
|
sql += ` WHERE ${whereClauses.join(" AND ")}`;
|
|
183
274
|
}
|
|
@@ -197,16 +288,77 @@ var NativeSQLStrategy = class {
|
|
|
197
288
|
return { sql, params };
|
|
198
289
|
}
|
|
199
290
|
// ── Helpers ──────────────────────────────────────────────────────
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
291
|
+
/**
|
|
292
|
+
* Resolve a dimension/measure/filter SQL expression that may reference a
|
|
293
|
+
* related table via dot notation (e.g. `account.industry`).
|
|
294
|
+
*
|
|
295
|
+
* When the resolved `sql` contains a dot, treat the prefix as a lookup
|
|
296
|
+
* field on the cube's table and synthesise a `LEFT JOIN` against the
|
|
297
|
+
* related table. The convention (matching the auto-cube generator and
|
|
298
|
+
* ObjectStack object schemas) is:
|
|
299
|
+
*
|
|
300
|
+
* <parentTable>.<lookupField> = <lookupField>.id
|
|
301
|
+
*
|
|
302
|
+
* i.e. the lookup field name on the parent table equals the related
|
|
303
|
+
* table name. This holds for all `Field.lookup({ object: '...' })`
|
|
304
|
+
* declarations where the field is named after its target object.
|
|
305
|
+
*
|
|
306
|
+
* Returns the qualified SQL reference (e.g. `"account"."industry"`).
|
|
307
|
+
* Pure column references (no dot) are returned as-is.
|
|
308
|
+
*/
|
|
309
|
+
qualifyAndRegisterJoin(rawSql, parentTable, joins) {
|
|
310
|
+
if (!rawSql.includes(".")) return rawSql;
|
|
311
|
+
const [alias, ...rest] = rawSql.split(".");
|
|
312
|
+
if (!alias || rest.length === 0) return rawSql;
|
|
313
|
+
const column = rest.join(".");
|
|
314
|
+
if (!joins.has(alias)) {
|
|
315
|
+
joins.set(
|
|
316
|
+
alias,
|
|
317
|
+
`LEFT JOIN "${alias}" ON "${parentTable}"."${alias}" = "${alias}"."id"`
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
return `"${alias}"."${column}"`;
|
|
204
321
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
322
|
+
/**
|
|
323
|
+
* Resolve a member reference (dimension, measure, or filter field) to its
|
|
324
|
+
* cube definition.
|
|
325
|
+
*
|
|
326
|
+
* Accepts three naming conventions:
|
|
327
|
+
* 1. `<cube>.<field>` — the canonical analytics qualifier (stripped to `<field>`).
|
|
328
|
+
* 2. `<lookup>.<field>` — a relation traversal (e.g. `account.industry`).
|
|
329
|
+
* First tried as the literal key, then as the underscore-flattened
|
|
330
|
+
* key (`account_industry`), and finally returned as a synthetic
|
|
331
|
+
* definition whose `sql` is the dotted reference so the JOIN
|
|
332
|
+
* machinery can pick it up.
|
|
333
|
+
* 3. `<field>` — a bare field name on the cube's table.
|
|
334
|
+
*/
|
|
335
|
+
lookupMember(cube, member, kind) {
|
|
336
|
+
const bag = kind === "dimension" ? cube.dimensions : cube.measures;
|
|
337
|
+
if (bag[member]) return bag[member];
|
|
338
|
+
if (member.includes(".")) {
|
|
339
|
+
const [first, ...rest] = member.split(".");
|
|
340
|
+
const tail = rest.join(".");
|
|
341
|
+
if (first === cube.name && bag[tail]) return bag[tail];
|
|
342
|
+
if (bag[tail]) return bag[tail];
|
|
343
|
+
const flat = member.replace(/\./g, "_");
|
|
344
|
+
if (bag[flat]) return bag[flat];
|
|
345
|
+
if (kind === "dimension") {
|
|
346
|
+
return { sql: member, type: "string" };
|
|
347
|
+
}
|
|
348
|
+
} else if (bag[member]) {
|
|
349
|
+
return bag[member];
|
|
350
|
+
}
|
|
351
|
+
return void 0;
|
|
352
|
+
}
|
|
353
|
+
resolveDimensionSql(cube, member, parentTable, joins) {
|
|
354
|
+
const dim = this.lookupMember(cube, member, "dimension");
|
|
355
|
+
const raw = dim ? dim.sql : member.includes(".") ? member.split(".")[1] : member;
|
|
356
|
+
return this.qualifyAndRegisterJoin(raw, parentTable, joins);
|
|
357
|
+
}
|
|
358
|
+
resolveMeasureSql(cube, member, parentTable, joins) {
|
|
359
|
+
const measure = this.lookupMember(cube, member, "measure");
|
|
208
360
|
if (!measure) return `COUNT(*)`;
|
|
209
|
-
const col = measure.sql;
|
|
361
|
+
const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins);
|
|
210
362
|
switch (measure.type) {
|
|
211
363
|
case "count":
|
|
212
364
|
return "COUNT(*)";
|
|
@@ -224,12 +376,12 @@ var NativeSQLStrategy = class {
|
|
|
224
376
|
return `COUNT(*)`;
|
|
225
377
|
}
|
|
226
378
|
}
|
|
227
|
-
resolveFieldSql(cube, member) {
|
|
379
|
+
resolveFieldSql(cube, member, parentTable, joins) {
|
|
380
|
+
const dim = this.lookupMember(cube, member, "dimension");
|
|
381
|
+
if (dim) return this.qualifyAndRegisterJoin(dim.sql, parentTable, joins);
|
|
382
|
+
const measure = this.lookupMember(cube, member, "measure");
|
|
383
|
+
if (measure) return this.qualifyAndRegisterJoin(measure.sql, parentTable, joins);
|
|
228
384
|
const fieldName = member.includes(".") ? member.split(".")[1] : member;
|
|
229
|
-
const dim = cube.dimensions[fieldName];
|
|
230
|
-
if (dim) return dim.sql;
|
|
231
|
-
const measure = cube.measures[fieldName];
|
|
232
|
-
if (measure) return measure.sql;
|
|
233
385
|
return fieldName;
|
|
234
386
|
}
|
|
235
387
|
buildFilterClause(col, operator, values, params) {
|
|
@@ -245,12 +397,20 @@ var NativeSQLStrategy = class {
|
|
|
245
397
|
};
|
|
246
398
|
if (operator === "set") return `${col} IS NOT NULL`;
|
|
247
399
|
if (operator === "notSet") return `${col} IS NULL`;
|
|
400
|
+
if (operator === "in" || operator === "notIn") {
|
|
401
|
+
if (!values || values.length === 0) return null;
|
|
402
|
+
const placeholders = values.map((v) => {
|
|
403
|
+
params.push(coerceFilterValueForSql(v));
|
|
404
|
+
return `$${params.length}`;
|
|
405
|
+
}).join(", ");
|
|
406
|
+
return `${col} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
|
|
407
|
+
}
|
|
248
408
|
const sqlOp = opMap[operator];
|
|
249
409
|
if (!sqlOp || !values || values.length === 0) return null;
|
|
250
410
|
if (operator === "contains" || operator === "notContains") {
|
|
251
411
|
params.push(`%${values[0]}%`);
|
|
252
412
|
} else {
|
|
253
|
-
params.push(values[0]);
|
|
413
|
+
params.push(coerceFilterValueForSql(values[0]));
|
|
254
414
|
}
|
|
255
415
|
return `${col} ${sqlOp} $${params.length}`;
|
|
256
416
|
}
|
|
@@ -261,8 +421,7 @@ var NativeSQLStrategy = class {
|
|
|
261
421
|
const fields = [];
|
|
262
422
|
if (query.dimensions) {
|
|
263
423
|
for (const dim of query.dimensions) {
|
|
264
|
-
const
|
|
265
|
-
const d = cube.dimensions[fieldName];
|
|
424
|
+
const d = this.lookupMember(cube, dim, "dimension");
|
|
266
425
|
fields.push({ name: dim, type: d?.type || "string" });
|
|
267
426
|
}
|
|
268
427
|
}
|
|
@@ -303,8 +462,9 @@ var ObjectQLStrategy = class {
|
|
|
303
462
|
}
|
|
304
463
|
}
|
|
305
464
|
const filter = {};
|
|
306
|
-
|
|
307
|
-
|
|
465
|
+
const normalizedFilters = normalizeAnalyticsFilters(query);
|
|
466
|
+
if (normalizedFilters.length > 0) {
|
|
467
|
+
for (const f of normalizedFilters) {
|
|
308
468
|
const fieldName = this.resolveFieldName(cube, f.member, "any");
|
|
309
469
|
filter[fieldName] = this.convertFilter(f.operator, f.values);
|
|
310
470
|
}
|
|
@@ -361,48 +521,97 @@ var ObjectQLStrategy = class {
|
|
|
361
521
|
return { sql, params: [] };
|
|
362
522
|
}
|
|
363
523
|
// ── Helpers ──────────────────────────────────────────────────────
|
|
524
|
+
/**
|
|
525
|
+
* Resolve a member ref to a `{ sql, type? }` definition.
|
|
526
|
+
*
|
|
527
|
+
* Mirrors `NativeSQLStrategy.lookupMember` so the two strategies
|
|
528
|
+
* accept the same naming conventions:
|
|
529
|
+
* 1. `<cube>.<field>` — canonical analytics qualifier.
|
|
530
|
+
* 2. `<lookup>.<field>` — relation traversal (e.g. `account.industry`).
|
|
531
|
+
* Tries literal key, then underscore-flattened key, then falls
|
|
532
|
+
* back to a synthetic dim whose `sql` is the dotted path so the
|
|
533
|
+
* ObjectQL aggregate engine can traverse it via the lookup field.
|
|
534
|
+
* 3. `<field>` — bare column on the cube's table.
|
|
535
|
+
*/
|
|
536
|
+
lookupMember(cube, member, kind) {
|
|
537
|
+
const bag = kind === "dimension" ? cube.dimensions : cube.measures;
|
|
538
|
+
if (bag[member]) return bag[member];
|
|
539
|
+
if (member.includes(".")) {
|
|
540
|
+
const [first, ...rest] = member.split(".");
|
|
541
|
+
const tail = rest.join(".");
|
|
542
|
+
if (first === cube.name && bag[tail]) return bag[tail];
|
|
543
|
+
if (bag[tail]) return bag[tail];
|
|
544
|
+
const flat = member.replace(/\./g, "_");
|
|
545
|
+
if (bag[flat]) return bag[flat];
|
|
546
|
+
if (kind === "dimension") return { sql: member, type: "string" };
|
|
547
|
+
} else if (bag[member]) {
|
|
548
|
+
return bag[member];
|
|
549
|
+
}
|
|
550
|
+
return void 0;
|
|
551
|
+
}
|
|
364
552
|
resolveFieldName(cube, member, kind) {
|
|
365
|
-
const fieldName = member.includes(".") ? member.split(".")[1] : member;
|
|
366
553
|
if (kind === "dimension" || kind === "any") {
|
|
367
|
-
const dim = cube
|
|
554
|
+
const dim = this.lookupMember(cube, member, "dimension");
|
|
368
555
|
if (dim) return dim.sql.replace(/^\$/, "");
|
|
369
556
|
}
|
|
370
557
|
if (kind === "measure" || kind === "any") {
|
|
371
|
-
const measure = cube
|
|
558
|
+
const measure = this.lookupMember(cube, member, "measure");
|
|
372
559
|
if (measure) return measure.sql.replace(/^\$/, "");
|
|
373
560
|
}
|
|
374
|
-
return
|
|
561
|
+
return member.includes(".") ? member.split(".")[1] : member;
|
|
375
562
|
}
|
|
376
563
|
resolveMeasureAggregation(cube, measureName) {
|
|
564
|
+
const direct = this.lookupMember(cube, measureName, "measure");
|
|
565
|
+
if (direct) {
|
|
566
|
+
return {
|
|
567
|
+
field: direct.sql.replace(/^\$/, ""),
|
|
568
|
+
method: direct.type === "count_distinct" ? "count_distinct" : direct.type
|
|
569
|
+
};
|
|
570
|
+
}
|
|
377
571
|
const fieldName = measureName.includes(".") ? measureName.split(".")[1] : measureName;
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
572
|
+
const aggTypes = ["count", "sum", "avg", "min", "max", "count_distinct"];
|
|
573
|
+
for (const type of aggTypes) {
|
|
574
|
+
const suffix = `_${type}`;
|
|
575
|
+
if (fieldName.endsWith(suffix)) {
|
|
576
|
+
const baseField = fieldName.slice(0, -suffix.length);
|
|
577
|
+
const candidate = cube.measures[baseField];
|
|
578
|
+
if (candidate && candidate.type === type) {
|
|
579
|
+
return {
|
|
580
|
+
field: candidate.sql.replace(/^\$/, ""),
|
|
581
|
+
method: candidate.type === "count_distinct" ? "count_distinct" : candidate.type
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return { field: "*", method: "count" };
|
|
384
587
|
}
|
|
385
588
|
convertFilter(operator, values) {
|
|
386
589
|
if (operator === "set") return { $ne: null };
|
|
387
590
|
if (operator === "notSet") return null;
|
|
388
591
|
if (!values || values.length === 0) return void 0;
|
|
592
|
+
const v0 = coerceFilterValueForSql(values[0]);
|
|
593
|
+
const all = values.map(coerceFilterValueForSql);
|
|
389
594
|
switch (operator) {
|
|
390
595
|
case "equals":
|
|
391
|
-
return
|
|
596
|
+
return v0;
|
|
392
597
|
case "notEquals":
|
|
393
|
-
return { $ne:
|
|
598
|
+
return { $ne: v0 };
|
|
394
599
|
case "gt":
|
|
395
|
-
return { $gt:
|
|
600
|
+
return { $gt: v0 };
|
|
396
601
|
case "gte":
|
|
397
|
-
return { $gte:
|
|
602
|
+
return { $gte: v0 };
|
|
398
603
|
case "lt":
|
|
399
|
-
return { $lt:
|
|
604
|
+
return { $lt: v0 };
|
|
400
605
|
case "lte":
|
|
401
|
-
return { $lte:
|
|
606
|
+
return { $lte: v0 };
|
|
402
607
|
case "contains":
|
|
403
608
|
return { $regex: values[0] };
|
|
609
|
+
case "in":
|
|
610
|
+
return { $in: all };
|
|
611
|
+
case "notIn":
|
|
612
|
+
return { $nin: all };
|
|
404
613
|
default:
|
|
405
|
-
return
|
|
614
|
+
return v0;
|
|
406
615
|
}
|
|
407
616
|
}
|
|
408
617
|
extractObjectName(cube) {
|
|
@@ -412,8 +621,7 @@ var ObjectQLStrategy = class {
|
|
|
412
621
|
const fields = [];
|
|
413
622
|
if (query.dimensions) {
|
|
414
623
|
for (const dim of query.dimensions) {
|
|
415
|
-
const
|
|
416
|
-
const d = cube.dimensions[fieldName];
|
|
624
|
+
const d = this.lookupMember(cube, dim, "dimension");
|
|
417
625
|
fields.push({ name: dim, type: d?.type || "string" });
|
|
418
626
|
}
|
|
419
627
|
}
|
|
@@ -466,6 +674,7 @@ var AnalyticsService = class {
|
|
|
466
674
|
if (!query.cube) {
|
|
467
675
|
throw new Error("Cube name is required in analytics query");
|
|
468
676
|
}
|
|
677
|
+
this.ensureCube(query);
|
|
469
678
|
const strategy = this.resolveStrategy(query);
|
|
470
679
|
this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
471
680
|
return strategy.execute(query, this.strategyCtx);
|
|
@@ -497,11 +706,99 @@ var AnalyticsService = class {
|
|
|
497
706
|
if (!query.cube) {
|
|
498
707
|
throw new Error("Cube name is required for SQL generation");
|
|
499
708
|
}
|
|
709
|
+
this.ensureCube(query);
|
|
500
710
|
const strategy = this.resolveStrategy(query);
|
|
501
711
|
this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
502
712
|
return strategy.generateSql(query, this.strategyCtx);
|
|
503
713
|
}
|
|
504
714
|
// ── Internal ─────────────────────────────────────────────────────
|
|
715
|
+
/**
|
|
716
|
+
* Ensure a cube exists for the given query and that it knows about every
|
|
717
|
+
* measure referenced by the query.
|
|
718
|
+
*
|
|
719
|
+
* - If no cube is registered for `query.cube`, infer a minimal cube from
|
|
720
|
+
* the query so downstream strategies (which assume `cube.sql` exists)
|
|
721
|
+
* don't crash.
|
|
722
|
+
* - If a cube exists but the query references measures that aren't in
|
|
723
|
+
* `cube.measures` (e.g. `amount_sum`, `amount_avg` emitted by dashboard
|
|
724
|
+
* widget translators), inject suffix-inferred Metric entries so the
|
|
725
|
+
* strategies pick the right aggregation function and field.
|
|
726
|
+
*/
|
|
727
|
+
ensureCube(query) {
|
|
728
|
+
const name = query.cube;
|
|
729
|
+
let cube = this.cubeRegistry.get(name);
|
|
730
|
+
if (!cube) {
|
|
731
|
+
cube = this.inferCubeFromQuery(query);
|
|
732
|
+
this.cubeRegistry.register(cube);
|
|
733
|
+
this.logger.warn(
|
|
734
|
+
`[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.`
|
|
735
|
+
);
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
const stripPrefix = (m) => m.includes(".") ? m.split(".").slice(1).join(".") : m;
|
|
739
|
+
const extraMeasures = {};
|
|
740
|
+
for (const m of query.measures || []) {
|
|
741
|
+
const key = stripPrefix(m);
|
|
742
|
+
if (cube.measures[key] || extraMeasures[key]) continue;
|
|
743
|
+
extraMeasures[key] = inferMeasure(key);
|
|
744
|
+
}
|
|
745
|
+
if (Object.keys(extraMeasures).length > 0) {
|
|
746
|
+
const augmented = {
|
|
747
|
+
...cube,
|
|
748
|
+
measures: { ...cube.measures, ...extraMeasures }
|
|
749
|
+
};
|
|
750
|
+
this.cubeRegistry.register(augmented);
|
|
751
|
+
this.logger.debug(
|
|
752
|
+
`[Analytics] Augmented cube "${name}" with inferred measures: ${Object.keys(extraMeasures).join(",")}`
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
/** Build a minimal Cube from the fields referenced by an AnalyticsQuery. */
|
|
757
|
+
inferCubeFromQuery(query) {
|
|
758
|
+
const cubeName = query.cube;
|
|
759
|
+
const measures = {};
|
|
760
|
+
const dimensions = {};
|
|
761
|
+
const stripPrefix = (m) => m.includes(".") ? m.split(".").slice(1).join(".") : m;
|
|
762
|
+
measures.count = { name: "count", label: "Count", type: "count", sql: "*" };
|
|
763
|
+
for (const m of query.measures || []) {
|
|
764
|
+
const key = stripPrefix(m);
|
|
765
|
+
if (measures[key]) continue;
|
|
766
|
+
const inferred = inferMeasure(key);
|
|
767
|
+
measures[key] = inferred;
|
|
768
|
+
}
|
|
769
|
+
for (const d of query.dimensions || []) {
|
|
770
|
+
const key = stripPrefix(d);
|
|
771
|
+
if (dimensions[key]) continue;
|
|
772
|
+
dimensions[key] = { name: key, label: key, type: "string", sql: key };
|
|
773
|
+
}
|
|
774
|
+
if (query.where && typeof query.where === "object" && !Array.isArray(query.where)) {
|
|
775
|
+
for (const key of Object.keys(query.where)) {
|
|
776
|
+
if (key.startsWith("$")) continue;
|
|
777
|
+
const stripped = stripPrefix(key);
|
|
778
|
+
if (dimensions[stripped] || measures[stripped]) continue;
|
|
779
|
+
dimensions[stripped] = { name: stripped, label: stripped, type: "string", sql: stripped };
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
for (const td of query.timeDimensions || []) {
|
|
783
|
+
const key = stripPrefix(td.dimension);
|
|
784
|
+
if (dimensions[key]) continue;
|
|
785
|
+
dimensions[key] = {
|
|
786
|
+
name: key,
|
|
787
|
+
label: key,
|
|
788
|
+
type: "time",
|
|
789
|
+
sql: key,
|
|
790
|
+
granularities: ["day", "week", "month", "quarter", "year"]
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
return {
|
|
794
|
+
name: cubeName,
|
|
795
|
+
title: cubeName,
|
|
796
|
+
sql: cubeName,
|
|
797
|
+
measures,
|
|
798
|
+
dimensions,
|
|
799
|
+
public: false
|
|
800
|
+
};
|
|
801
|
+
}
|
|
505
802
|
/**
|
|
506
803
|
* Walk the strategy chain and return the first strategy that can handle the query.
|
|
507
804
|
*/
|
|
@@ -516,6 +813,26 @@ var AnalyticsService = class {
|
|
|
516
813
|
);
|
|
517
814
|
}
|
|
518
815
|
};
|
|
816
|
+
function inferMeasure(key) {
|
|
817
|
+
if (key === "count") {
|
|
818
|
+
return { name: "count", label: "Count", type: "count", sql: "*" };
|
|
819
|
+
}
|
|
820
|
+
const suffixes = [
|
|
821
|
+
["_count_distinct", "count_distinct"],
|
|
822
|
+
["_sum", "sum"],
|
|
823
|
+
["_avg", "avg"],
|
|
824
|
+
["_average", "avg"],
|
|
825
|
+
["_min", "min"],
|
|
826
|
+
["_max", "max"]
|
|
827
|
+
];
|
|
828
|
+
for (const [suffix, type] of suffixes) {
|
|
829
|
+
if (key.endsWith(suffix)) {
|
|
830
|
+
const field = key.slice(0, -suffix.length) || "*";
|
|
831
|
+
return { name: key, label: key, type, sql: field };
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return { name: key, label: key, type: "sum", sql: key };
|
|
835
|
+
}
|
|
519
836
|
var FallbackDelegateStrategy = class {
|
|
520
837
|
constructor() {
|
|
521
838
|
this.name = "FallbackDelegateStrategy";
|
|
@@ -558,14 +875,89 @@ var AnalyticsServicePlugin = class {
|
|
|
558
875
|
}
|
|
559
876
|
} catch {
|
|
560
877
|
}
|
|
878
|
+
let executeAggregate = this.options.executeAggregate;
|
|
879
|
+
let autoBridged = false;
|
|
880
|
+
if (!executeAggregate) {
|
|
881
|
+
const tryGetDataEngine = () => {
|
|
882
|
+
try {
|
|
883
|
+
const svc = ctx.getService("data");
|
|
884
|
+
return svc && typeof svc.aggregate === "function" ? svc : void 0;
|
|
885
|
+
} catch {
|
|
886
|
+
return void 0;
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
if (!tryGetDataEngine()) {
|
|
890
|
+
ctx.logger.warn(
|
|
891
|
+
'[Analytics] No "data" service registered yet at init; will retry per-query. Register ObjectQLPlugin or pass executeAggregate.'
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
executeAggregate = async (objectName, { groupBy, aggregations, filter }) => {
|
|
895
|
+
const engine = tryGetDataEngine();
|
|
896
|
+
if (!engine) {
|
|
897
|
+
throw new Error(
|
|
898
|
+
'[Analytics] Cannot execute aggregate: no IDataEngine ("data") service is registered. Add ObjectQLPlugin to the kernel or supply AnalyticsServicePlugin({ executeAggregate }).'
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
const rows = await engine.aggregate(objectName, {
|
|
902
|
+
where: filter,
|
|
903
|
+
groupBy,
|
|
904
|
+
aggregations: aggregations?.map((a) => ({
|
|
905
|
+
function: a.method,
|
|
906
|
+
field: a.field,
|
|
907
|
+
alias: a.alias
|
|
908
|
+
}))
|
|
909
|
+
});
|
|
910
|
+
return rows;
|
|
911
|
+
};
|
|
912
|
+
autoBridged = true;
|
|
913
|
+
}
|
|
914
|
+
let executeRawSql = this.options.executeRawSql;
|
|
915
|
+
let autoBridgedRawSql = false;
|
|
916
|
+
if (!executeRawSql) {
|
|
917
|
+
const tryGetExecutor = () => {
|
|
918
|
+
try {
|
|
919
|
+
const svc = ctx.getService("data");
|
|
920
|
+
return svc && typeof svc.execute === "function" ? svc : void 0;
|
|
921
|
+
} catch {
|
|
922
|
+
return void 0;
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
executeRawSql = async (_objectName, sql, params) => {
|
|
926
|
+
const engine = tryGetExecutor();
|
|
927
|
+
if (!engine || !engine.execute) {
|
|
928
|
+
throw new Error(
|
|
929
|
+
'[Analytics] Cannot execute raw SQL: no IDataEngine ("data") service with execute() is registered.'
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
const knexSql = sql.replace(/\$(\d+)/g, "?");
|
|
933
|
+
const result = await engine.execute(knexSql, { args: params });
|
|
934
|
+
if (Array.isArray(result)) return result;
|
|
935
|
+
if (result && typeof result === "object" && "rows" in result) {
|
|
936
|
+
return result.rows;
|
|
937
|
+
}
|
|
938
|
+
return [];
|
|
939
|
+
};
|
|
940
|
+
autoBridgedRawSql = true;
|
|
941
|
+
}
|
|
942
|
+
const queryCapabilities = this.options.queryCapabilities ?? (() => ({
|
|
943
|
+
nativeSql: !!executeRawSql,
|
|
944
|
+
objectqlAggregate: !!executeAggregate,
|
|
945
|
+
inMemory: false
|
|
946
|
+
}));
|
|
561
947
|
const config = {
|
|
562
948
|
cubes: this.options.cubes,
|
|
563
949
|
logger: ctx.logger,
|
|
564
|
-
queryCapabilities
|
|
565
|
-
executeRawSql
|
|
566
|
-
executeAggregate
|
|
950
|
+
queryCapabilities,
|
|
951
|
+
executeRawSql,
|
|
952
|
+
executeAggregate,
|
|
567
953
|
fallbackService
|
|
568
954
|
};
|
|
955
|
+
if (autoBridged) {
|
|
956
|
+
ctx.logger.info('[Analytics] Auto-bridged executeAggregate \u2192 "data" service (IDataEngine)');
|
|
957
|
+
}
|
|
958
|
+
if (autoBridgedRawSql) {
|
|
959
|
+
ctx.logger.info('[Analytics] Auto-bridged executeRawSql \u2192 "data" service (IDataEngine.execute)');
|
|
960
|
+
}
|
|
569
961
|
this.service = new AnalyticsService(config);
|
|
570
962
|
if (fallbackService) {
|
|
571
963
|
ctx.replaceService("analytics", this.service);
|