@objectstack/objectql 3.0.11 → 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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +9 -0
- package/dist/index.d.mts +17 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +119 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +119 -5
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/engine.test.ts +386 -0
- package/src/engine.ts +136 -2
- package/src/protocol.ts +32 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/objectql",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Isomorphic ObjectQL Engine for ObjectStack",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@objectstack/core": "3.0
|
|
17
|
-
"@objectstack/spec": "3.0
|
|
18
|
-
"@objectstack/types": "3.0
|
|
16
|
+
"@objectstack/core": "3.1.0",
|
|
17
|
+
"@objectstack/spec": "3.1.0",
|
|
18
|
+
"@objectstack/types": "3.1.0"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"typescript": "^5.0.0",
|
package/src/engine.test.ts
CHANGED
|
@@ -210,4 +210,390 @@ describe('ObjectQL Engine', () => {
|
|
|
210
210
|
expect(result).toHaveLength(1);
|
|
211
211
|
});
|
|
212
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', { populate: ['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', { populate: ['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', { populate: ['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', { populate: ['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', { populate: ['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', { populate: ['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', { populate: ['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', { populate: ['assignee', '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', { populate: ['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', { populate: ['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', { populate: ['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', { populate: ['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', { populate: ['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
|
+
});
|
|
213
599
|
});
|
package/src/engine.ts
CHANGED
|
@@ -617,6 +617,127 @@ export class ObjectQL implements IDataEngine {
|
|
|
617
617
|
this.logger.info('ObjectQL engine destroyed');
|
|
618
618
|
}
|
|
619
619
|
|
|
620
|
+
// ============================================
|
|
621
|
+
// Helper: Expand Related Records
|
|
622
|
+
// ============================================
|
|
623
|
+
|
|
624
|
+
/** Maximum depth for recursive expand to prevent infinite loops */
|
|
625
|
+
private static readonly MAX_EXPAND_DEPTH = 3;
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Post-process expand: resolve lookup/master_detail fields by batch-loading related records.
|
|
629
|
+
*
|
|
630
|
+
* This is a driver-agnostic implementation that uses secondary queries ($in batches)
|
|
631
|
+
* to load related records, then injects them into the result set.
|
|
632
|
+
*
|
|
633
|
+
* @param objectName - The source object name
|
|
634
|
+
* @param records - The records returned by the driver
|
|
635
|
+
* @param expand - The expand map from QueryAST (field name → nested QueryAST)
|
|
636
|
+
* @param depth - Current recursion depth (0-based)
|
|
637
|
+
* @returns Records with expanded lookup fields (IDs replaced by full objects)
|
|
638
|
+
*/
|
|
639
|
+
private async expandRelatedRecords(
|
|
640
|
+
objectName: string,
|
|
641
|
+
records: any[],
|
|
642
|
+
expand: Record<string, QueryAST>,
|
|
643
|
+
depth: number = 0,
|
|
644
|
+
): Promise<any[]> {
|
|
645
|
+
if (!records || records.length === 0) return records;
|
|
646
|
+
if (depth >= ObjectQL.MAX_EXPAND_DEPTH) return records;
|
|
647
|
+
|
|
648
|
+
const objectSchema = SchemaRegistry.getObject(objectName);
|
|
649
|
+
// If no schema registered, skip expand — return raw data
|
|
650
|
+
if (!objectSchema || !objectSchema.fields) return records;
|
|
651
|
+
|
|
652
|
+
for (const [fieldName, nestedAST] of Object.entries(expand)) {
|
|
653
|
+
const fieldDef = objectSchema.fields[fieldName];
|
|
654
|
+
|
|
655
|
+
// Skip if field not found or not a relationship type
|
|
656
|
+
if (!fieldDef || !fieldDef.reference) continue;
|
|
657
|
+
if (fieldDef.type !== 'lookup' && fieldDef.type !== 'master_detail') continue;
|
|
658
|
+
|
|
659
|
+
const referenceObject = fieldDef.reference;
|
|
660
|
+
|
|
661
|
+
// Collect all foreign key IDs from records (handle both single and multiple values)
|
|
662
|
+
const allIds: any[] = [];
|
|
663
|
+
for (const record of records) {
|
|
664
|
+
const val = record[fieldName];
|
|
665
|
+
if (val == null) continue;
|
|
666
|
+
if (Array.isArray(val)) {
|
|
667
|
+
allIds.push(...val.filter((id: any) => id != null));
|
|
668
|
+
} else if (typeof val === 'object') {
|
|
669
|
+
// Already expanded — skip
|
|
670
|
+
continue;
|
|
671
|
+
} else {
|
|
672
|
+
allIds.push(val);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// De-duplicate IDs
|
|
677
|
+
const uniqueIds = [...new Set(allIds)];
|
|
678
|
+
if (uniqueIds.length === 0) continue;
|
|
679
|
+
|
|
680
|
+
// Batch-load related records using $in query
|
|
681
|
+
try {
|
|
682
|
+
const relatedQuery: QueryAST = {
|
|
683
|
+
object: referenceObject,
|
|
684
|
+
where: { _id: { $in: uniqueIds } },
|
|
685
|
+
...(nestedAST.fields ? { fields: nestedAST.fields } : {}),
|
|
686
|
+
...(nestedAST.orderBy ? { orderBy: nestedAST.orderBy } : {}),
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
const driver = this.getDriver(referenceObject);
|
|
690
|
+
const relatedRecords = await driver.find(referenceObject, relatedQuery) ?? [];
|
|
691
|
+
|
|
692
|
+
// Build a lookup map: id → record
|
|
693
|
+
const recordMap = new Map<string, any>();
|
|
694
|
+
for (const rec of relatedRecords) {
|
|
695
|
+
const id = rec._id ?? rec.id;
|
|
696
|
+
if (id != null) recordMap.set(String(id), rec);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Recursively expand nested relations if present
|
|
700
|
+
if (nestedAST.expand && Object.keys(nestedAST.expand).length > 0) {
|
|
701
|
+
const expandedRelated = await this.expandRelatedRecords(
|
|
702
|
+
referenceObject,
|
|
703
|
+
relatedRecords,
|
|
704
|
+
nestedAST.expand,
|
|
705
|
+
depth + 1,
|
|
706
|
+
);
|
|
707
|
+
// Rebuild map with expanded records
|
|
708
|
+
recordMap.clear();
|
|
709
|
+
for (const rec of expandedRelated) {
|
|
710
|
+
const id = rec._id ?? rec.id;
|
|
711
|
+
if (id != null) recordMap.set(String(id), rec);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Inject expanded records back into the original result set
|
|
716
|
+
for (const record of records) {
|
|
717
|
+
const val = record[fieldName];
|
|
718
|
+
if (val == null) continue;
|
|
719
|
+
|
|
720
|
+
if (Array.isArray(val)) {
|
|
721
|
+
record[fieldName] = val.map((id: any) => recordMap.get(String(id)) ?? id);
|
|
722
|
+
} else if (typeof val !== 'object') {
|
|
723
|
+
record[fieldName] = recordMap.get(String(val)) ?? val;
|
|
724
|
+
}
|
|
725
|
+
// If val is already an object, leave it as-is
|
|
726
|
+
}
|
|
727
|
+
} catch (e) {
|
|
728
|
+
// Graceful degradation: if expand fails, keep original IDs
|
|
729
|
+
this.logger.warn('Failed to expand relationship field; retaining foreign key IDs', {
|
|
730
|
+
object: objectName,
|
|
731
|
+
field: fieldName,
|
|
732
|
+
reference: referenceObject,
|
|
733
|
+
error: (e as Error).message,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
return records;
|
|
739
|
+
}
|
|
740
|
+
|
|
620
741
|
// ============================================
|
|
621
742
|
// Helper: Query Conversion
|
|
622
743
|
// ============================================
|
|
@@ -691,7 +812,12 @@ export class ObjectQL implements IDataEngine {
|
|
|
691
812
|
await this.triggerHooks('beforeFind', hookContext);
|
|
692
813
|
|
|
693
814
|
try {
|
|
694
|
-
|
|
815
|
+
let result = await driver.find(object, hookContext.input.ast as QueryAST, hookContext.input.options as any);
|
|
816
|
+
|
|
817
|
+
// Post-process: expand related records if expand is requested
|
|
818
|
+
if (ast.expand && Object.keys(ast.expand).length > 0 && Array.isArray(result)) {
|
|
819
|
+
result = await this.expandRelatedRecords(object, result, ast.expand, 0);
|
|
820
|
+
}
|
|
695
821
|
|
|
696
822
|
hookContext.event = 'afterFind';
|
|
697
823
|
hookContext.result = result;
|
|
@@ -723,7 +849,15 @@ export class ObjectQL implements IDataEngine {
|
|
|
723
849
|
};
|
|
724
850
|
|
|
725
851
|
await this.executeWithMiddleware(opCtx, async () => {
|
|
726
|
-
|
|
852
|
+
let result = await driver.findOne(objectName, opCtx.ast as QueryAST);
|
|
853
|
+
|
|
854
|
+
// Post-process: expand related records if expand is requested
|
|
855
|
+
if (ast.expand && Object.keys(ast.expand).length > 0 && result != null) {
|
|
856
|
+
const expanded = await this.expandRelatedRecords(objectName, [result], ast.expand, 0);
|
|
857
|
+
result = expanded[0];
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
return result;
|
|
727
861
|
});
|
|
728
862
|
|
|
729
863
|
return opCtx.result;
|
package/src/protocol.ts
CHANGED
|
@@ -327,6 +327,19 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
|
|
|
327
327
|
options.populate = options.populate.split(',').map((s: string) => s.trim()).filter(Boolean);
|
|
328
328
|
}
|
|
329
329
|
|
|
330
|
+
// Expand → Populate: normalize $expand/expand to populate array
|
|
331
|
+
// Supports OData $expand, REST expand, and JSON-RPC expand parameters
|
|
332
|
+
const expandValue = options.$expand ?? options.expand;
|
|
333
|
+
if (expandValue && !options.populate) {
|
|
334
|
+
if (typeof expandValue === 'string') {
|
|
335
|
+
options.populate = expandValue.split(',').map((s: string) => s.trim()).filter(Boolean);
|
|
336
|
+
} else if (Array.isArray(expandValue)) {
|
|
337
|
+
options.populate = expandValue;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
delete options.$expand;
|
|
341
|
+
delete options.expand;
|
|
342
|
+
|
|
330
343
|
// Boolean fields
|
|
331
344
|
for (const key of ['distinct', 'count']) {
|
|
332
345
|
if (options[key] === 'true') options[key] = true;
|
|
@@ -346,10 +359,26 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
|
|
|
346
359
|
};
|
|
347
360
|
}
|
|
348
361
|
|
|
349
|
-
async getData(request: { object: string, id: string }) {
|
|
350
|
-
const
|
|
362
|
+
async getData(request: { object: string, id: string, expand?: string | string[], select?: string | string[] }) {
|
|
363
|
+
const queryOptions: any = {
|
|
351
364
|
filter: { _id: request.id }
|
|
352
|
-
}
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
// Support select for single-record retrieval
|
|
368
|
+
if (request.select) {
|
|
369
|
+
queryOptions.select = typeof request.select === 'string'
|
|
370
|
+
? request.select.split(',').map((s: string) => s.trim()).filter(Boolean)
|
|
371
|
+
: request.select;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Support expand for single-record retrieval
|
|
375
|
+
if (request.expand) {
|
|
376
|
+
queryOptions.populate = typeof request.expand === 'string'
|
|
377
|
+
? request.expand.split(',').map((s: string) => s.trim()).filter(Boolean)
|
|
378
|
+
: request.expand;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const result = await this.engine.findOne(request.object, queryOptions);
|
|
353
382
|
if (result) {
|
|
354
383
|
return {
|
|
355
384
|
object: request.object,
|