@objectstack/objectql 4.0.3 → 4.0.5

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.
@@ -1,599 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { ObjectQL } from './engine';
3
- import { SchemaRegistry } from './registry';
4
- import type { IDataDriver } from '@objectstack/spec/contracts';
5
-
6
- // Mock the SchemaRegistry to avoid side effects between tests
7
- vi.mock('./registry', () => {
8
- const mockObjects = new Map();
9
- return {
10
- SchemaRegistry: {
11
- getObject: vi.fn((name) => mockObjects.get(name)),
12
- resolveObject: vi.fn((name) => mockObjects.get(name)),
13
- registerObject: vi.fn((obj, packageId, namespace, ownership, priority) => {
14
- const fqn = namespace ? `${namespace}__${obj.name}` : obj.name;
15
- mockObjects.set(fqn, { ...obj, name: fqn });
16
- return fqn;
17
- }),
18
- registerNamespace: vi.fn(),
19
- registerKind: vi.fn(),
20
- registerItem: vi.fn(),
21
- registerApp: vi.fn(),
22
- installPackage: vi.fn((manifest) => ({
23
- manifest,
24
- status: 'installed',
25
- enabled: true,
26
- installedAt: new Date().toISOString(),
27
- })),
28
- reset: vi.fn(() => mockObjects.clear()),
29
- metadata: {
30
- get: vi.fn(() => mockObjects) // Expose for verification if needed
31
- }
32
- }
33
- };
34
- });
35
-
36
- describe('ObjectQL Engine', () => {
37
- let engine: ObjectQL;
38
- let mockDriver: IDataDriver;
39
- let mockDriver2: IDataDriver;
40
-
41
- beforeEach(() => {
42
- // Clear Registry Mocks
43
- vi.clearAllMocks();
44
-
45
- // Setup Drivers
46
- mockDriver = {
47
- name: 'default-driver',
48
- connect: vi.fn().mockResolvedValue(undefined),
49
- disconnect: vi.fn().mockResolvedValue(undefined),
50
- find: vi.fn().mockResolvedValue([{ id: '1', name: 'Test Record' }]),
51
- findOne: vi.fn(),
52
- create: vi.fn().mockResolvedValue({ id: '1', success: true }),
53
- update: vi.fn(),
54
- delete: vi.fn(),
55
- count: vi.fn(),
56
- capabilities: {} as any // Simplified
57
- } as unknown as IDataDriver;
58
-
59
- mockDriver2 = {
60
- name: 'mongo',
61
- connect: vi.fn().mockResolvedValue(undefined),
62
- disconnect: vi.fn().mockResolvedValue(undefined),
63
- find: vi.fn().mockResolvedValue([{ id: '2', name: 'Mongo Record' }]),
64
- findOne: vi.fn(),
65
- create: vi.fn().mockResolvedValue({ id: '2', success: true }),
66
- update: vi.fn(),
67
- delete: vi.fn(),
68
- count: vi.fn(),
69
- capabilities: {} as any
70
- } as unknown as IDataDriver;
71
-
72
- engine = new ObjectQL();
73
- });
74
-
75
- describe('Initialization', () => {
76
- it('should initialize with default logger', () => {
77
- expect(engine).toBeDefined();
78
- expect(engine.getStatus().status).toBe('running');
79
- });
80
-
81
- it('should register and connect drivers on init', async () => {
82
- engine.registerDriver(mockDriver, true);
83
- await engine.init();
84
- expect(mockDriver.connect).toHaveBeenCalled();
85
- });
86
- });
87
-
88
- describe('Metadata Registration', () => {
89
- it('should register objects from app manifest with namespace', () => {
90
- const manifest = {
91
- id: 'com.example.app',
92
- namespace: 'example',
93
- objects: [
94
- { name: 'task', fields: {} }
95
- ]
96
- };
97
-
98
- engine.registerApp(manifest);
99
- expect(SchemaRegistry.registerObject).toHaveBeenCalledWith(
100
- expect.objectContaining({ name: 'task' }),
101
- 'com.example.app',
102
- 'example',
103
- 'own'
104
- );
105
- });
106
-
107
- it('should register objects without namespace (legacy)', () => {
108
- const manifest = {
109
- id: 'com.legacy.app',
110
- objects: [
111
- { name: 'item', fields: {} }
112
- ]
113
- };
114
-
115
- engine.registerApp(manifest);
116
- expect(SchemaRegistry.registerObject).toHaveBeenCalledWith(
117
- expect.objectContaining({ name: 'item' }),
118
- 'com.legacy.app',
119
- undefined,
120
- 'own'
121
- );
122
- });
123
-
124
- it('should register object extensions', () => {
125
- const manifest = {
126
- id: 'com.extender.app',
127
- namespace: 'ext',
128
- objectExtensions: [
129
- { extend: 'base__contact', fields: { custom_field: { type: 'text' } }, priority: 250 }
130
- ]
131
- };
132
-
133
- engine.registerApp(manifest);
134
- expect(SchemaRegistry.registerObject).toHaveBeenCalledWith(
135
- expect.objectContaining({ name: 'base__contact' }),
136
- 'com.extender.app',
137
- undefined,
138
- 'extend',
139
- 250
140
- );
141
- });
142
-
143
- it('should register kinds from app manifest', () => {
144
- const manifest = {
145
- id: 'com.example.app',
146
- contributes: {
147
- kinds: [{ id: 'test.kind', description: 'Test Kind' }]
148
- }
149
- };
150
-
151
- engine.registerApp(manifest);
152
- expect(SchemaRegistry.registerKind).toHaveBeenCalledWith(expect.objectContaining({ id: 'test.kind' }));
153
- });
154
- });
155
-
156
- describe('Driver Routing', () => {
157
- beforeEach(async () => {
158
- // Setup:
159
- // - Default Driver: mockDriver
160
- // - Specific Driver: mockDriver2 (named 'mongo')
161
- engine.registerDriver(mockDriver, true);
162
- engine.registerDriver(mockDriver2);
163
- await engine.init();
164
- });
165
-
166
- it('should route to default driver when no datasource is specified', async () => {
167
- // Mock Schema: Object uses default datasource
168
- vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'task', datasource: 'default', fields: {} });
169
-
170
- await engine.find('task', { filters: [] });
171
-
172
- expect(mockDriver.find).toHaveBeenCalled();
173
- expect(mockDriver2.find).not.toHaveBeenCalled();
174
- });
175
-
176
- it('should route to specific driver when datasource is specified', async () => {
177
- // Mock Schema: Object uses 'mongo' datasource
178
- vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'log', datasource: 'mongo', fields: {} });
179
-
180
- await engine.find('log', { filters: [] });
181
-
182
- expect(mockDriver.find).not.toHaveBeenCalled();
183
- expect(mockDriver2.find).toHaveBeenCalled();
184
- });
185
-
186
- it('should throw error if datasource is not found', async () => {
187
- // Mock Schema: Object uses unknown datasource
188
- vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'old_data', datasource: 'legacy_sql', fields: {} });
189
-
190
- await expect(engine.find('old_data', {})).rejects.toThrow("Datasource 'legacy_sql' configured for object 'old_data' is not registered");
191
- });
192
- });
193
-
194
- describe('CRUD Operations', () => {
195
- beforeEach(async () => {
196
- engine.registerDriver(mockDriver, true);
197
- await engine.init();
198
- vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'task', fields: {} });
199
- });
200
-
201
- it('should execute insert operation', async () => {
202
- const result = await engine.insert('task', { title: 'New Task' });
203
- expect(mockDriver.create).toHaveBeenCalledWith('task', { title: 'New Task' }, undefined);
204
- expect(result).toEqual({ id: '1', success: true });
205
- });
206
-
207
- it('should execute find operation', async () => {
208
- const result = await engine.find('task', {});
209
- expect(mockDriver.find).toHaveBeenCalled();
210
- expect(result).toHaveLength(1);
211
- });
212
- });
213
-
214
- describe('Expand Related Records', () => {
215
- beforeEach(async () => {
216
- engine.registerDriver(mockDriver, true);
217
- await engine.init();
218
- });
219
-
220
- it('should expand lookup fields by replacing IDs with full objects', async () => {
221
- // Setup: task has a lookup field "assignee" → user object
222
- vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => {
223
- if (name === 'task') return {
224
- name: 'task',
225
- fields: {
226
- assignee: { type: 'lookup', reference: 'user' },
227
- title: { type: 'text' },
228
- },
229
- } as any;
230
- if (name === 'user') return {
231
- name: 'user',
232
- fields: {
233
- name: { type: 'text' },
234
- },
235
- } as any;
236
- return undefined;
237
- });
238
-
239
- // Primary find returns tasks with assignee IDs
240
- vi.mocked(mockDriver.find)
241
- .mockResolvedValueOnce([
242
- { id: 't1', title: 'Task 1', assignee: 'u1' },
243
- { id: 't2', title: 'Task 2', assignee: 'u2' },
244
- ])
245
- // Second call (expand): returns user records
246
- .mockResolvedValueOnce([
247
- { id: 'u1', name: 'Alice' },
248
- { id: 'u2', name: 'Bob' },
249
- ]);
250
-
251
- const result = await engine.find('task', { expand: { assignee: { object: 'assignee' } } });
252
-
253
- expect(result).toHaveLength(2);
254
- expect(result[0].assignee).toEqual({ id: 'u1', name: 'Alice' });
255
- expect(result[1].assignee).toEqual({ id: 'u2', name: 'Bob' });
256
-
257
- // Verify the expand query used $in
258
- expect(mockDriver.find).toHaveBeenCalledTimes(2);
259
- expect(mockDriver.find).toHaveBeenLastCalledWith(
260
- 'user',
261
- expect.objectContaining({
262
- object: 'user',
263
- where: { id: { $in: ['u1', 'u2'] } },
264
- }),
265
- );
266
- });
267
-
268
- it('should expand master_detail fields', async () => {
269
- vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => {
270
- if (name === 'order_item') return {
271
- name: 'order_item',
272
- fields: {
273
- order: { type: 'master_detail', reference: 'order' },
274
- },
275
- } as any;
276
- if (name === 'order') return {
277
- name: 'order',
278
- fields: { total: { type: 'number' } },
279
- } as any;
280
- return undefined;
281
- });
282
-
283
- vi.mocked(mockDriver.find)
284
- .mockResolvedValueOnce([
285
- { id: 'oi1', order: 'o1' },
286
- ])
287
- .mockResolvedValueOnce([
288
- { id: 'o1', total: 100 },
289
- ]);
290
-
291
- const result = await engine.find('order_item', { expand: { order: { object: 'order' } } });
292
- expect(result[0].order).toEqual({ id: 'o1', total: 100 });
293
- });
294
-
295
- it('should skip expand for fields without reference definition', async () => {
296
- vi.mocked(SchemaRegistry.getObject).mockReturnValue({
297
- name: 'task',
298
- fields: {
299
- title: { type: 'text' }, // Not a lookup
300
- },
301
- } as any);
302
-
303
- vi.mocked(mockDriver.find).mockResolvedValueOnce([
304
- { id: 't1', title: 'Task 1' },
305
- ]);
306
-
307
- const result = await engine.find('task', { expand: { title: { object: 'title' } } });
308
- expect(result[0].title).toBe('Task 1'); // Unchanged
309
- expect(mockDriver.find).toHaveBeenCalledTimes(1); // No expand query
310
- });
311
-
312
- it('should skip expand if schema is not registered', async () => {
313
- vi.mocked(SchemaRegistry.getObject).mockReturnValue(undefined);
314
-
315
- vi.mocked(mockDriver.find).mockResolvedValueOnce([
316
- { id: 't1', assignee: 'u1' },
317
- ]);
318
-
319
- const result = await engine.find('task', { expand: { assignee: { object: 'assignee' } } });
320
- expect(result[0].assignee).toBe('u1'); // Unchanged — raw ID
321
- expect(mockDriver.find).toHaveBeenCalledTimes(1);
322
- });
323
-
324
- it('should handle null values gracefully during expand', async () => {
325
- vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => {
326
- if (name === 'task') return {
327
- name: 'task',
328
- fields: {
329
- assignee: { type: 'lookup', reference: 'user' },
330
- },
331
- } as any;
332
- if (name === 'user') return {
333
- name: 'user',
334
- fields: {},
335
- } as any;
336
- return undefined;
337
- });
338
-
339
- vi.mocked(mockDriver.find)
340
- .mockResolvedValueOnce([
341
- { id: 't1', assignee: null },
342
- { id: 't2', assignee: 'u1' },
343
- ])
344
- .mockResolvedValueOnce([
345
- { id: 'u1', name: 'Alice' },
346
- ]);
347
-
348
- const result = await engine.find('task', { expand: { assignee: { object: 'assignee' } } });
349
- expect(result[0].assignee).toBeNull();
350
- expect(result[1].assignee).toEqual({ id: 'u1', name: 'Alice' });
351
- });
352
-
353
- it('should de-duplicate foreign key IDs in batch query', async () => {
354
- vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => {
355
- if (name === 'task') return {
356
- name: 'task',
357
- fields: {
358
- assignee: { type: 'lookup', reference: 'user' },
359
- },
360
- } as any;
361
- if (name === 'user') return {
362
- name: 'user',
363
- fields: {},
364
- } as any;
365
- return undefined;
366
- });
367
-
368
- vi.mocked(mockDriver.find)
369
- .mockResolvedValueOnce([
370
- { id: 't1', assignee: 'u1' },
371
- { id: 't2', assignee: 'u1' }, // Same user
372
- { id: 't3', assignee: 'u2' },
373
- ])
374
- .mockResolvedValueOnce([
375
- { id: 'u1', name: 'Alice' },
376
- { id: 'u2', name: 'Bob' },
377
- ]);
378
-
379
- const result = await engine.find('task', { expand: { assignee: { object: 'assignee' } } });
380
-
381
- // Verify only 2 unique IDs queried
382
- expect(mockDriver.find).toHaveBeenLastCalledWith(
383
- 'user',
384
- expect.objectContaining({
385
- where: { id: { $in: ['u1', 'u2'] } },
386
- }),
387
- );
388
- expect(result[0].assignee).toEqual({ id: 'u1', name: 'Alice' });
389
- expect(result[1].assignee).toEqual({ id: 'u1', name: 'Alice' });
390
- });
391
-
392
- it('should keep raw ID when referenced record not found', async () => {
393
- vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => {
394
- if (name === 'task') return {
395
- name: 'task',
396
- fields: {
397
- assignee: { type: 'lookup', reference: 'user' },
398
- },
399
- } as any;
400
- if (name === 'user') return {
401
- name: 'user',
402
- fields: {},
403
- } as any;
404
- return undefined;
405
- });
406
-
407
- vi.mocked(mockDriver.find)
408
- .mockResolvedValueOnce([
409
- { id: 't1', assignee: 'u_deleted' },
410
- ])
411
- .mockResolvedValueOnce([]); // No records found
412
-
413
- const result = await engine.find('task', { expand: { assignee: { object: 'assignee' } } });
414
- expect(result[0].assignee).toBe('u_deleted'); // Fallback to raw ID
415
- });
416
-
417
- it('should expand multiple fields in a single query', async () => {
418
- vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => {
419
- if (name === 'task') return {
420
- name: 'task',
421
- fields: {
422
- assignee: { type: 'lookup', reference: 'user' },
423
- project: { type: 'lookup', reference: 'project' },
424
- },
425
- } as any;
426
- if (name === 'user') return {
427
- name: 'user',
428
- fields: {},
429
- } as any;
430
- if (name === 'project') return {
431
- name: 'project',
432
- fields: {},
433
- } as any;
434
- return undefined;
435
- });
436
-
437
- vi.mocked(mockDriver.find)
438
- .mockResolvedValueOnce([
439
- { id: 't1', assignee: 'u1', project: 'p1' },
440
- ])
441
- .mockResolvedValueOnce([{ id: 'u1', name: 'Alice' }])
442
- .mockResolvedValueOnce([{ id: 'p1', name: 'Project X' }]);
443
-
444
- const result = await engine.find('task', { expand: { assignee: { object: 'assignee' }, project: { object: 'project' } } });
445
-
446
- expect(result[0].assignee).toEqual({ id: 'u1', name: 'Alice' });
447
- expect(result[0].project).toEqual({ id: 'p1', name: 'Project X' });
448
- expect(mockDriver.find).toHaveBeenCalledTimes(3);
449
- });
450
-
451
- it('should work with findOne and expand', async () => {
452
- vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => {
453
- if (name === 'task') return {
454
- name: 'task',
455
- fields: {
456
- assignee: { type: 'lookup', reference: 'user' },
457
- },
458
- } as any;
459
- if (name === 'user') return {
460
- name: 'user',
461
- fields: {},
462
- } as any;
463
- return undefined;
464
- });
465
-
466
- vi.mocked(mockDriver.findOne as any).mockResolvedValueOnce(
467
- { id: 't1', title: 'Task 1', assignee: 'u1' },
468
- );
469
- vi.mocked(mockDriver.find).mockResolvedValueOnce([
470
- { id: 'u1', name: 'Alice' },
471
- ]);
472
-
473
- const result = await engine.findOne('task', { expand: { assignee: { object: 'assignee' } } });
474
-
475
- expect(result.assignee).toEqual({ id: 'u1', name: 'Alice' });
476
- });
477
-
478
- it('should handle already-expanded objects (skip re-expansion)', async () => {
479
- vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => {
480
- if (name === 'task') return {
481
- name: 'task',
482
- fields: {
483
- assignee: { type: 'lookup', reference: 'user' },
484
- },
485
- } as any;
486
- if (name === 'user') return {
487
- name: 'user',
488
- fields: {},
489
- } as any;
490
- return undefined;
491
- });
492
-
493
- // Driver returns an already-expanded object
494
- vi.mocked(mockDriver.find).mockResolvedValueOnce([
495
- { id: 't1', assignee: { id: 'u1', name: 'Alice' } },
496
- ]);
497
-
498
- const result = await engine.find('task', { expand: { assignee: { object: 'assignee' } } });
499
-
500
- // No expand query should have been made — the value was already an object
501
- expect(mockDriver.find).toHaveBeenCalledTimes(1);
502
- expect(result[0].assignee).toEqual({ id: 'u1', name: 'Alice' });
503
- });
504
-
505
- it('should gracefully handle expand errors and keep raw IDs', async () => {
506
- vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => {
507
- if (name === 'task') return {
508
- name: 'task',
509
- fields: {
510
- assignee: { type: 'lookup', reference: 'user' },
511
- },
512
- } as any;
513
- if (name === 'user') return {
514
- name: 'user',
515
- fields: {},
516
- } as any;
517
- return undefined;
518
- });
519
-
520
- vi.mocked(mockDriver.find)
521
- .mockResolvedValueOnce([
522
- { id: 't1', assignee: 'u1' },
523
- ])
524
- .mockRejectedValueOnce(new Error('Driver connection failed'));
525
-
526
- const result = await engine.find('task', { expand: { assignee: { object: 'assignee' } } });
527
- expect(result[0].assignee).toBe('u1'); // Kept raw ID
528
- });
529
-
530
- it('should handle multi-value lookup fields (arrays)', async () => {
531
- vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => {
532
- if (name === 'task') return {
533
- name: 'task',
534
- fields: {
535
- watchers: { type: 'lookup', reference: 'user', multiple: true },
536
- },
537
- } as any;
538
- if (name === 'user') return {
539
- name: 'user',
540
- fields: {},
541
- } as any;
542
- return undefined;
543
- });
544
-
545
- vi.mocked(mockDriver.find)
546
- .mockResolvedValueOnce([
547
- { id: 't1', watchers: ['u1', 'u2'] },
548
- ])
549
- .mockResolvedValueOnce([
550
- { id: 'u1', name: 'Alice' },
551
- { id: 'u2', name: 'Bob' },
552
- ]);
553
-
554
- const result = await engine.find('task', { expand: { watchers: { object: 'watchers' } } });
555
- expect(result[0].watchers).toEqual([
556
- { id: 'u1', name: 'Alice' },
557
- { id: 'u2', name: 'Bob' },
558
- ]);
559
- });
560
-
561
- it('should expand only fields specified in the expand map (populate creates flat expand)', async () => {
562
- // populate: ['project'] creates expand: { project: { object: 'project' } } (1 level only)
563
- // Nested fields like project.org should NOT be expanded unless explicitly nested in the AST
564
- vi.mocked(SchemaRegistry.getObject).mockImplementation((name) => {
565
- const schemas: Record<string, any> = {
566
- task: { name: 'task', fields: { project: { type: 'lookup', reference: 'project' } } },
567
- project: { name: 'project', fields: { org: { type: 'lookup', reference: 'org' } } },
568
- };
569
- return schemas[name] as any;
570
- });
571
-
572
- vi.mocked(mockDriver.find)
573
- .mockResolvedValueOnce([{ id: 't1', project: 'p1' }]) // find task
574
- .mockResolvedValueOnce([{ id: 'p1', org: 'o1' }]); // expand project (depth 0)
575
- // org should NOT be expanded further — flat populate doesn't create nested expand
576
-
577
- const result = await engine.find('task', { expand: { project: { object: 'project' } } });
578
-
579
- // Project expanded, but org inside project remains as raw ID
580
- expect(result[0].project).toEqual({ id: 'p1', org: 'o1' });
581
- expect(mockDriver.find).toHaveBeenCalledTimes(2); // Only primary + 1 expand query
582
- });
583
-
584
- it('should return records unchanged when expand map is empty', async () => {
585
- vi.mocked(SchemaRegistry.getObject).mockReturnValue({
586
- name: 'task',
587
- fields: {},
588
- } as any);
589
-
590
- vi.mocked(mockDriver.find).mockResolvedValueOnce([
591
- { id: 't1', title: 'Task 1' },
592
- ]);
593
-
594
- const result = await engine.find('task', {});
595
- expect(result).toEqual([{ id: 't1', title: 'Task 1' }]);
596
- expect(mockDriver.find).toHaveBeenCalledTimes(1);
597
- });
598
- });
599
- });