@object-ui/core 3.0.2 → 3.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.
Files changed (79) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +6 -0
  3. package/dist/actions/ActionEngine.d.ts +98 -0
  4. package/dist/actions/ActionEngine.js +222 -0
  5. package/dist/actions/UndoManager.d.ts +80 -0
  6. package/dist/actions/UndoManager.js +193 -0
  7. package/dist/actions/index.d.ts +2 -0
  8. package/dist/actions/index.js +2 -0
  9. package/dist/adapters/ApiDataSource.d.ts +2 -1
  10. package/dist/adapters/ApiDataSource.js +25 -0
  11. package/dist/adapters/ValueDataSource.d.ts +2 -1
  12. package/dist/adapters/ValueDataSource.js +99 -3
  13. package/dist/data-scope/ViewDataProvider.d.ts +143 -0
  14. package/dist/data-scope/ViewDataProvider.js +153 -0
  15. package/dist/data-scope/index.d.ts +1 -0
  16. package/dist/data-scope/index.js +1 -0
  17. package/dist/evaluator/ExpressionEvaluator.d.ts +7 -0
  18. package/dist/evaluator/ExpressionEvaluator.js +19 -0
  19. package/dist/index.d.ts +5 -0
  20. package/dist/index.js +5 -0
  21. package/dist/protocols/DndProtocol.d.ts +84 -0
  22. package/dist/protocols/DndProtocol.js +113 -0
  23. package/dist/protocols/KeyboardProtocol.d.ts +93 -0
  24. package/dist/protocols/KeyboardProtocol.js +108 -0
  25. package/dist/protocols/NotificationProtocol.d.ts +71 -0
  26. package/dist/protocols/NotificationProtocol.js +99 -0
  27. package/dist/protocols/ResponsiveProtocol.d.ts +73 -0
  28. package/dist/protocols/ResponsiveProtocol.js +158 -0
  29. package/dist/protocols/SharingProtocol.d.ts +71 -0
  30. package/dist/protocols/SharingProtocol.js +124 -0
  31. package/dist/protocols/index.d.ts +12 -0
  32. package/dist/protocols/index.js +12 -0
  33. package/dist/utils/debug-collector.d.ts +59 -0
  34. package/dist/utils/debug-collector.js +73 -0
  35. package/dist/utils/debug.d.ts +37 -2
  36. package/dist/utils/debug.js +62 -3
  37. package/dist/utils/expand-fields.d.ts +40 -0
  38. package/dist/utils/expand-fields.js +68 -0
  39. package/dist/utils/extract-records.d.ts +16 -0
  40. package/dist/utils/extract-records.js +32 -0
  41. package/dist/utils/normalize-quick-filter.d.ts +29 -0
  42. package/dist/utils/normalize-quick-filter.js +66 -0
  43. package/package.json +3 -3
  44. package/src/__tests__/protocols/DndProtocol.test.ts +186 -0
  45. package/src/__tests__/protocols/KeyboardProtocol.test.ts +177 -0
  46. package/src/__tests__/protocols/NotificationProtocol.test.ts +142 -0
  47. package/src/__tests__/protocols/ResponsiveProtocol.test.ts +176 -0
  48. package/src/__tests__/protocols/SharingProtocol.test.ts +188 -0
  49. package/src/actions/ActionEngine.ts +268 -0
  50. package/src/actions/UndoManager.ts +215 -0
  51. package/src/actions/__tests__/ActionEngine.test.ts +206 -0
  52. package/src/actions/__tests__/UndoManager.test.ts +320 -0
  53. package/src/actions/index.ts +2 -0
  54. package/src/adapters/ApiDataSource.ts +27 -0
  55. package/src/adapters/ValueDataSource.ts +109 -3
  56. package/src/adapters/__tests__/ValueDataSource.test.ts +147 -0
  57. package/src/data-scope/ViewDataProvider.ts +282 -0
  58. package/src/data-scope/__tests__/ViewDataProvider.test.ts +270 -0
  59. package/src/data-scope/index.ts +8 -0
  60. package/src/evaluator/ExpressionEvaluator.ts +22 -0
  61. package/src/evaluator/__tests__/ExpressionEvaluator.test.ts +31 -1
  62. package/src/index.ts +5 -0
  63. package/src/protocols/DndProtocol.ts +184 -0
  64. package/src/protocols/KeyboardProtocol.ts +185 -0
  65. package/src/protocols/NotificationProtocol.ts +159 -0
  66. package/src/protocols/ResponsiveProtocol.ts +210 -0
  67. package/src/protocols/SharingProtocol.ts +185 -0
  68. package/src/{index.test.ts → protocols/index.ts} +5 -7
  69. package/src/utils/__tests__/debug-collector.test.ts +102 -0
  70. package/src/utils/__tests__/debug.test.ts +52 -1
  71. package/src/utils/__tests__/expand-fields.test.ts +120 -0
  72. package/src/utils/__tests__/extract-records.test.ts +50 -0
  73. package/src/utils/__tests__/normalize-quick-filter.test.ts +123 -0
  74. package/src/utils/debug-collector.ts +100 -0
  75. package/src/utils/debug.ts +87 -6
  76. package/src/utils/expand-fields.ts +76 -0
  77. package/src/utils/extract-records.ts +33 -0
  78. package/src/utils/normalize-quick-filter.ts +78 -0
  79. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,320 @@
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 { UndoManager, type UndoableOperation } from '../UndoManager';
11
+
12
+ function makeOp(id: string, type: 'create' | 'update' | 'delete' = 'update'): UndoableOperation {
13
+ return {
14
+ id,
15
+ type,
16
+ objectName: 'Account',
17
+ recordId: `rec-${id}`,
18
+ timestamp: Date.now(),
19
+ description: `op-${id}`,
20
+ undoData: { prev: id },
21
+ redoData: { next: id },
22
+ };
23
+ }
24
+
25
+ describe('UndoManager', () => {
26
+ let manager: UndoManager;
27
+
28
+ beforeEach(() => {
29
+ manager = new UndoManager({ maxHistory: 5 });
30
+ });
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Basic push / pop (existing behaviour)
34
+ // ---------------------------------------------------------------------------
35
+ describe('basic push/pop', () => {
36
+ it('pushes and peeks', () => {
37
+ const op = makeOp('1');
38
+ manager.push(op);
39
+ expect(manager.canUndo).toBe(true);
40
+ expect(manager.peekUndo()).toEqual(op);
41
+ expect(manager.undoCount).toBe(1);
42
+ });
43
+
44
+ it('popUndo moves to redo stack', () => {
45
+ const op = makeOp('1');
46
+ manager.push(op);
47
+ const popped = manager.popUndo();
48
+ expect(popped).toEqual(op);
49
+ expect(manager.canUndo).toBe(false);
50
+ expect(manager.canRedo).toBe(true);
51
+ expect(manager.peekRedo()).toEqual(op);
52
+ });
53
+
54
+ it('popRedo moves back to undo stack', () => {
55
+ manager.push(makeOp('1'));
56
+ manager.popUndo();
57
+ const redone = manager.popRedo();
58
+ expect(redone?.id).toBe('1');
59
+ expect(manager.canUndo).toBe(true);
60
+ expect(manager.canRedo).toBe(false);
61
+ });
62
+
63
+ it('push clears redo stack', () => {
64
+ manager.push(makeOp('1'));
65
+ manager.popUndo();
66
+ expect(manager.canRedo).toBe(true);
67
+ manager.push(makeOp('2'));
68
+ expect(manager.canRedo).toBe(false);
69
+ });
70
+
71
+ it('trims beyond maxHistory', () => {
72
+ for (let i = 0; i < 7; i++) manager.push(makeOp(String(i)));
73
+ expect(manager.undoCount).toBe(5);
74
+ // Oldest operations should have been shifted out
75
+ expect(manager.getHistory()[0].id).toBe('2');
76
+ });
77
+
78
+ it('clear removes everything', () => {
79
+ manager.push(makeOp('1'));
80
+ manager.clear();
81
+ expect(manager.canUndo).toBe(false);
82
+ expect(manager.canRedo).toBe(false);
83
+ });
84
+
85
+ it('subscribe notifies on changes', () => {
86
+ const listener = vi.fn();
87
+ const unsub = manager.subscribe(listener);
88
+ manager.push(makeOp('1'));
89
+ expect(listener).toHaveBeenCalledTimes(1);
90
+ unsub();
91
+ manager.push(makeOp('2'));
92
+ expect(listener).toHaveBeenCalledTimes(1);
93
+ });
94
+ });
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // pushBatch
98
+ // ---------------------------------------------------------------------------
99
+ describe('pushBatch', () => {
100
+ it('pushes multiple operations atomically', () => {
101
+ const ops = [makeOp('a'), makeOp('b'), makeOp('c')];
102
+ manager.pushBatch(ops);
103
+ expect(manager.undoCount).toBe(3);
104
+ expect(manager.getHistory().map((o) => o.id)).toEqual(['a', 'b', 'c']);
105
+ });
106
+
107
+ it('clears redo stack', () => {
108
+ manager.push(makeOp('1'));
109
+ manager.popUndo();
110
+ expect(manager.canRedo).toBe(true);
111
+ manager.pushBatch([makeOp('x')]);
112
+ expect(manager.canRedo).toBe(false);
113
+ });
114
+
115
+ it('notifies listeners exactly once', () => {
116
+ const listener = vi.fn();
117
+ manager.subscribe(listener);
118
+ manager.pushBatch([makeOp('a'), makeOp('b')]);
119
+ expect(listener).toHaveBeenCalledTimes(1);
120
+ });
121
+
122
+ it('is a no-op for empty array', () => {
123
+ const listener = vi.fn();
124
+ manager.subscribe(listener);
125
+ manager.pushBatch([]);
126
+ expect(listener).not.toHaveBeenCalled();
127
+ expect(manager.undoCount).toBe(0);
128
+ });
129
+
130
+ it('trims to maxHistory when batch exceeds limit', () => {
131
+ const ops = Array.from({ length: 8 }, (_, i) => makeOp(String(i)));
132
+ manager.pushBatch(ops);
133
+ expect(manager.undoCount).toBe(5);
134
+ // Oldest should be trimmed
135
+ expect(manager.getHistory()[0].id).toBe('3');
136
+ expect(manager.getHistory()[4].id).toBe('7');
137
+ });
138
+ });
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // popUndoBatch / popRedoBatch
142
+ // ---------------------------------------------------------------------------
143
+ describe('popUndoBatch', () => {
144
+ it('pops multiple operations from undo to redo', () => {
145
+ manager.pushBatch([makeOp('a'), makeOp('b'), makeOp('c')]);
146
+ const popped = manager.popUndoBatch(2);
147
+ expect(popped.map((o) => o.id)).toEqual(['b', 'c']);
148
+ expect(manager.undoCount).toBe(1);
149
+ expect(manager.redoCount).toBe(2);
150
+ });
151
+
152
+ it('clamps to available stack size', () => {
153
+ manager.push(makeOp('1'));
154
+ const popped = manager.popUndoBatch(10);
155
+ expect(popped).toHaveLength(1);
156
+ expect(manager.undoCount).toBe(0);
157
+ });
158
+
159
+ it('returns empty array when nothing to undo', () => {
160
+ expect(manager.popUndoBatch(3)).toEqual([]);
161
+ });
162
+
163
+ it('notifies listeners once', () => {
164
+ manager.pushBatch([makeOp('a'), makeOp('b')]);
165
+ const listener = vi.fn();
166
+ manager.subscribe(listener);
167
+ manager.popUndoBatch(2);
168
+ expect(listener).toHaveBeenCalledTimes(1);
169
+ });
170
+ });
171
+
172
+ describe('popRedoBatch', () => {
173
+ it('pops multiple operations from redo to undo', () => {
174
+ manager.pushBatch([makeOp('a'), makeOp('b'), makeOp('c')]);
175
+ manager.popUndoBatch(3);
176
+ expect(manager.redoCount).toBe(3);
177
+ const redone = manager.popRedoBatch(2);
178
+ expect(redone.map((o) => o.id)).toEqual(['b', 'c']);
179
+ expect(manager.undoCount).toBe(2);
180
+ expect(manager.redoCount).toBe(1);
181
+ });
182
+
183
+ it('clamps to available stack size', () => {
184
+ manager.push(makeOp('1'));
185
+ manager.popUndo();
186
+ const redone = manager.popRedoBatch(5);
187
+ expect(redone).toHaveLength(1);
188
+ });
189
+
190
+ it('returns empty array when nothing to redo', () => {
191
+ expect(manager.popRedoBatch(3)).toEqual([]);
192
+ });
193
+ });
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // getHistory / getRedoHistory
197
+ // ---------------------------------------------------------------------------
198
+ describe('getHistory / getRedoHistory', () => {
199
+ it('getHistory returns shallow copy of undo stack', () => {
200
+ manager.push(makeOp('1'));
201
+ manager.push(makeOp('2'));
202
+ const history = manager.getHistory();
203
+ expect(history).toHaveLength(2);
204
+ // Mutating the copy must not affect the manager
205
+ history.pop();
206
+ expect(manager.undoCount).toBe(2);
207
+ });
208
+
209
+ it('getRedoHistory returns shallow copy of redo stack', () => {
210
+ manager.push(makeOp('1'));
211
+ manager.push(makeOp('2'));
212
+ manager.popUndo();
213
+ const redoHistory = manager.getRedoHistory();
214
+ expect(redoHistory).toHaveLength(1);
215
+ expect(redoHistory[0].id).toBe('2');
216
+ // Mutating the copy must not affect the manager
217
+ redoHistory.pop();
218
+ expect(manager.redoCount).toBe(1);
219
+ });
220
+ });
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // saveToStorage / loadFromStorage
224
+ // ---------------------------------------------------------------------------
225
+ describe('persistence', () => {
226
+ let storage: Record<string, string>;
227
+
228
+ beforeEach(() => {
229
+ storage = {};
230
+ vi.stubGlobal('localStorage', {
231
+ getItem: vi.fn((key: string) => storage[key] ?? null),
232
+ setItem: vi.fn((key: string, value: string) => { storage[key] = value; }),
233
+ removeItem: vi.fn((key: string) => { delete storage[key]; }),
234
+ });
235
+ });
236
+
237
+ it('round-trips undo/redo stacks through localStorage', () => {
238
+ manager.push(makeOp('1'));
239
+ manager.push(makeOp('2'));
240
+ manager.popUndo(); // '2' goes to redo
241
+
242
+ manager.saveToStorage();
243
+
244
+ const fresh = new UndoManager({ maxHistory: 5 });
245
+ fresh.loadFromStorage();
246
+
247
+ expect(fresh.undoCount).toBe(1);
248
+ expect(fresh.redoCount).toBe(1);
249
+ expect(fresh.peekUndo()?.id).toBe('1');
250
+ expect(fresh.peekRedo()?.id).toBe('2');
251
+ });
252
+
253
+ it('preserves all UndoableOperation fields', () => {
254
+ const op = makeOp('full');
255
+ manager.push(op);
256
+ manager.saveToStorage();
257
+
258
+ const fresh = new UndoManager();
259
+ fresh.loadFromStorage();
260
+ const restored = fresh.peekUndo()!;
261
+ expect(restored.id).toBe(op.id);
262
+ expect(restored.type).toBe(op.type);
263
+ expect(restored.objectName).toBe(op.objectName);
264
+ expect(restored.recordId).toBe(op.recordId);
265
+ expect(restored.timestamp).toBe(op.timestamp);
266
+ expect(restored.description).toBe(op.description);
267
+ expect(restored.undoData).toEqual(op.undoData);
268
+ expect(restored.redoData).toEqual(op.redoData);
269
+ });
270
+
271
+ it('is a no-op when localStorage has no data', () => {
272
+ const fresh = new UndoManager();
273
+ fresh.loadFromStorage();
274
+ expect(fresh.undoCount).toBe(0);
275
+ expect(fresh.redoCount).toBe(0);
276
+ });
277
+
278
+ it('handles corrupt localStorage data gracefully', () => {
279
+ storage['objectui:undo-history'] = 'not-valid-json!!!';
280
+ const fresh = new UndoManager();
281
+ expect(() => fresh.loadFromStorage()).not.toThrow();
282
+ expect(fresh.undoCount).toBe(0);
283
+ });
284
+
285
+ it('notifies listeners after loading', () => {
286
+ manager.push(makeOp('1'));
287
+ manager.saveToStorage();
288
+
289
+ const fresh = new UndoManager();
290
+ const listener = vi.fn();
291
+ fresh.subscribe(listener);
292
+ fresh.loadFromStorage();
293
+ expect(listener).toHaveBeenCalledTimes(1);
294
+ });
295
+ });
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // Boundary: batch larger than maxHistory
299
+ // ---------------------------------------------------------------------------
300
+ describe('boundary: batch larger than maxHistory', () => {
301
+ it('trims oldest entries when batch overflows maxHistory', () => {
302
+ // maxHistory is 5
303
+ manager.push(makeOp('existing'));
304
+ const batch = Array.from({ length: 6 }, (_, i) => makeOp(`batch-${i}`));
305
+ manager.pushBatch(batch);
306
+ expect(manager.undoCount).toBe(5);
307
+ // 1 existing + 6 batch = 7 total, trimmed to last 5
308
+ const ids = manager.getHistory().map((o) => o.id);
309
+ expect(ids).toEqual(['batch-1', 'batch-2', 'batch-3', 'batch-4', 'batch-5']);
310
+ });
311
+
312
+ it('works when batch itself equals maxHistory', () => {
313
+ const batch = Array.from({ length: 5 }, (_, i) => makeOp(`b-${i}`));
314
+ manager.pushBatch(batch);
315
+ expect(manager.undoCount).toBe(5);
316
+ expect(manager.getHistory()[0].id).toBe('b-0');
317
+ expect(manager.getHistory()[4].id).toBe('b-4');
318
+ });
319
+ });
320
+ });
@@ -7,4 +7,6 @@
7
7
  */
