@objectstack/driver-memory 0.9.2 → 1.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/CHANGELOG.md +22 -0
- package/README.md +7 -221
- package/dist/memory-driver.d.ts +9 -0
- package/dist/memory-driver.d.ts.map +1 -1
- package/dist/memory-driver.js +185 -8
- package/dist/memory-driver.test.d.ts +2 -0
- package/dist/memory-driver.test.d.ts.map +1 -0
- package/dist/memory-driver.test.js +93 -0
- package/dist/memory-matcher.d.ts +19 -0
- package/dist/memory-matcher.d.ts.map +1 -0
- package/dist/memory-matcher.js +160 -0
- package/package.json +9 -5
- package/src/memory-driver.test.ts +120 -0
- package/src/memory-driver.ts +213 -9
- package/src/memory-matcher.ts +167 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple In-Memory Query Matcher
|
|
3
|
+
*
|
|
4
|
+
* Implements a subset of the ObjectStack Filter Protocol (MongoDB-compatible)
|
|
5
|
+
* for evaluating conditions against in-memory JavaScript objects.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* matches - Check if a record matches a filter criteria
|
|
9
|
+
* @param record The data record to check
|
|
10
|
+
* @param filter The filter condition (where clause)
|
|
11
|
+
*/
|
|
12
|
+
export function match(record, filter) {
|
|
13
|
+
if (!filter || Object.keys(filter).length === 0)
|
|
14
|
+
return true;
|
|
15
|
+
// 1. Handle Top-Level Logical Operators ($and, $or, $not)
|
|
16
|
+
// These usually appear at the root or nested.
|
|
17
|
+
// $and: [ { ... }, { ... } ]
|
|
18
|
+
if (Array.isArray(filter.$and)) {
|
|
19
|
+
if (!filter.$and.every((f) => match(record, f))) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// $or: [ { ... }, { ... } ]
|
|
24
|
+
if (Array.isArray(filter.$or)) {
|
|
25
|
+
if (!filter.$or.some((f) => match(record, f))) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// $not: { ... }
|
|
30
|
+
if (filter.$not) {
|
|
31
|
+
if (match(record, filter.$not)) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// 2. Iterate over field constraints
|
|
36
|
+
for (const key of Object.keys(filter)) {
|
|
37
|
+
// Skip logical operators we already handled (or future ones)
|
|
38
|
+
if (key.startsWith('$'))
|
|
39
|
+
continue;
|
|
40
|
+
const condition = filter[key];
|
|
41
|
+
const value = getValueByPath(record, key);
|
|
42
|
+
if (!checkCondition(value, condition)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Access nested properties via dot-notation (e.g. "user.name")
|
|
50
|
+
*/
|
|
51
|
+
export function getValueByPath(obj, path) {
|
|
52
|
+
if (!path.includes('.'))
|
|
53
|
+
return obj[path];
|
|
54
|
+
return path.split('.').reduce((o, i) => (o ? o[i] : undefined), obj);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Evaluate a specific condition against a value
|
|
58
|
+
*/
|
|
59
|
+
function checkCondition(value, condition) {
|
|
60
|
+
// Case A: Implicit Equality (e.g. status: 'active')
|
|
61
|
+
// If condition is a primitive or Date/Array (exact match), treat as equality.
|
|
62
|
+
if (typeof condition !== 'object' ||
|
|
63
|
+
condition === null ||
|
|
64
|
+
condition instanceof Date ||
|
|
65
|
+
Array.isArray(condition)) {
|
|
66
|
+
// Loose equality to handle undefined/null mismatch or string/number coercion if desired.
|
|
67
|
+
// But stick to == for JS loose equality which is often convenient in weakly typed queries.
|
|
68
|
+
return value == condition;
|
|
69
|
+
}
|
|
70
|
+
// Case B: Operator Object (e.g. { $gt: 10, $lt: 20 })
|
|
71
|
+
const keys = Object.keys(condition);
|
|
72
|
+
const isOperatorObject = keys.some(k => k.startsWith('$'));
|
|
73
|
+
if (!isOperatorObject) {
|
|
74
|
+
// It's just a nested object comparison or implicit equality against an object
|
|
75
|
+
// Simplistic check:
|
|
76
|
+
return JSON.stringify(value) === JSON.stringify(condition);
|
|
77
|
+
}
|
|
78
|
+
// Iterate operators
|
|
79
|
+
for (const op of keys) {
|
|
80
|
+
const target = condition[op];
|
|
81
|
+
// Handle undefined values
|
|
82
|
+
if (value === undefined && op !== '$exists' && op !== '$ne') {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
switch (op) {
|
|
86
|
+
case '$eq':
|
|
87
|
+
if (value != target)
|
|
88
|
+
return false;
|
|
89
|
+
break;
|
|
90
|
+
case '$ne':
|
|
91
|
+
if (value == target)
|
|
92
|
+
return false;
|
|
93
|
+
break;
|
|
94
|
+
// Numeric / Date
|
|
95
|
+
case '$gt':
|
|
96
|
+
if (!(value > target))
|
|
97
|
+
return false;
|
|
98
|
+
break;
|
|
99
|
+
case '$gte':
|
|
100
|
+
if (!(value >= target))
|
|
101
|
+
return false;
|
|
102
|
+
break;
|
|
103
|
+
case '$lt':
|
|
104
|
+
if (!(value < target))
|
|
105
|
+
return false;
|
|
106
|
+
break;
|
|
107
|
+
case '$lte':
|
|
108
|
+
if (!(value <= target))
|
|
109
|
+
return false;
|
|
110
|
+
break;
|
|
111
|
+
case '$between':
|
|
112
|
+
// target should be [min, max]
|
|
113
|
+
if (Array.isArray(target) && (value < target[0] || value > target[1]))
|
|
114
|
+
return false;
|
|
115
|
+
break;
|
|
116
|
+
// Sets
|
|
117
|
+
case '$in':
|
|
118
|
+
if (!Array.isArray(target) || !target.includes(value))
|
|
119
|
+
return false;
|
|
120
|
+
break;
|
|
121
|
+
case '$nin':
|
|
122
|
+
if (Array.isArray(target) && target.includes(value))
|
|
123
|
+
return false;
|
|
124
|
+
break;
|
|
125
|
+
// Existence
|
|
126
|
+
case '$exists':
|
|
127
|
+
const exists = value !== undefined && value !== null;
|
|
128
|
+
if (exists !== !!target)
|
|
129
|
+
return false;
|
|
130
|
+
break;
|
|
131
|
+
// Strings
|
|
132
|
+
case '$contains':
|
|
133
|
+
if (typeof value !== 'string' || !value.includes(target))
|
|
134
|
+
return false;
|
|
135
|
+
break;
|
|
136
|
+
case '$startsWith':
|
|
137
|
+
if (typeof value !== 'string' || !value.startsWith(target))
|
|
138
|
+
return false;
|
|
139
|
+
break;
|
|
140
|
+
case '$endsWith':
|
|
141
|
+
if (typeof value !== 'string' || !value.endsWith(target))
|
|
142
|
+
return false;
|
|
143
|
+
break;
|
|
144
|
+
case '$regex':
|
|
145
|
+
try {
|
|
146
|
+
const re = new RegExp(target, condition.$options || '');
|
|
147
|
+
if (!re.test(String(value)))
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
break;
|
|
154
|
+
default:
|
|
155
|
+
// Unknown operator, ignore or fail. Ignoring safe for optional features.
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return true;
|
|
160
|
+
}
|
package/package.json
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/driver-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"license": "Apache-2.0",
|
|
4
5
|
"description": "In-Memory Driver for ObjectStack (Reference Implementation)",
|
|
5
6
|
"main": "dist/index.js",
|
|
6
7
|
"types": "dist/index.d.ts",
|
|
7
8
|
"dependencies": {
|
|
8
|
-
"@objectstack/core": "0.
|
|
9
|
-
"@objectstack/spec": "0.
|
|
9
|
+
"@objectstack/core": "1.0.1",
|
|
10
|
+
"@objectstack/spec": "1.0.1"
|
|
10
11
|
},
|
|
11
12
|
"devDependencies": {
|
|
12
|
-
"typescript": "^5.0.0"
|
|
13
|
+
"typescript": "^5.0.0",
|
|
14
|
+
"vitest": "^4.0.18",
|
|
15
|
+
"@types/node": "^25.1.0"
|
|
13
16
|
},
|
|
14
17
|
"scripts": {
|
|
15
18
|
"build": "tsc",
|
|
16
|
-
"dev": "tsc -w"
|
|
19
|
+
"dev": "tsc -w",
|
|
20
|
+
"test": "vitest run"
|
|
17
21
|
}
|
|
18
22
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { InMemoryDriver } from './memory-driver.js';
|
|
3
|
+
|
|
4
|
+
describe('InMemoryDriver', () => {
|
|
5
|
+
let driver: InMemoryDriver;
|
|
6
|
+
const testTable = 'test_table';
|
|
7
|
+
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
driver = new InMemoryDriver();
|
|
10
|
+
await driver.connect();
|
|
11
|
+
// No explicit clear DB method exposed, but new instance is clean.
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('Lifecycle', () => {
|
|
15
|
+
it('should connect successfully', async () => {
|
|
16
|
+
expect(driver.checkHealth()).resolves.toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should clear data on disconnect', async () => {
|
|
20
|
+
await driver.create(testTable, { id: '1', name: 'test' });
|
|
21
|
+
await driver.disconnect();
|
|
22
|
+
const results = await driver.find(testTable, { fields: ['id'], object: testTable });
|
|
23
|
+
expect(results).toHaveLength(0);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('Plugin Installation', () => {
|
|
28
|
+
it('should register driver with engine', () => {
|
|
29
|
+
const registerDriverFn = vi.fn();
|
|
30
|
+
const mockEngine = {
|
|
31
|
+
ql: {
|
|
32
|
+
registerDriver: registerDriverFn
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
driver.install({ engine: mockEngine });
|
|
37
|
+
expect(registerDriverFn).toHaveBeenCalledWith(driver);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should handle missing engine gracefully', () => {
|
|
41
|
+
const mockCtx = {}; // No engine
|
|
42
|
+
// Should not throw
|
|
43
|
+
driver.install(mockCtx);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('CRUD Operations', () => {
|
|
48
|
+
it('should create and find records', async () => {
|
|
49
|
+
const data = { id: '1', name: 'Alice', age: 30 };
|
|
50
|
+
const created = await driver.create(testTable, data);
|
|
51
|
+
expect(created.id).toBe('1');
|
|
52
|
+
|
|
53
|
+
const results = await driver.find(testTable, {
|
|
54
|
+
fields: ['id', 'name', 'age'],
|
|
55
|
+
object: testTable
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(results).toHaveLength(1);
|
|
59
|
+
expect(results[0].id).toBe('1');
|
|
60
|
+
expect(results[0].name).toBe('Alice');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should support updating records by ID', async () => {
|
|
64
|
+
await driver.create(testTable, { id: '1', name: 'Bob', active: true });
|
|
65
|
+
|
|
66
|
+
const updateResult = await driver.update(
|
|
67
|
+
testTable,
|
|
68
|
+
'1',
|
|
69
|
+
{ active: false }
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(updateResult.active).toBe(false);
|
|
73
|
+
|
|
74
|
+
const results = await driver.find(testTable, { fields: ['active'], object: testTable });
|
|
75
|
+
expect(results[0].active).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should support deleting records by ID', async () => {
|
|
79
|
+
await driver.create(testTable, { id: '1', name: 'Charlie' });
|
|
80
|
+
await driver.create(testTable, { id: '2', name: 'David' });
|
|
81
|
+
|
|
82
|
+
const deleteResult = await driver.delete(testTable, '1');
|
|
83
|
+
expect(deleteResult).toBe(true);
|
|
84
|
+
|
|
85
|
+
const results = await driver.find(testTable, { fields: ['name'], object: testTable });
|
|
86
|
+
expect(results).toHaveLength(1);
|
|
87
|
+
expect(results[0].name).toBe('David');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('Query Capability', () => {
|
|
92
|
+
it('should filter results', async () => {
|
|
93
|
+
await driver.create(testTable, { id: '1', role: 'admin' });
|
|
94
|
+
await driver.create(testTable, { id: '2', role: 'user' });
|
|
95
|
+
await driver.create(testTable, { id: '3', role: 'user' });
|
|
96
|
+
|
|
97
|
+
const results = await driver.find(testTable, {
|
|
98
|
+
fields: ['id'],
|
|
99
|
+
object: testTable,
|
|
100
|
+
where: { role: 'user' }
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(results).toHaveLength(2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should limit results', async () => {
|
|
107
|
+
await driver.create(testTable, { id: '1' });
|
|
108
|
+
await driver.create(testTable, { id: '2' });
|
|
109
|
+
await driver.create(testTable, { id: '3' });
|
|
110
|
+
|
|
111
|
+
const results = await driver.find(testTable, {
|
|
112
|
+
fields: ['id'],
|
|
113
|
+
object: testTable,
|
|
114
|
+
limit: 2
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(results).toHaveLength(2);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
});
|
package/src/memory-driver.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { QueryAST, QueryInput } from '@objectstack/spec/data';
|
|
2
2
|
import { DriverOptions } from '@objectstack/spec/data';
|
|
3
3
|
import { DriverInterface, Logger, createLogger } from '@objectstack/core';
|
|
4
|
+
import { match, getValueByPath } from './memory-matcher.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Example: In-Memory Driver
|
|
@@ -36,10 +37,10 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
36
37
|
transactions: false,
|
|
37
38
|
|
|
38
39
|
// Query Operations
|
|
39
|
-
queryFilters:
|
|
40
|
-
queryAggregations:
|
|
41
|
-
querySorting:
|
|
42
|
-
queryPagination: true, //
|
|
40
|
+
queryFilters: true, // Implemented via memory-matcher
|
|
41
|
+
queryAggregations: true, // Implemented
|
|
42
|
+
querySorting: true, // Implemented via JS sort
|
|
43
|
+
queryPagination: true, // Implemented
|
|
43
44
|
queryWindowFunctions: false, // TODO: Not implemented
|
|
44
45
|
querySubqueries: false, // TODO: Not implemented
|
|
45
46
|
joins: false, // TODO: Not implemented
|
|
@@ -101,11 +102,43 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
101
102
|
this.logger.debug('Find operation', { object, query });
|
|
102
103
|
|
|
103
104
|
const table = this.getTable(object);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
let results = table;
|
|
106
|
+
|
|
107
|
+
// 1. Filter
|
|
108
|
+
if (query.where) {
|
|
109
|
+
results = results.filter(record => match(record, query.where));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 1.5 Aggregation & Grouping
|
|
113
|
+
if (query.groupBy || (query.aggregations && query.aggregations.length > 0)) {
|
|
114
|
+
results = this.performAggregation(results, query);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 2. Sort
|
|
118
|
+
if (query.orderBy) {
|
|
119
|
+
// Normalize sort to array
|
|
120
|
+
const sortFields = Array.isArray(query.orderBy) ? query.orderBy : [query.orderBy];
|
|
121
|
+
|
|
122
|
+
results.sort((a, b) => {
|
|
123
|
+
for (const { field, order } of sortFields) {
|
|
124
|
+
const valA = getValueByPath(a, field);
|
|
125
|
+
const valB = getValueByPath(b, field);
|
|
126
|
+
|
|
127
|
+
if (valA === valB) continue;
|
|
128
|
+
|
|
129
|
+
const comparison = valA > valB ? 1 : -1;
|
|
130
|
+
return order === 'desc' ? -comparison : comparison;
|
|
131
|
+
}
|
|
132
|
+
return 0;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
107
135
|
|
|
108
|
-
//
|
|
136
|
+
// 3. Pagination (Offset)
|
|
137
|
+
if (query.offset) {
|
|
138
|
+
results = results.slice(query.offset);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 4. Pagination (Limit)
|
|
109
142
|
if (query.limit) {
|
|
110
143
|
results = results.slice(0, query.limit);
|
|
111
144
|
}
|
|
@@ -211,7 +244,11 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
211
244
|
}
|
|
212
245
|
|
|
213
246
|
async count(object: string, query?: QueryInput, options?: DriverOptions) {
|
|
214
|
-
|
|
247
|
+
let results = this.getTable(object);
|
|
248
|
+
if (query?.where) {
|
|
249
|
+
results = results.filter(record => match(record, query.where));
|
|
250
|
+
}
|
|
251
|
+
const count = results.length;
|
|
215
252
|
this.logger.debug('Count operation', { object, count });
|
|
216
253
|
return count;
|
|
217
254
|
}
|
|
@@ -226,7 +263,62 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
226
263
|
this.logger.debug('BulkCreate completed', { object, count: results.length });
|
|
227
264
|
return results;
|
|
228
265
|
}
|
|
266
|
+
|
|
267
|
+
async updateMany(object: string, query: QueryInput, data: Record<string, any>, options?: DriverOptions) {
|
|
268
|
+
this.logger.debug('UpdateMany operation', { object, query });
|
|
269
|
+
|
|
270
|
+
const table = this.getTable(object);
|
|
271
|
+
let targetRecords = table;
|
|
272
|
+
|
|
273
|
+
if (query && query.where) {
|
|
274
|
+
targetRecords = targetRecords.filter(r => match(r, query.where));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const count = targetRecords.length;
|
|
278
|
+
|
|
279
|
+
// Update each record
|
|
280
|
+
for (const record of targetRecords) {
|
|
281
|
+
// Find index in original table
|
|
282
|
+
const index = table.findIndex(r => r.id === record.id);
|
|
283
|
+
if (index !== -1) {
|
|
284
|
+
const updated = {
|
|
285
|
+
...table[index],
|
|
286
|
+
...data,
|
|
287
|
+
updated_at: new Date()
|
|
288
|
+
};
|
|
289
|
+
table[index] = updated;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
this.logger.debug('UpdateMany completed', { object, count });
|
|
294
|
+
return { count };
|
|
295
|
+
}
|
|
229
296
|
|
|
297
|
+
async deleteMany(object: string, query: QueryInput, options?: DriverOptions) {
|
|
298
|
+
this.logger.debug('DeleteMany operation', { object, query });
|
|
299
|
+
|
|
300
|
+
const table = this.getTable(object);
|
|
301
|
+
const initialLength = table.length;
|
|
302
|
+
|
|
303
|
+
// Filter IN PLACE or create new array?
|
|
304
|
+
// Creating new array is safer for now.
|
|
305
|
+
|
|
306
|
+
const remaining = table.filter(r => {
|
|
307
|
+
if (!query || !query.where) return false; // Delete all? No, standard safety implies explicit empty filter for delete all.
|
|
308
|
+
// Wait, normally deleteMany({}) deletes all.
|
|
309
|
+
// Let's assume if query passed, use it.
|
|
310
|
+
const matches = match(r, query.where);
|
|
311
|
+
return !matches; // Keep if it DOES NOT match
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
this.db[object] = remaining;
|
|
315
|
+
const count = initialLength - remaining.length;
|
|
316
|
+
|
|
317
|
+
this.logger.debug('DeleteMany completed', { object, count });
|
|
318
|
+
return { count };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Compatibility aliases
|
|
230
322
|
async bulkUpdate(object: string, updates: { id: string | number, data: Record<string, any> }[], options?: DriverOptions) {
|
|
231
323
|
this.logger.debug('BulkUpdate operation', { object, count: updates.length });
|
|
232
324
|
const results = await Promise.all(updates.map(u => this.update(object, u.id, u.data, options)));
|
|
@@ -266,6 +358,118 @@ export class InMemoryDriver implements DriverInterface {
|
|
|
266
358
|
async commit() { /* No-op */ }
|
|
267
359
|
async rollback() { /* No-op */ }
|
|
268
360
|
|
|
361
|
+
// ===================================
|
|
362
|
+
// Aggregation Logic
|
|
363
|
+
// ===================================
|
|
364
|
+
|
|
365
|
+
private performAggregation(records: any[], query: QueryInput): any[] {
|
|
366
|
+
const { groupBy, aggregations } = query;
|
|
367
|
+
const groups: Map<string, any[]> = new Map();
|
|
368
|
+
|
|
369
|
+
// 1. Group records
|
|
370
|
+
if (groupBy && groupBy.length > 0) {
|
|
371
|
+
for (const record of records) {
|
|
372
|
+
// Create a composite key from group values
|
|
373
|
+
const keyParts = groupBy.map(field => {
|
|
374
|
+
const val = getValueByPath(record, field);
|
|
375
|
+
return val === undefined || val === null ? 'null' : String(val);
|
|
376
|
+
});
|
|
377
|
+
const key = JSON.stringify(keyParts);
|
|
378
|
+
|
|
379
|
+
if (!groups.has(key)) {
|
|
380
|
+
groups.set(key, []);
|
|
381
|
+
}
|
|
382
|
+
groups.get(key)!.push(record);
|
|
383
|
+
}
|
|
384
|
+
} else {
|
|
385
|
+
// No grouping -> Single group containing all records
|
|
386
|
+
// If aggregation is requested without group by, it runs on whole set (even if empty)
|
|
387
|
+
if (aggregations && aggregations.length > 0) {
|
|
388
|
+
groups.set('all', records);
|
|
389
|
+
} else {
|
|
390
|
+
// Should not be here if performAggregation called correctly
|
|
391
|
+
groups.set('all', records);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// 2. Compute aggregates for each group
|
|
396
|
+
const resultRows: any[] = [];
|
|
397
|
+
|
|
398
|
+
for (const [key, groupRecords] of groups.entries()) {
|
|
399
|
+
const row: any = {};
|
|
400
|
+
|
|
401
|
+
// A. Add Group fields to row (if groupBy exists)
|
|
402
|
+
if (groupBy && groupBy.length > 0) {
|
|
403
|
+
if (groupRecords.length > 0) {
|
|
404
|
+
const firstRecord = groupRecords[0];
|
|
405
|
+
for (const field of groupBy) {
|
|
406
|
+
this.setValueByPath(row, field, getValueByPath(firstRecord, field));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// B. Compute Aggregations
|
|
412
|
+
if (aggregations) {
|
|
413
|
+
for (const agg of aggregations) {
|
|
414
|
+
const value = this.computeAggregate(groupRecords, agg);
|
|
415
|
+
row[agg.alias] = value;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
resultRows.push(row);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return resultRows;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private computeAggregate(records: any[], agg: any): any {
|
|
426
|
+
const { function: func, field } = agg;
|
|
427
|
+
|
|
428
|
+
const values = field ? records.map(r => getValueByPath(r, field)) : [];
|
|
429
|
+
|
|
430
|
+
switch (func) {
|
|
431
|
+
case 'count':
|
|
432
|
+
if (!field || field === '*') return records.length;
|
|
433
|
+
return values.filter(v => v !== null && v !== undefined).length;
|
|
434
|
+
|
|
435
|
+
case 'sum':
|
|
436
|
+
case 'avg': {
|
|
437
|
+
const nums = values.filter(v => typeof v === 'number');
|
|
438
|
+
const sum = nums.reduce((a, b) => a + b, 0);
|
|
439
|
+
if (func === 'sum') return sum;
|
|
440
|
+
return nums.length > 0 ? sum / nums.length : null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
case 'min': {
|
|
444
|
+
// Handle comparable values
|
|
445
|
+
const valid = values.filter(v => v !== null && v !== undefined);
|
|
446
|
+
if (valid.length === 0) return null;
|
|
447
|
+
// Works for numbers and strings
|
|
448
|
+
return valid.reduce((min, v) => (v < min ? v : min), valid[0]);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
case 'max': {
|
|
452
|
+
const valid = values.filter(v => v !== null && v !== undefined);
|
|
453
|
+
if (valid.length === 0) return null;
|
|
454
|
+
return valid.reduce((max, v) => (v > max ? v : max), valid[0]);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
default:
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private setValueByPath(obj: any, path: string, value: any) {
|
|
463
|
+
const parts = path.split('.');
|
|
464
|
+
let current = obj;
|
|
465
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
466
|
+
const part = parts[i];
|
|
467
|
+
if (!current[part]) current[part] = {};
|
|
468
|
+
current = current[part];
|
|
469
|
+
}
|
|
470
|
+
current[parts[parts.length - 1]] = value;
|
|
471
|
+
}
|
|
472
|
+
|
|
269
473
|
// ===================================
|
|
270
474
|
// Helpers
|
|
271
475
|
// ===================================
|