@object-ui/data-objectstack 3.1.5 → 3.3.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/CHANGELOG.md +118 -0
- package/README.md +20 -1
- package/dist/index.cjs +176 -33
- package/dist/index.d.cts +12 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.js +174 -33
- package/package.json +34 -9
- package/src/aggregate.test.ts +277 -0
- package/src/cache/MetadataCache.test.ts +51 -0
- package/src/cache/MetadataCache.ts +37 -8
- package/src/connection.test.ts +55 -8
- package/src/errors.ts +2 -2
- package/src/index.ts +128 -24
package/dist/index.js
CHANGED
|
@@ -4,7 +4,72 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
|
|
|
4
4
|
|
|
5
5
|
// src/index.ts
|
|
6
6
|
import { ObjectStackClient } from "@objectstack/client";
|
|
7
|
-
|
|
7
|
+
|
|
8
|
+
// ../core/src/utils/filter-converter.ts
|
|
9
|
+
function convertOperatorToAST(operator) {
|
|
10
|
+
const operatorMap = {
|
|
11
|
+
"$eq": "=",
|
|
12
|
+
"$ne": "!=",
|
|
13
|
+
"$gt": ">",
|
|
14
|
+
"$gte": ">=",
|
|
15
|
+
"$lt": "<",
|
|
16
|
+
"$lte": "<=",
|
|
17
|
+
"$in": "in",
|
|
18
|
+
"$nin": "nin",
|
|
19
|
+
"$notin": "nin",
|
|
20
|
+
"$between": "between",
|
|
21
|
+
"$contains": "contains",
|
|
22
|
+
"$notContains": "notcontains",
|
|
23
|
+
"$notcontains": "notcontains",
|
|
24
|
+
"$startsWith": "startswith",
|
|
25
|
+
"$startswith": "startswith",
|
|
26
|
+
"$endsWith": "endswith",
|
|
27
|
+
"$endswith": "endswith"
|
|
28
|
+
};
|
|
29
|
+
return operatorMap[operator] || null;
|
|
30
|
+
}
|
|
31
|
+
function convertFiltersToAST(filter) {
|
|
32
|
+
const conditions = [];
|
|
33
|
+
for (const [field, value] of Object.entries(filter)) {
|
|
34
|
+
if (value === null || value === void 0) continue;
|
|
35
|
+
if (typeof value === "object" && !Array.isArray(value)) {
|
|
36
|
+
for (const [operator, operatorValue] of Object.entries(value)) {
|
|
37
|
+
if (operator === "$regex") {
|
|
38
|
+
console.warn(
|
|
39
|
+
`[ObjectUI] Warning: $regex operator is not fully supported. Converting to 'contains' which only supports substring matching, not regex patterns. Field: '${field}', Value: ${JSON.stringify(operatorValue)}. Consider using $contains or $startsWith instead.`
|
|
40
|
+
);
|
|
41
|
+
conditions.push([field, "contains", operatorValue]);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (operator === "$null") {
|
|
45
|
+
conditions.push([field, operatorValue ? "is_null" : "is_not_null", true]);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (operator === "$exists") {
|
|
49
|
+
conditions.push([field, operatorValue ? "is_not_null" : "is_null", true]);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const astOperator = convertOperatorToAST(operator);
|
|
53
|
+
if (astOperator) {
|
|
54
|
+
conditions.push([field, astOperator, operatorValue]);
|
|
55
|
+
} else {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, $contains, $notContains, $startsWith, $endsWith, $null, $exists. If you need exact object matching, use the value directly without an operator.`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
conditions.push([field, "=", value]);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (conditions.length === 0) {
|
|
66
|
+
return filter;
|
|
67
|
+
}
|
|
68
|
+
if (conditions.length === 1) {
|
|
69
|
+
return conditions[0];
|
|
70
|
+
}
|
|
71
|
+
return ["and", ...conditions];
|
|
72
|
+
}
|
|
8
73
|
|
|
9
74
|
// src/cache/MetadataCache.ts
|
|
10
75
|
var MetadataCache = class {
|
|
@@ -17,16 +82,19 @@ var MetadataCache = class {
|
|
|
17
82
|
*/
|
|
18
83
|
constructor(options = {}) {
|
|
19
84
|
__publicField(this, "cache");
|
|
85
|
+
__publicField(this, "inflight");
|
|
20
86
|
__publicField(this, "maxSize");
|
|
21
87
|
__publicField(this, "ttl");
|
|
22
88
|
__publicField(this, "stats");
|
|
23
89
|
this.cache = /* @__PURE__ */ new Map();
|
|
90
|
+
this.inflight = /* @__PURE__ */ new Map();
|
|
24
91
|
this.maxSize = options.maxSize || 100;
|
|
25
92
|
this.ttl = options.ttl || 5 * 60 * 1e3;
|
|
26
93
|
this.stats = {
|
|
27
94
|
hits: 0,
|
|
28
95
|
misses: 0,
|
|
29
|
-
evictions: 0
|
|
96
|
+
evictions: 0,
|
|
97
|
+
coalesced: 0
|
|
30
98
|
};
|
|
31
99
|
}
|
|
32
100
|
/**
|
|
@@ -52,10 +120,31 @@ var MetadataCache = class {
|
|
|
52
120
|
this.cache.delete(key);
|
|
53
121
|
}
|
|
54
122
|
}
|
|
123
|
+
const existing = this.inflight.get(key);
|
|
124
|
+
if (existing) {
|
|
125
|
+
this.stats.coalesced++;
|
|
126
|
+
return existing;
|
|
127
|
+
}
|
|
55
128
|
this.stats.misses++;
|
|
56
|
-
const
|
|
129
|
+
const promise = (async () => {
|
|
130
|
+
try {
|
|
131
|
+
const data = await fetcher();
|
|
132
|
+
this.set(key, data);
|
|
133
|
+
return data;
|
|
134
|
+
} finally {
|
|
135
|
+
this.inflight.delete(key);
|
|
136
|
+
}
|
|
137
|
+
})();
|
|
138
|
+
this.inflight.set(key, promise);
|
|
139
|
+
return promise;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Prime the cache with a pre-fetched value. Useful when a bulk endpoint
|
|
143
|
+
* (e.g. list of all object schemas) returns data that would otherwise
|
|
144
|
+
* be fetched again per item.
|
|
145
|
+
*/
|
|
146
|
+
prime(key, data) {
|
|
57
147
|
this.set(key, data);
|
|
58
|
-
return data;
|
|
59
148
|
}
|
|
60
149
|
/**
|
|
61
150
|
* Set a value in the cache
|
|
@@ -102,10 +191,12 @@ var MetadataCache = class {
|
|
|
102
191
|
*/
|
|
103
192
|
clear() {
|
|
104
193
|
this.cache.clear();
|
|
194
|
+
this.inflight.clear();
|
|
105
195
|
this.stats = {
|
|
106
196
|
hits: 0,
|
|
107
197
|
misses: 0,
|
|
108
|
-
evictions: 0
|
|
198
|
+
evictions: 0,
|
|
199
|
+
coalesced: 0
|
|
109
200
|
};
|
|
110
201
|
}
|
|
111
202
|
/**
|
|
@@ -122,6 +213,7 @@ var MetadataCache = class {
|
|
|
122
213
|
hits: this.stats.hits,
|
|
123
214
|
misses: this.stats.misses,
|
|
124
215
|
evictions: this.stats.evictions,
|
|
216
|
+
coalesced: this.stats.coalesced,
|
|
125
217
|
hitRate
|
|
126
218
|
};
|
|
127
219
|
}
|
|
@@ -173,9 +265,9 @@ var ObjectStackError = class extends Error {
|
|
|
173
265
|
*/
|
|
174
266
|
constructor(message, code, statusCode, details) {
|
|
175
267
|
super(message);
|
|
176
|
-
this
|
|
177
|
-
this
|
|
178
|
-
this
|
|
268
|
+
__publicField(this, "code", code);
|
|
269
|
+
__publicField(this, "statusCode", statusCode);
|
|
270
|
+
__publicField(this, "details", details);
|
|
179
271
|
this.name = "ObjectStackError";
|
|
180
272
|
if (Error.captureStackTrace) {
|
|
181
273
|
Error.captureStackTrace(this, this.constructor);
|
|
@@ -229,9 +321,9 @@ var BulkOperationError = class extends ObjectStackError {
|
|
|
229
321
|
...details
|
|
230
322
|
}
|
|
231
323
|
);
|
|
232
|
-
this
|
|
233
|
-
this
|
|
234
|
-
this
|
|
324
|
+
__publicField(this, "successCount", successCount);
|
|
325
|
+
__publicField(this, "failureCount", failureCount);
|
|
326
|
+
__publicField(this, "errors", errors);
|
|
235
327
|
this.name = "BulkOperationError";
|
|
236
328
|
}
|
|
237
329
|
/**
|
|
@@ -258,7 +350,7 @@ var ConnectionError = class extends ObjectStackError {
|
|
|
258
350
|
statusCode || 503,
|
|
259
351
|
{ url, ...details }
|
|
260
352
|
);
|
|
261
|
-
this
|
|
353
|
+
__publicField(this, "url", url);
|
|
262
354
|
this.name = "ConnectionError";
|
|
263
355
|
}
|
|
264
356
|
};
|
|
@@ -293,8 +385,8 @@ var ValidationError = class extends ObjectStackError {
|
|
|
293
385
|
...details
|
|
294
386
|
}
|
|
295
387
|
);
|
|
296
|
-
this
|
|
297
|
-
this
|
|
388
|
+
__publicField(this, "field", field);
|
|
389
|
+
__publicField(this, "validationErrors", validationErrors);
|
|
298
390
|
this.name = "ValidationError";
|
|
299
391
|
}
|
|
300
392
|
/**
|
|
@@ -351,7 +443,7 @@ function isErrorType(error, errorClass) {
|
|
|
351
443
|
// src/cloud.ts
|
|
352
444
|
var CloudOperations = class {
|
|
353
445
|
constructor(getClient) {
|
|
354
|
-
this
|
|
446
|
+
__publicField(this, "getClient", getClient);
|
|
355
447
|
}
|
|
356
448
|
/**
|
|
357
449
|
* Deploy an application to the cloud.
|
|
@@ -695,10 +787,26 @@ function calculateAutoLayout(items, canvasWidth, padding = 40, gap = 40) {
|
|
|
695
787
|
}
|
|
696
788
|
|
|
697
789
|
// src/index.ts
|
|
790
|
+
var discoveryCache = /* @__PURE__ */ new Map();
|
|
791
|
+
async function getSharedDiscovery(baseUrl, fetcher) {
|
|
792
|
+
const key = baseUrl || "<default>";
|
|
793
|
+
const cached = discoveryCache.get(key);
|
|
794
|
+
if (cached) return cached;
|
|
795
|
+
const p = fetcher().catch((err) => {
|
|
796
|
+
discoveryCache.delete(key);
|
|
797
|
+
throw err;
|
|
798
|
+
});
|
|
799
|
+
discoveryCache.set(key, p);
|
|
800
|
+
return p;
|
|
801
|
+
}
|
|
802
|
+
function clearSharedDiscoveryCache() {
|
|
803
|
+
discoveryCache.clear();
|
|
804
|
+
}
|
|
698
805
|
var ObjectStackAdapter = class {
|
|
699
806
|
constructor(config) {
|
|
700
807
|
__publicField(this, "client");
|
|
701
808
|
__publicField(this, "connected", false);
|
|
809
|
+
__publicField(this, "connectPromise", null);
|
|
702
810
|
__publicField(this, "metadataCache");
|
|
703
811
|
__publicField(this, "connectionState", "disconnected");
|
|
704
812
|
__publicField(this, "connectionStateListeners", []);
|
|
@@ -724,10 +832,25 @@ var ObjectStackAdapter = class {
|
|
|
724
832
|
* Call this before making requests or it will auto-connect on first request.
|
|
725
833
|
*/
|
|
726
834
|
async connect() {
|
|
727
|
-
if (
|
|
728
|
-
|
|
835
|
+
if (this.connected) return;
|
|
836
|
+
if (this.connectPromise) return this.connectPromise;
|
|
837
|
+
this.setConnectionState("connecting");
|
|
838
|
+
this.connectPromise = (async () => {
|
|
729
839
|
try {
|
|
730
|
-
|
|
840
|
+
const baseUrl = this.baseUrl || "";
|
|
841
|
+
const discoveryUrl = baseUrl ? `${baseUrl.replace(/\/$/, "")}/api/v1/discovery` : "/api/v1/discovery";
|
|
842
|
+
const data = await getSharedDiscovery(baseUrl, async () => {
|
|
843
|
+
const res = await this.fetchImpl(discoveryUrl, {
|
|
844
|
+
method: "GET",
|
|
845
|
+
headers: this.token ? { Authorization: `Bearer ${this.token}` } : void 0
|
|
846
|
+
});
|
|
847
|
+
if (!res.ok) {
|
|
848
|
+
throw new Error(`discovery ${res.status} ${res.statusText}`);
|
|
849
|
+
}
|
|
850
|
+
const body = await res.json();
|
|
851
|
+
return body && typeof body.success === "boolean" && "data" in body ? body.data : body;
|
|
852
|
+
});
|
|
853
|
+
this.client.discoveryInfo = data;
|
|
731
854
|
this.connected = true;
|
|
732
855
|
this.reconnectAttempts = 0;
|
|
733
856
|
this.setConnectionState("connected");
|
|
@@ -744,8 +867,11 @@ var ObjectStackAdapter = class {
|
|
|
744
867
|
} else {
|
|
745
868
|
throw connectionError;
|
|
746
869
|
}
|
|
870
|
+
} finally {
|
|
871
|
+
this.connectPromise = null;
|
|
747
872
|
}
|
|
748
|
-
}
|
|
873
|
+
})();
|
|
874
|
+
return this.connectPromise;
|
|
749
875
|
}
|
|
750
876
|
/**
|
|
751
877
|
* Attempt to reconnect to the server with exponential backoff
|
|
@@ -1086,8 +1212,11 @@ var ObjectStackAdapter = class {
|
|
|
1086
1212
|
queryParams.set("sort", sortStr);
|
|
1087
1213
|
}
|
|
1088
1214
|
}
|
|
1089
|
-
if (params.$filter) {
|
|
1090
|
-
|
|
1215
|
+
if (params.$filter !== void 0 && params.$filter !== null) {
|
|
1216
|
+
const isEmpty = Array.isArray(params.$filter) ? params.$filter.length === 0 : typeof params.$filter === "object" && Object.keys(params.$filter).length === 0;
|
|
1217
|
+
if (!isEmpty) {
|
|
1218
|
+
queryParams.set("filter", JSON.stringify(params.$filter));
|
|
1219
|
+
}
|
|
1091
1220
|
}
|
|
1092
1221
|
const baseUrl = this.baseUrl.replace(/\/$/, "");
|
|
1093
1222
|
const qs = queryParams.toString();
|
|
@@ -1123,11 +1252,14 @@ var ObjectStackAdapter = class {
|
|
|
1123
1252
|
if (params.$select) {
|
|
1124
1253
|
options.select = params.$select;
|
|
1125
1254
|
}
|
|
1126
|
-
if (params.$filter) {
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1255
|
+
if (params.$filter !== void 0 && params.$filter !== null) {
|
|
1256
|
+
const isEmpty = Array.isArray(params.$filter) ? params.$filter.length === 0 : typeof params.$filter === "object" && Object.keys(params.$filter).length === 0;
|
|
1257
|
+
if (!isEmpty) {
|
|
1258
|
+
if (Array.isArray(params.$filter)) {
|
|
1259
|
+
options.filters = params.$filter;
|
|
1260
|
+
} else {
|
|
1261
|
+
options.filters = convertFiltersToAST(params.$filter);
|
|
1262
|
+
}
|
|
1131
1263
|
}
|
|
1132
1264
|
}
|
|
1133
1265
|
if (params.$orderby) {
|
|
@@ -1278,19 +1410,26 @@ var ObjectStackAdapter = class {
|
|
|
1278
1410
|
async aggregate(resource, params) {
|
|
1279
1411
|
await this.connect();
|
|
1280
1412
|
try {
|
|
1413
|
+
const measureName = params.function === "count" ? "count" : `${params.field}_${params.function}`;
|
|
1281
1414
|
const payload = {
|
|
1282
|
-
|
|
1283
|
-
measures: [
|
|
1284
|
-
dimensions
|
|
1415
|
+
cube: resource,
|
|
1416
|
+
measures: [measureName],
|
|
1417
|
+
// When groupBy is '_all' no dimensions are needed (single-bucket).
|
|
1418
|
+
dimensions: params.groupBy && params.groupBy !== "_all" ? [params.groupBy] : []
|
|
1285
1419
|
};
|
|
1286
1420
|
if (params.filter) {
|
|
1287
1421
|
payload.filters = params.filter;
|
|
1288
1422
|
}
|
|
1289
1423
|
const data = await this.client.analytics.query(payload);
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1424
|
+
const rawRows = Array.isArray(data) ? data : data?.rows && Array.isArray(data.rows) ? data.rows : data?.data && Array.isArray(data.data) ? data.data : data?.data?.rows && Array.isArray(data.data.rows) ? data.data.rows : data?.results && Array.isArray(data.results) ? data.results : [];
|
|
1425
|
+
return rawRows.map((row) => {
|
|
1426
|
+
const mapped = { ...row };
|
|
1427
|
+
if (measureName !== params.field && measureName in mapped) {
|
|
1428
|
+
mapped[params.field] = mapped[measureName];
|
|
1429
|
+
delete mapped[measureName];
|
|
1430
|
+
}
|
|
1431
|
+
return mapped;
|
|
1432
|
+
});
|
|
1294
1433
|
} catch {
|
|
1295
1434
|
const result = await this.find(resource);
|
|
1296
1435
|
const records = result.data || [];
|
|
@@ -1484,10 +1623,12 @@ export {
|
|
|
1484
1623
|
SecurityManager,
|
|
1485
1624
|
ValidationError,
|
|
1486
1625
|
calculateAutoLayout,
|
|
1626
|
+
clearSharedDiscoveryCache,
|
|
1487
1627
|
createDefaultCanvasConfig,
|
|
1488
1628
|
createErrorFromResponse,
|
|
1489
1629
|
createObjectStackAdapter,
|
|
1490
1630
|
generateContractManifest,
|
|
1631
|
+
getSharedDiscovery,
|
|
1491
1632
|
isErrorType,
|
|
1492
1633
|
isObjectStackError,
|
|
1493
1634
|
snapToGrid,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/data-objectstack",
|
|
3
|
-
"version": "3.1
|
|
3
|
+
"version": "3.3.1",
|
|
4
4
|
"description": "ObjectStack Data Adapter for Object UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -17,24 +17,49 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"dist",
|
|
19
19
|
"src",
|
|
20
|
-
"README.md"
|
|
20
|
+
"README.md",
|
|
21
|
+
"CHANGELOG.md",
|
|
22
|
+
"LICENSE"
|
|
21
23
|
],
|
|
22
24
|
"dependencies": {
|
|
23
|
-
"@objectstack/client": "^
|
|
24
|
-
"@object-ui/core": "3.1
|
|
25
|
-
"@object-ui/types": "3.1
|
|
25
|
+
"@objectstack/client": "^4.0.4",
|
|
26
|
+
"@object-ui/core": "3.3.1",
|
|
27
|
+
"@object-ui/types": "3.3.1"
|
|
26
28
|
},
|
|
27
29
|
"devDependencies": {
|
|
28
30
|
"tsup": "^8.5.1",
|
|
29
|
-
"typescript": "^
|
|
30
|
-
"vitest": "^4.1.
|
|
31
|
+
"typescript": "^6.0.3",
|
|
32
|
+
"vitest": "^4.1.5"
|
|
31
33
|
},
|
|
32
34
|
"publishConfig": {
|
|
33
35
|
"access": "public"
|
|
34
36
|
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"objectui",
|
|
39
|
+
"sdui",
|
|
40
|
+
"schema-driven-ui",
|
|
41
|
+
"react",
|
|
42
|
+
"tailwind",
|
|
43
|
+
"shadcn",
|
|
44
|
+
"objectstack",
|
|
45
|
+
"data-source",
|
|
46
|
+
"adapter",
|
|
47
|
+
"objectql",
|
|
48
|
+
"objectstack-client"
|
|
49
|
+
],
|
|
50
|
+
"author": "ObjectStack Team <team@objectstack.ai>",
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "git+https://github.com/objectstack-ai/objectui.git",
|
|
54
|
+
"directory": "packages/data-objectstack"
|
|
55
|
+
},
|
|
56
|
+
"bugs": {
|
|
57
|
+
"url": "https://github.com/objectstack-ai/objectui/issues"
|
|
58
|
+
},
|
|
59
|
+
"homepage": "https://www.objectui.org/docs/guide/data-source",
|
|
35
60
|
"scripts": {
|
|
36
|
-
"build": "tsup
|
|
37
|
-
"dev": "tsup
|
|
61
|
+
"build": "tsup",
|
|
62
|
+
"dev": "tsup --watch",
|
|
38
63
|
"clean": "rm -rf dist",
|
|
39
64
|
"type-check": "tsc --noEmit",
|
|
40
65
|
"test": "vitest run",
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
10
|
+
import { ObjectStackAdapter } from './index';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Tests for ObjectStackAdapter.aggregate() — verifies that the analytics
|
|
14
|
+
* query payload uses the correct string-based measure/dimension format
|
|
15
|
+
* expected by the backend analytics service (MemoryAnalyticsService).
|
|
16
|
+
*
|
|
17
|
+
* See: https://github.com/objectstack-ai/objectui/issues (measures format bug)
|
|
18
|
+
*/
|
|
19
|
+
describe('ObjectStackAdapter aggregate()', () => {
|
|
20
|
+
let adapter: ObjectStackAdapter;
|
|
21
|
+
let mockAnalyticsQuery: ReturnType<typeof vi.fn>;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
mockAnalyticsQuery = vi.fn().mockResolvedValue({ data: [] });
|
|
25
|
+
|
|
26
|
+
adapter = new ObjectStackAdapter({
|
|
27
|
+
baseUrl: 'http://localhost:3000',
|
|
28
|
+
autoReconnect: false,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Inject mock client and mark as connected to bypass connect()
|
|
32
|
+
(adapter as any).client = {
|
|
33
|
+
data: {
|
|
34
|
+
find: vi.fn().mockResolvedValue({ records: [], total: 0 }),
|
|
35
|
+
},
|
|
36
|
+
analytics: {
|
|
37
|
+
query: mockAnalyticsQuery,
|
|
38
|
+
},
|
|
39
|
+
connect: vi.fn().mockResolvedValue(undefined),
|
|
40
|
+
discover: vi.fn().mockResolvedValue({ status: 'ok' }),
|
|
41
|
+
};
|
|
42
|
+
(adapter as any).connected = true;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should send measures as string array with field_function format for sum', async () => {
|
|
46
|
+
mockAnalyticsQuery.mockResolvedValue({ data: [] });
|
|
47
|
+
|
|
48
|
+
await adapter.aggregate('opportunity', {
|
|
49
|
+
field: 'amount',
|
|
50
|
+
function: 'sum',
|
|
51
|
+
groupBy: 'stage',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(mockAnalyticsQuery).toHaveBeenCalledWith({
|
|
55
|
+
cube: 'opportunity',
|
|
56
|
+
measures: ['amount_sum'],
|
|
57
|
+
dimensions: ['stage'],
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should send measures as ["count"] for count aggregation', async () => {
|
|
62
|
+
mockAnalyticsQuery.mockResolvedValue({ data: [] });
|
|
63
|
+
|
|
64
|
+
await adapter.aggregate('opportunity', {
|
|
65
|
+
field: 'amount',
|
|
66
|
+
function: 'count',
|
|
67
|
+
groupBy: 'stage',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(mockAnalyticsQuery).toHaveBeenCalledWith({
|
|
71
|
+
cube: 'opportunity',
|
|
72
|
+
measures: ['count'],
|
|
73
|
+
dimensions: ['stage'],
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should send measures as string for avg aggregation', async () => {
|
|
78
|
+
mockAnalyticsQuery.mockResolvedValue({ data: [] });
|
|
79
|
+
|
|
80
|
+
await adapter.aggregate('opportunity', {
|
|
81
|
+
field: 'amount',
|
|
82
|
+
function: 'avg',
|
|
83
|
+
groupBy: 'stage',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(mockAnalyticsQuery).toHaveBeenCalledWith({
|
|
87
|
+
cube: 'opportunity',
|
|
88
|
+
measures: ['amount_avg'],
|
|
89
|
+
dimensions: ['stage'],
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should send empty dimensions when groupBy is _all', async () => {
|
|
94
|
+
mockAnalyticsQuery.mockResolvedValue({ data: [] });
|
|
95
|
+
|
|
96
|
+
await adapter.aggregate('opportunity', {
|
|
97
|
+
field: 'amount',
|
|
98
|
+
function: 'sum',
|
|
99
|
+
groupBy: '_all',
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(mockAnalyticsQuery).toHaveBeenCalledWith({
|
|
103
|
+
cube: 'opportunity',
|
|
104
|
+
measures: ['amount_sum'],
|
|
105
|
+
dimensions: [],
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should include filters in payload when provided', async () => {
|
|
110
|
+
const filter = [{ member: 'stage', operator: 'equals', values: ['Closed Won'] }];
|
|
111
|
+
mockAnalyticsQuery.mockResolvedValue({ data: [] });
|
|
112
|
+
|
|
113
|
+
await adapter.aggregate('opportunity', {
|
|
114
|
+
field: 'amount',
|
|
115
|
+
function: 'sum',
|
|
116
|
+
groupBy: 'stage',
|
|
117
|
+
filter,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(mockAnalyticsQuery).toHaveBeenCalledWith({
|
|
121
|
+
cube: 'opportunity',
|
|
122
|
+
measures: ['amount_sum'],
|
|
123
|
+
dimensions: ['stage'],
|
|
124
|
+
filters: filter,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should map measure key back to field name in response', async () => {
|
|
129
|
+
mockAnalyticsQuery.mockResolvedValue({
|
|
130
|
+
data: [
|
|
131
|
+
{ stage: 'Prospect', amount_sum: 300 },
|
|
132
|
+
{ stage: 'Closed Won', amount_sum: 500 },
|
|
133
|
+
],
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const result = await adapter.aggregate('opportunity', {
|
|
137
|
+
field: 'amount',
|
|
138
|
+
function: 'sum',
|
|
139
|
+
groupBy: 'stage',
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(result).toEqual([
|
|
143
|
+
{ stage: 'Prospect', amount: 300 },
|
|
144
|
+
{ stage: 'Closed Won', amount: 500 },
|
|
145
|
+
]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should map count measure back to field name in response', async () => {
|
|
149
|
+
mockAnalyticsQuery.mockResolvedValue({
|
|
150
|
+
data: [
|
|
151
|
+
{ stage: 'Prospect', count: 5 },
|
|
152
|
+
{ stage: 'Closed Won', count: 3 },
|
|
153
|
+
],
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const result = await adapter.aggregate('opportunity', {
|
|
157
|
+
field: 'amount',
|
|
158
|
+
function: 'count',
|
|
159
|
+
groupBy: 'stage',
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(result).toEqual([
|
|
163
|
+
{ stage: 'Prospect', amount: 5 },
|
|
164
|
+
{ stage: 'Closed Won', amount: 3 },
|
|
165
|
+
]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should handle direct array response from analytics', async () => {
|
|
169
|
+
mockAnalyticsQuery.mockResolvedValue([
|
|
170
|
+
{ stage: 'Prospect', amount_sum: 300 },
|
|
171
|
+
]);
|
|
172
|
+
|
|
173
|
+
const result = await adapter.aggregate('opportunity', {
|
|
174
|
+
field: 'amount',
|
|
175
|
+
function: 'sum',
|
|
176
|
+
groupBy: 'stage',
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(result).toEqual([
|
|
180
|
+
{ stage: 'Prospect', amount: 300 },
|
|
181
|
+
]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should handle results wrapper in response', async () => {
|
|
185
|
+
mockAnalyticsQuery.mockResolvedValue({
|
|
186
|
+
results: [
|
|
187
|
+
{ stage: 'Prospect', amount_avg: 150 },
|
|
188
|
+
],
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const result = await adapter.aggregate('opportunity', {
|
|
192
|
+
field: 'amount',
|
|
193
|
+
function: 'avg',
|
|
194
|
+
groupBy: 'stage',
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(result).toEqual([
|
|
198
|
+
{ stage: 'Prospect', amount: 150 },
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should extract rows from { rows: [...] } envelope (SDK unwraps outer success/data)', async () => {
|
|
203
|
+
mockAnalyticsQuery.mockResolvedValue({
|
|
204
|
+
rows: [
|
|
205
|
+
{ stage: 'closed_won', expected_revenue_sum: 225000 },
|
|
206
|
+
{ stage: 'negotiation', expected_revenue_sum: 36000 },
|
|
207
|
+
],
|
|
208
|
+
fields: [
|
|
209
|
+
{ name: 'stage', type: 'string' },
|
|
210
|
+
{ name: 'expected_revenue_sum', type: 'number' },
|
|
211
|
+
],
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const result = await adapter.aggregate('opportunity', {
|
|
215
|
+
field: 'expected_revenue',
|
|
216
|
+
function: 'sum',
|
|
217
|
+
groupBy: 'stage',
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(result).toEqual([
|
|
221
|
+
{ stage: 'closed_won', expected_revenue: 225000 },
|
|
222
|
+
{ stage: 'negotiation', expected_revenue: 36000 },
|
|
223
|
+
]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should extract rows from { data: { rows: [...] } } envelope (SDK does not unwrap)', async () => {
|
|
227
|
+
mockAnalyticsQuery.mockResolvedValue({
|
|
228
|
+
success: true,
|
|
229
|
+
data: {
|
|
230
|
+
rows: [
|
|
231
|
+
{ stage: 'closed_won', expected_revenue_sum: 225000 },
|
|
232
|
+
{ stage: 'negotiation', expected_revenue_sum: 36000 },
|
|
233
|
+
],
|
|
234
|
+
fields: [
|
|
235
|
+
{ name: 'stage', type: 'string' },
|
|
236
|
+
{ name: 'expected_revenue_sum', type: 'number' },
|
|
237
|
+
],
|
|
238
|
+
sql: '-- MongoDB Aggregation Pipeline',
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const result = await adapter.aggregate('opportunity', {
|
|
243
|
+
field: 'expected_revenue',
|
|
244
|
+
function: 'sum',
|
|
245
|
+
groupBy: 'stage',
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
expect(result).toEqual([
|
|
249
|
+
{ stage: 'closed_won', expected_revenue: 225000 },
|
|
250
|
+
{ stage: 'negotiation', expected_revenue: 36000 },
|
|
251
|
+
]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('should fall back to client-side aggregation when analytics endpoint fails', async () => {
|
|
255
|
+
mockAnalyticsQuery.mockRejectedValue(new Error('Analytics not available'));
|
|
256
|
+
|
|
257
|
+
// Mock find() to return records for client-side aggregation
|
|
258
|
+
(adapter as any).client.data.find = vi.fn().mockResolvedValue({
|
|
259
|
+
records: [
|
|
260
|
+
{ stage: 'Prospect', amount: 100 },
|
|
261
|
+
{ stage: 'Prospect', amount: 200 },
|
|
262
|
+
{ stage: 'Closed Won', amount: 500 },
|
|
263
|
+
],
|
|
264
|
+
total: 3,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const result = await adapter.aggregate('opportunity', {
|
|
268
|
+
field: 'amount',
|
|
269
|
+
function: 'sum',
|
|
270
|
+
groupBy: 'stage',
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
expect(result).toHaveLength(2);
|
|
274
|
+
expect(result.find((r: any) => r.stage === 'Prospect')?.amount).toBe(300);
|
|
275
|
+
expect(result.find((r: any) => r.stage === 'Closed Won')?.amount).toBe(500);
|
|
276
|
+
});
|
|
277
|
+
});
|