8
8
 
9
9
  export * from './ActionRunner.js';
10
+ export * from './ActionEngine.js';
10
11
  export * from './TransactionManager.js';
12
+ export * from './UndoManager.js';
@@ -15,6 +15,8 @@ import type {
15
15
  QueryResult,
16
16
  HttpRequest,
17
17
  HttpMethod,
18
+ AggregateParams,
19
+ AggregateResult,
18
20
  } from '@object-ui/types';
19
21
 
20
22
  // ---------------------------------------------------------------------------
@@ -297,6 +299,31 @@ export class ApiDataSource<T = any> implements DataSource<T> {
297
299
  return null;
298
300
  }
299
301
 
302
+ async aggregate(_resource: string, params: AggregateParams): Promise<AggregateResult[]> {
303
+ const queryParams: Record<string, unknown> = {
304
+ field: params.field,
305
+ function: params.function,
306
+ groupBy: params.groupBy,
307
+ };
308
+ if (params.filter) {
309
+ queryParams.filter = typeof params.filter === 'string'
310
+ ? params.filter
311
+ : JSON.stringify(params.filter);
312
+ }
313
+
314
+ const raw = await this.request<any>(this.readConfig, {
315
+ pathSuffix: 'aggregate',
316
+ method: 'GET',
317
+ queryParams,
318
+ });
319
+
320
+ // Normalize: the API might return an array or an object with data/results
321
+ if (Array.isArray(raw)) return raw;
322
+ if (raw?.data && Array.isArray(raw.data)) return raw.data;
323
+ if (raw?.results && Array.isArray(raw.results)) return raw.results;
324
+ return [];
325
+ }
326
+
300
327
  // -----------------------------------------------------------------------
