@objectstack/objectql 3.0.10 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/objectql",
3
- "version": "3.0.10",
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.10",
17
- "@objectstack/spec": "3.0.10",
18
- "@objectstack/types": "3.0.10"
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",
@@ -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
- const result = await driver.find(object, hookContext.input.ast as QueryAST, hookContext.input.options as any);
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
- return driver.findOne(objectName, opCtx.ast as QueryAST);
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 result = await this.engine.findOne(request.object, {
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,