@objectstack/objectql 4.0.4 → 4.1.0

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