301
328
  // Helpers
302
329
  // -----------------------------------------------------------------------
@@ -13,6 +13,8 @@ import type {
13
13
  DataSource,
14
14
  QueryParams,
15
15
  QueryResult,
16
+ AggregateParams,
17
+ AggregateResult,
16
18
  } from '@object-ui/types';
17
19
 
18
20
  // ---------------------------------------------------------------------------
@@ -36,6 +38,69 @@ function getRecordId(record: any, idField?: string): string | number | undefined
36
38
  return record._id ?? record.id;
37
39
  }
38
40
 
41
+ /**
42
+ * Evaluate an AST-format filter node against a record.
43
+ * Supports conditions like ['field', 'op', value] and logical
44
+ * combinations like ['and', ...conditions] or ['or', ...conditions].
45
+ */
46
+ function matchesASTFilter(record: any, filterNode: any[]): boolean {
47
+ if (!filterNode || filterNode.length === 0) return true;
48
+
49
+ const head = filterNode[0];
50
+
51
+ // Logical operators: ['and', ...conditions] or ['or', ...conditions]
52
+ if (head === 'and') {
53
+ return filterNode.slice(1).every((sub: any) => matchesASTFilter(record, sub));
54
+ }
55
+ if (head === 'or') {
56
+ return filterNode.slice(1).some((sub: any) => matchesASTFilter(record, sub));
57
+ }
58
+
59
+ // Condition node: [field, operator, value]
60
+ if (filterNode.length === 3 && typeof head === 'string') {
61
+ const [field, operator, target] = filterNode;
62
+ const value = record[field];
63
+
64
+ switch (operator) {
65
+ case '=':
66
+ return value === target;
67
+ case '!=':
68
+ return value !== target;
69
+ case '>':
70
+ return value > target;
71
+ case '>=':
72
+ return value >= target;
73
+ case '<':
74
+ return value < target;
75
+ case '<=':
76
+ return value <= target;
77
+ case 'in':
78
+ return Array.isArray(target) && target.includes(value);
79
+ case 'not in':
80
+ case 'notin': // alias used by convertFiltersToAST
81
+ return Array.isArray(target) && !target.includes(value);
82
+ case 'contains': {
83
+ const lv = typeof value === 'string' ? value.toLowerCase() : '';
84
+ return typeof value === 'string' && lv.includes(String(target).toLowerCase());
85
+ }
86
+ case 'notcontains': {
87
+ const lv = typeof value === 'string' ? value.toLowerCase() : '';
88
+ return typeof value === 'string' && !lv.includes(String(target).toLowerCase());
89
+ }
90
+ case 'startswith': {
91
+ const lv = typeof value === 'string' ? value.toLowerCase() : '';
92
+ return typeof value === 'string' && lv.startsWith(String(target).toLowerCase());
93
+ }
94
+ case 'between':
95
+ return Array.isArray(target) && target.length === 2 && value >= target[0] && value <= target[1];
96
+ default:
97
+ return true;
98
+ }
99
+ }
100
+
101
+ return true;
102
+ }
103
+
39
104
  /**
40
105
  * Simple in-memory filter evaluation.
41
106
  * Supports flat key-value equality and basic operators ($gt, $gte, $lt, $lte, $ne, $in).
@@ -177,9 +242,13 @@ export class ValueDataSource<T = any> implements DataSource<T> {
177
242
  async find(_resource: string, params?: QueryParams): Promise<QueryResult<T>> {
178
243
  let result = [...this.items];
179
244
 
180
- // Filter
181
- if (params?.$filter && Object.keys(params.$filter).length > 0) {
182
- result = result.filter((r) => matchesFilter(r, params.$filter!));
245
+ // Filter — support both MongoDB-style objects and AST-format arrays
246
+ if (params?.$filter) {
247
+ if (Array.isArray(params.$filter) && params.$filter.length > 0) {
248
+ result = result.filter((r) => matchesASTFilter(r, params.$filter as any[]));
249
+ } else if (!Array.isArray(params.$filter) && Object.keys(params.$filter).length > 0) {
250
+ result = result.filter((r) => matchesFilter(r, params.$filter!));
251
+ }
183
252
  }
184
253
 
185
254
  // Search (simple text search across all string fields)
@@ -316,6 +385,43 @@ export class ValueDataSource<T = any> implements DataSource<T> {
316
385
  return null;
317
386
  }
318
387
 
388
+ async aggregate(_resource: string, params: AggregateParams): Promise<AggregateResult[]> {
389
+ const { field, function: aggFn, groupBy } = params;
390
+ const groups: Record<string, any[]> = {};
391
+
392
+ for (const record of this.items as any[]) {
393
+ const key = String(record[groupBy] ?? 'Unknown');
394
+ if (!groups[key]) groups[key] = [];
395
+ groups[key].push(record);
396
+ }
397
+
398
+ return Object.entries(groups).map(([key, group]) => {
399
+ const values = group.map(r => Number(r[field]) || 0);
400
+ let result: number;
401
+
402
+ switch (aggFn) {
403
+ case 'count':
404
+ result = group.length;
405
+ break;
406
+ case 'avg':
407
+ result = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
408
+ break;
409
+ case 'min':
410
+ result = values.length > 0 ? Math.min(...values) : 0;
411
+ break;
412
+ case 'max':
413
+ result = values.length > 0 ? Math.max(...values) : 0;
414
+ break;
415
+ case 'sum':
416
+ default:
417
+ result = values.reduce((a, b) => a + b, 0);
418
+ break;
419
+ }
420
+
421
+ return { [groupBy]: key, [field]: result };
422
+ });
423
+ }
424
+
319
425
  // -----------------------------------------------------------------------
320
426
  // Extra utilities
321
427
  // -----------------------------------------------------------------------
@@ -157,6 +157,96 @@ describe('ValueDataSource — find', () => {
157
157
  });
158
158
  });
159
159
 
160
+ // ---------------------------------------------------------------------------
161
+ // AST-format filter support
162
+ // ---------------------------------------------------------------------------
163
+
164
+ describe('ValueDataSource — AST filter', () => {
165
+ it('should filter with simple AST equality condition', async () => {
166
+ const ds = createDS();
167
+ const result = await ds.find('users', {
168
+ $filter: ['role', '=', 'admin'] as any,
169
+ });
170
+ expect(result.data).toHaveLength(2);
171
+ expect(result.data.every((r: any) => r.role === 'admin')).toBe(true);
172
+ });
173
+
174
+ it('should filter with AST "in" operator', async () => {
175
+ const ds = createDS();
176
+ const result = await ds.find('users', {
177
+ $filter: ['role', 'in', ['admin', 'guest']] as any,
178
+ });
179
+ expect(result.data).toHaveLength(3);
180
+ });
181
+
182
+ it('should filter with AST "and" logical operator', async () => {
183
+ const ds = createDS();
184
+ const result = await ds.find('users', {
185
+ $filter: ['and', ['role', '=', 'admin'], ['age', '>', 30]] as any,
186
+ });
187
+ expect(result.data).toHaveLength(1);
188
+ expect(result.data[0].name).toBe('Charlie');
189
+ });
190
+
191
+ it('should filter with AST "or" logical operator', async () => {
192
+ const ds = createDS();
193
+ const result = await ds.find('users', {
194
+ $filter: ['or', ['role', '=', 'guest'], ['name', '=', 'Alice']] as any,
195
+ });
196
+ expect(result.data).toHaveLength(2);
197
+ });
198
+
199
+ it('should filter with AST "!=" operator', async () => {
200
+ const ds = createDS();
201
+ const result = await ds.find('users', {
202
+ $filter: ['role', '!=', 'admin'] as any,
203
+ });
204
+ expect(result.data).toHaveLength(3);
205
+ });
206
+
207
+ it('should filter with AST "not in" operator', async () => {
208
+ const ds = createDS();
209
+ const result = await ds.find('users', {
210
+ $filter: ['role', 'not in', ['admin', 'guest']] as any,
211
+ });
212
+ expect(result.data).toHaveLength(2);
213
+ expect(result.data.every((r: any) => r.role === 'user')).toBe(true);
214
+ });
215
+
216
+ it('should filter with AST "contains" operator', async () => {
217
+ const ds = createDS();
218
+ const result = await ds.find('users', {
219
+ $filter: ['name', 'contains', 'li'] as any,
220
+ });
221
+ expect(result.data).toHaveLength(2); // Alice, Charlie
222
+ });
223
+
224
+ it('should filter with nested AST (and with in operator)', async () => {
225
+ const ds = createDS();
226
+ const result = await ds.find('users', {
227
+ $filter: ['and', ['role', 'in', ['admin', 'user']], ['age', '>=', 28]] as any,
228
+ });
229
+ expect(result.data).toHaveLength(3); // Alice (30, admin), Charlie (35, admin), Diana (28, user)
230
+ });
231
+
232
+ it('should return all items with empty AST filter', async () => {
233
+ const ds = createDS();
234
+ const result = await ds.find('users', { $filter: [] as any });
235
+ expect(result.data).toHaveLength(5);
236
+ });
237
+
238
+ it('should combine AST filter with sort', async () => {
239
+ const ds = createDS();
240
+ const result = await ds.find('users', {
241
+ $filter: ['role', '=', 'admin'] as any,
242
+ $orderby: { age: 'asc' },
243
+ });
244
+ expect(result.data).toHaveLength(2);
245
+ expect(result.data[0].name).toBe('Alice');
246
+ expect(result.data[1].name).toBe('Charlie');
247
+ });
248
+ });
249
+
160
250
  // ---------------------------------------------------------------------------
161
251
  // findOne
162
252
  // ---------------------------------------------------------------------------
@@ -323,3 +413,60 @@ describe('ValueDataSource — isolation', () => {
323
413
  expect(ds.count).toBe(2);
324
414
  });
325
415
  });
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // aggregate
419
+ // ---------------------------------------------------------------------------
420
+
421
+ describe('ValueDataSource — aggregate', () => {
422
+ const aggData = [
423
+ { _id: '1', category: 'A', amount: 10 },
424
+ { _id: '2', category: 'A', amount: 20 },
425
+ { _id: '3', category: 'B', amount: 30 },
426
+ { _id: '4', category: 'B', amount: 40 },
427
+ { _id: '5', category: 'B', amount: 50 },
428
+ ];
429
+
430
+ function createAggDS() {
431
+ return new ValueDataSource({ items: aggData });
432
+ }
433
+
434
+ it('should compute sum aggregation', async () => {
435
+ const ds = createAggDS();
436
+ const result = await ds.aggregate('items', { field: 'amount', function: 'sum', groupBy: 'category' });
437
+ expect(result).toHaveLength(2);
438
+ const groupA = result.find((r: any) => r.category === 'A');
439
+ const groupB = result.find((r: any) => r.category === 'B');
440
+ expect(groupA?.amount).toBe(30);
441
+ expect(groupB?.amount).toBe(120);
442
+ });
443
+
444
+ it('should compute count aggregation', async () => {
445
+ const ds = createAggDS();
446
+ const result = await ds.aggregate('items', { field: 'amount', function: 'count', groupBy: 'category' });
447
+ expect(result).toHaveLength(2);
448
+ expect(result.find((r: any) => r.category === 'A')?.amount).toBe(2);
449
+ expect(result.find((r: any) => r.category === 'B')?.amount).toBe(3);
450
+ });
451
+
452
+ it('should compute avg aggregation', async () => {
453
+ const ds = createAggDS();
454
+ const result = await ds.aggregate('items', { field: 'amount', function: 'avg', groupBy: 'category' });
455
+ expect(result.find((r: any) => r.category === 'A')?.amount).toBe(15);
456
+ expect(result.find((r: any) => r.category === 'B')?.amount).toBe(40);
457
+ });
458
+
459
+ it('should compute min aggregation', async () => {
460
+ const ds = createAggDS();
461
+ const result = await ds.aggregate('items', { field: 'amount', function: 'min', groupBy: 'category' });
462
+ expect(result.find((r: any) => r.category === 'A')?.amount).toBe(10);
463
+ expect(result.find((r: any) => r.category === 'B')?.amount).toBe(30);
464
+ });
465
+
466
+ it('should compute max aggregation', async () => {
467
+ const ds = createAggDS();
468
+ const result = await ds.aggregate('items', { field: 'amount', function: 'max', groupBy: 'category' });
469
+ expect(result.find((r: any) => r.category === 'A')?.amount).toBe(20);
470
+ expect(result.find((r: any) => r.category === 'B')?.amount).toBe(50);
471
+ });
472
+ });