@sylphx/lens-server 2.2.0 → 2.3.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/dist/index.js +140 -23
- package/package.json +2 -2
- package/src/server/create.test.ts +633 -1
- package/src/server/create.ts +305 -25
- package/src/server/selection.test.ts +253 -0
package/src/server/create.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type ResolverDef,
|
|
30
30
|
type RouterDef,
|
|
31
31
|
toResolverMap,
|
|
32
|
+
valuesEqual,
|
|
32
33
|
} from "@sylphx/lens-core";
|
|
33
34
|
import { createContext, runWithContext } from "../context/index.js";
|
|
34
35
|
import {
|
|
@@ -41,7 +42,7 @@ import {
|
|
|
41
42
|
type UpdateFieldsContext,
|
|
42
43
|
} from "../plugin/types.js";
|
|
43
44
|
import { DataLoader } from "./dataloader.js";
|
|
44
|
-
import { applySelection } from "./selection.js";
|
|
45
|
+
import { applySelection, extractNestedInputs } from "./selection.js";
|
|
45
46
|
import type {
|
|
46
47
|
ClientSendFn,
|
|
47
48
|
EntitiesMap,
|
|
@@ -234,8 +235,17 @@ class LensServerImpl<
|
|
|
234
235
|
subscribe: (observer) => {
|
|
235
236
|
let cancelled = false;
|
|
236
237
|
let currentState: unknown;
|
|
238
|
+
let lastEmittedResult: unknown;
|
|
237
239
|
const cleanups: (() => void)[] = [];
|
|
238
240
|
|
|
241
|
+
// Helper to emit only if value changed
|
|
242
|
+
const emitIfChanged = (data: unknown) => {
|
|
243
|
+
if (cancelled) return;
|
|
244
|
+
if (valuesEqual(data, lastEmittedResult)) return;
|
|
245
|
+
lastEmittedResult = data;
|
|
246
|
+
observer.next?.({ data });
|
|
247
|
+
};
|
|
248
|
+
|
|
239
249
|
// Run the operation
|
|
240
250
|
(async () => {
|
|
241
251
|
try {
|
|
@@ -277,11 +287,61 @@ class LensServerImpl<
|
|
|
277
287
|
return;
|
|
278
288
|
}
|
|
279
289
|
|
|
280
|
-
// Create emit handler
|
|
290
|
+
// Create emit handler with async queue processing
|
|
291
|
+
// Emit commands are queued and processed through processQueryResult
|
|
292
|
+
// to ensure field resolvers run on every emit
|
|
293
|
+
let emitProcessing = false;
|
|
294
|
+
const emitQueue: EmitCommand[] = [];
|
|
295
|
+
|
|
296
|
+
const processEmitQueue = async () => {
|
|
297
|
+
if (emitProcessing || cancelled) return;
|
|
298
|
+
emitProcessing = true;
|
|
299
|
+
|
|
300
|
+
while (emitQueue.length > 0 && !cancelled) {
|
|
301
|
+
const command = emitQueue.shift()!;
|
|
302
|
+
currentState = this.applyEmitCommand(command, currentState);
|
|
303
|
+
|
|
304
|
+
// Process through field resolvers (unlike before where we bypassed this)
|
|
305
|
+
// Note: createFieldEmit is created after this function but used lazily
|
|
306
|
+
const fieldEmitFactory = isQuery
|
|
307
|
+
? this.createFieldEmitFactory(
|
|
308
|
+
() => currentState,
|
|
309
|
+
(state) => {
|
|
310
|
+
currentState = state;
|
|
311
|
+
},
|
|
312
|
+
emitIfChanged,
|
|
313
|
+
select,
|
|
314
|
+
context,
|
|
315
|
+
onCleanup,
|
|
316
|
+
)
|
|
317
|
+
: undefined;
|
|
318
|
+
|
|
319
|
+
const processed = isQuery
|
|
320
|
+
? await this.processQueryResult(
|
|
321
|
+
path,
|
|
322
|
+
currentState,
|
|
323
|
+
select,
|
|
324
|
+
context,
|
|
325
|
+
onCleanup,
|
|
326
|
+
fieldEmitFactory,
|
|
327
|
+
)
|
|
328
|
+
: currentState;
|
|
329
|
+
|
|
330
|
+
emitIfChanged(processed);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
emitProcessing = false;
|
|
334
|
+
};
|
|
335
|
+
|
|
281
336
|
const emitHandler = (command: EmitCommand) => {
|
|
282
337
|
if (cancelled) return;
|
|
283
|
-
|
|
284
|
-
|
|
338
|
+
emitQueue.push(command);
|
|
339
|
+
// Fire async processing (don't await - emit should be sync from caller's perspective)
|
|
340
|
+
processEmitQueue().catch((err) => {
|
|
341
|
+
if (!cancelled) {
|
|
342
|
+
observer.next?.({ error: err instanceof Error ? err : new Error(String(err)) });
|
|
343
|
+
}
|
|
344
|
+
});
|
|
285
345
|
};
|
|
286
346
|
|
|
287
347
|
const emit = createEmit(emitHandler);
|
|
@@ -293,6 +353,20 @@ class LensServerImpl<
|
|
|
293
353
|
};
|
|
294
354
|
};
|
|
295
355
|
|
|
356
|
+
// Create field emit factory for field-level live queries
|
|
357
|
+
const createFieldEmit = isQuery
|
|
358
|
+
? this.createFieldEmitFactory(
|
|
359
|
+
() => currentState,
|
|
360
|
+
(state) => {
|
|
361
|
+
currentState = state;
|
|
362
|
+
},
|
|
363
|
+
emitIfChanged,
|
|
364
|
+
select,
|
|
365
|
+
context,
|
|
366
|
+
onCleanup,
|
|
367
|
+
)
|
|
368
|
+
: undefined;
|
|
369
|
+
|
|
296
370
|
const lensContext = { ...context, emit, onCleanup };
|
|
297
371
|
const result = resolver({ input: cleanInput, ctx: lensContext });
|
|
298
372
|
|
|
@@ -301,8 +375,15 @@ class LensServerImpl<
|
|
|
301
375
|
for await (const value of result) {
|
|
302
376
|
if (cancelled) break;
|
|
303
377
|
currentState = value;
|
|
304
|
-
const processed = await this.processQueryResult(
|
|
305
|
-
|
|
378
|
+
const processed = await this.processQueryResult(
|
|
379
|
+
path,
|
|
380
|
+
value,
|
|
381
|
+
select,
|
|
382
|
+
context,
|
|
383
|
+
onCleanup,
|
|
384
|
+
createFieldEmit,
|
|
385
|
+
);
|
|
386
|
+
emitIfChanged(processed);
|
|
306
387
|
}
|
|
307
388
|
if (!cancelled) {
|
|
308
389
|
observer.complete?.();
|
|
@@ -312,13 +393,18 @@ class LensServerImpl<
|
|
|
312
393
|
const value = await result;
|
|
313
394
|
currentState = value;
|
|
314
395
|
const processed = isQuery
|
|
315
|
-
? await this.processQueryResult(
|
|
396
|
+
? await this.processQueryResult(
|
|
397
|
+
path,
|
|
398
|
+
value,
|
|
399
|
+
select,
|
|
400
|
+
context,
|
|
401
|
+
onCleanup,
|
|
402
|
+
createFieldEmit,
|
|
403
|
+
)
|
|
316
404
|
: value;
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
// For true one-shot, client can unsubscribe after first value
|
|
321
|
-
}
|
|
405
|
+
emitIfChanged(processed);
|
|
406
|
+
// Don't complete immediately - stay open for potential emit calls
|
|
407
|
+
// For true one-shot, client can unsubscribe after first value
|
|
322
408
|
}
|
|
323
409
|
});
|
|
324
410
|
} catch (error) {
|
|
@@ -409,25 +495,149 @@ class LensServerImpl<
|
|
|
409
495
|
// Result Processing
|
|
410
496
|
// =========================================================================
|
|
411
497
|
|
|
498
|
+
/**
|
|
499
|
+
* Factory type for creating field-level emit handlers.
|
|
500
|
+
* Each field gets its own emit that updates just that field path.
|
|
501
|
+
*/
|
|
502
|
+
private createFieldEmitFactory(
|
|
503
|
+
getCurrentState: () => unknown,
|
|
504
|
+
setCurrentState: (state: unknown) => void,
|
|
505
|
+
notifyObserver: (data: unknown) => void,
|
|
506
|
+
select: SelectionObject | undefined,
|
|
507
|
+
context: TContext | undefined,
|
|
508
|
+
onCleanup: ((fn: () => void) => void) | undefined,
|
|
509
|
+
): (fieldPath: string) => ((value: unknown) => void) | undefined {
|
|
510
|
+
return (fieldPath: string) => {
|
|
511
|
+
if (!fieldPath) return undefined;
|
|
512
|
+
|
|
513
|
+
return (newValue: unknown) => {
|
|
514
|
+
// Get current state and update the field at the given path
|
|
515
|
+
const state = getCurrentState();
|
|
516
|
+
if (!state || typeof state !== "object") return;
|
|
517
|
+
|
|
518
|
+
const updatedState = this.setFieldByPath(
|
|
519
|
+
state as Record<string, unknown>,
|
|
520
|
+
fieldPath,
|
|
521
|
+
newValue,
|
|
522
|
+
);
|
|
523
|
+
setCurrentState(updatedState);
|
|
524
|
+
|
|
525
|
+
// Resolve nested fields on the new value and notify observer
|
|
526
|
+
(async () => {
|
|
527
|
+
try {
|
|
528
|
+
const nestedInputs = select ? extractNestedInputs(select) : undefined;
|
|
529
|
+
const processed = await this.resolveEntityFields(
|
|
530
|
+
updatedState,
|
|
531
|
+
nestedInputs,
|
|
532
|
+
context,
|
|
533
|
+
"",
|
|
534
|
+
onCleanup,
|
|
535
|
+
this.createFieldEmitFactory(
|
|
536
|
+
getCurrentState,
|
|
537
|
+
setCurrentState,
|
|
538
|
+
notifyObserver,
|
|
539
|
+
select,
|
|
540
|
+
context,
|
|
541
|
+
onCleanup,
|
|
542
|
+
),
|
|
543
|
+
);
|
|
544
|
+
const result = select ? applySelection(processed, select) : processed;
|
|
545
|
+
notifyObserver(result);
|
|
546
|
+
} catch (err) {
|
|
547
|
+
// Field emit errors are logged but don't break the stream
|
|
548
|
+
console.error(`Field emit error at path "${fieldPath}":`, err);
|
|
549
|
+
}
|
|
550
|
+
})();
|
|
551
|
+
};
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Set a value at a nested path in an object.
|
|
557
|
+
* Creates a shallow copy at each level.
|
|
558
|
+
*/
|
|
559
|
+
private setFieldByPath(
|
|
560
|
+
obj: Record<string, unknown>,
|
|
561
|
+
path: string,
|
|
562
|
+
value: unknown,
|
|
563
|
+
): Record<string, unknown> {
|
|
564
|
+
const parts = path.split(".");
|
|
565
|
+
if (parts.length === 1) {
|
|
566
|
+
return { ...obj, [path]: value };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const [first, ...rest] = parts;
|
|
570
|
+
const nested = obj[first];
|
|
571
|
+
if (nested && typeof nested === "object") {
|
|
572
|
+
return {
|
|
573
|
+
...obj,
|
|
574
|
+
[first]: this.setFieldByPath(nested as Record<string, unknown>, rest.join("."), value),
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
return obj;
|
|
578
|
+
}
|
|
579
|
+
|
|
412
580
|
private async processQueryResult<T>(
|
|
413
581
|
_operationName: string,
|
|
414
582
|
data: T,
|
|
415
583
|
select?: SelectionObject,
|
|
584
|
+
context?: TContext,
|
|
585
|
+
onCleanup?: (fn: () => void) => void,
|
|
586
|
+
createFieldEmit?: (fieldPath: string) => ((value: unknown) => void) | undefined,
|
|
416
587
|
): Promise<T> {
|
|
417
588
|
if (!data) return data;
|
|
418
589
|
|
|
419
|
-
|
|
590
|
+
// Extract nested inputs from selection for field resolver args
|
|
591
|
+
const nestedInputs = select ? extractNestedInputs(select) : undefined;
|
|
592
|
+
|
|
593
|
+
const processed = await this.resolveEntityFields(
|
|
594
|
+
data,
|
|
595
|
+
nestedInputs,
|
|
596
|
+
context,
|
|
597
|
+
"",
|
|
598
|
+
onCleanup,
|
|
599
|
+
createFieldEmit,
|
|
600
|
+
);
|
|
420
601
|
if (select) {
|
|
421
602
|
return applySelection(processed, select) as T;
|
|
422
603
|
}
|
|
423
604
|
return processed as T;
|
|
424
605
|
}
|
|
425
606
|
|
|
426
|
-
|
|
607
|
+
/**
|
|
608
|
+
* Resolve entity fields using field resolvers.
|
|
609
|
+
* Supports nested inputs for field-level arguments (like GraphQL).
|
|
610
|
+
*
|
|
611
|
+
* @param data - The data to resolve
|
|
612
|
+
* @param nestedInputs - Map of field paths to their input args (from extractNestedInputs)
|
|
613
|
+
* @param context - Request context to pass to field resolvers
|
|
614
|
+
* @param fieldPath - Current path for nested field resolution
|
|
615
|
+
* @param onCleanup - Cleanup registration for live query subscriptions
|
|
616
|
+
* @param createFieldEmit - Factory for creating field-specific emit handlers
|
|
617
|
+
*/
|
|
618
|
+
private async resolveEntityFields<T>(
|
|
619
|
+
data: T,
|
|
620
|
+
nestedInputs?: Map<string, Record<string, unknown>>,
|
|
621
|
+
context?: TContext,
|
|
622
|
+
fieldPath = "",
|
|
623
|
+
onCleanup?: (fn: () => void) => void,
|
|
624
|
+
createFieldEmit?: (fieldPath: string) => ((value: unknown) => void) | undefined,
|
|
625
|
+
): Promise<T> {
|
|
427
626
|
if (!data || !this.resolverMap) return data;
|
|
428
627
|
|
|
429
628
|
if (Array.isArray(data)) {
|
|
430
|
-
return Promise.all(
|
|
629
|
+
return Promise.all(
|
|
630
|
+
data.map((item) =>
|
|
631
|
+
this.resolveEntityFields(
|
|
632
|
+
item,
|
|
633
|
+
nestedInputs,
|
|
634
|
+
context,
|
|
635
|
+
fieldPath,
|
|
636
|
+
onCleanup,
|
|
637
|
+
createFieldEmit,
|
|
638
|
+
),
|
|
639
|
+
),
|
|
640
|
+
) as Promise<T>;
|
|
431
641
|
}
|
|
432
642
|
|
|
433
643
|
if (typeof data !== "object") return data;
|
|
@@ -447,37 +657,107 @@ class LensServerImpl<
|
|
|
447
657
|
// Skip exposed fields
|
|
448
658
|
if (resolverDef.isExposed(field)) continue;
|
|
449
659
|
|
|
660
|
+
// Calculate the path for this field (for nested input lookup)
|
|
661
|
+
const currentPath = fieldPath ? `${fieldPath}.${field}` : field;
|
|
662
|
+
|
|
663
|
+
// Get args for this field from nested inputs
|
|
664
|
+
const args = nestedInputs?.get(currentPath) ?? {};
|
|
665
|
+
const hasArgs = Object.keys(args).length > 0;
|
|
666
|
+
|
|
450
667
|
// Skip if value already exists
|
|
451
668
|
const existingValue = result[field];
|
|
452
669
|
if (existingValue !== undefined) {
|
|
453
|
-
result[field] = await this.resolveEntityFields(
|
|
670
|
+
result[field] = await this.resolveEntityFields(
|
|
671
|
+
existingValue,
|
|
672
|
+
nestedInputs,
|
|
673
|
+
context,
|
|
674
|
+
currentPath,
|
|
675
|
+
onCleanup,
|
|
676
|
+
createFieldEmit,
|
|
677
|
+
);
|
|
454
678
|
continue;
|
|
455
679
|
}
|
|
456
680
|
|
|
457
681
|
// Resolve the field
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
682
|
+
if (hasArgs || context) {
|
|
683
|
+
// Direct resolution when we have args or context (skip DataLoader)
|
|
684
|
+
try {
|
|
685
|
+
// Build extended context with emit and onCleanup
|
|
686
|
+
// Lens is a live query library - these are always available
|
|
687
|
+
const extendedCtx = {
|
|
688
|
+
...(context ?? {}),
|
|
689
|
+
emit: createFieldEmit!(currentPath),
|
|
690
|
+
onCleanup: onCleanup!,
|
|
691
|
+
};
|
|
692
|
+
result[field] = await resolverDef.resolveField(field, obj, args, extendedCtx);
|
|
693
|
+
} catch {
|
|
694
|
+
result[field] = null;
|
|
695
|
+
}
|
|
696
|
+
} else {
|
|
697
|
+
// Use DataLoader for batching when no args (default case)
|
|
698
|
+
const loaderKey = `${typeName}.${field}`;
|
|
699
|
+
const loader = this.getOrCreateLoaderForField(loaderKey, resolverDef, field);
|
|
700
|
+
result[field] = await loader.load(obj);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Recursively resolve nested fields
|
|
704
|
+
result[field] = await this.resolveEntityFields(
|
|
705
|
+
result[field],
|
|
706
|
+
nestedInputs,
|
|
707
|
+
context,
|
|
708
|
+
currentPath,
|
|
709
|
+
onCleanup,
|
|
710
|
+
createFieldEmit,
|
|
711
|
+
);
|
|
462
712
|
}
|
|
463
713
|
|
|
464
714
|
return result as T;
|
|
465
715
|
}
|
|
466
716
|
|
|
717
|
+
/**
|
|
718
|
+
* Get the type name for an object by matching against entity definitions.
|
|
719
|
+
*
|
|
720
|
+
* Matching priority:
|
|
721
|
+
* 1. Explicit __typename or _type property
|
|
722
|
+
* 2. Best matching entity (highest field overlap score)
|
|
723
|
+
*
|
|
724
|
+
* Requires at least 50% field match to avoid false positives.
|
|
725
|
+
*/
|
|
467
726
|
private getTypeName(obj: Record<string, unknown>): string | undefined {
|
|
727
|
+
// Priority 1: Explicit type marker
|
|
468
728
|
if ("__typename" in obj) return obj.__typename as string;
|
|
469
729
|
if ("_type" in obj) return obj._type as string;
|
|
470
730
|
|
|
731
|
+
// Priority 2: Find best matching entity by field overlap
|
|
732
|
+
let bestMatch: { name: string; score: number } | undefined;
|
|
733
|
+
|
|
471
734
|
for (const [name, def] of Object.entries(this.entities)) {
|
|
472
|
-
if (isEntityDef(def)
|
|
473
|
-
|
|
735
|
+
if (!isEntityDef(def)) continue;
|
|
736
|
+
|
|
737
|
+
const score = this.getEntityMatchScore(obj, def);
|
|
738
|
+
// Require at least 50% field match to avoid false positives
|
|
739
|
+
if (score >= 0.5 && (!bestMatch || score > bestMatch.score)) {
|
|
740
|
+
bestMatch = { name, score };
|
|
474
741
|
}
|
|
475
742
|
}
|
|
476
|
-
|
|
743
|
+
|
|
744
|
+
return bestMatch?.name;
|
|
477
745
|
}
|
|
478
746
|
|
|
479
|
-
|
|
480
|
-
|
|
747
|
+
/**
|
|
748
|
+
* Calculate how well an object matches an entity definition.
|
|
749
|
+
*
|
|
750
|
+
* @returns Score between 0 and 1 (1 = perfect match, all entity fields present)
|
|
751
|
+
*/
|
|
752
|
+
private getEntityMatchScore(
|
|
753
|
+
obj: Record<string, unknown>,
|
|
754
|
+
entityDef: EntityDef<string, any>,
|
|
755
|
+
): number {
|
|
756
|
+
const fieldNames = Object.keys(entityDef.fields);
|
|
757
|
+
if (fieldNames.length === 0) return 0;
|
|
758
|
+
|
|
759
|
+
const matchingFields = fieldNames.filter((field) => field in obj);
|
|
760
|
+
return matchingFields.length / fieldNames.length;
|
|
481
761
|
}
|
|
482
762
|
|
|
483
763
|
private getOrCreateLoaderForField(
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sylphx/lens-server - Selection Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for field selection and nested input extraction.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from "bun:test";
|
|
8
|
+
import { applySelection, extractNestedInputs } from "./selection.js";
|
|
9
|
+
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// applySelection Tests
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
describe("applySelection", () => {
|
|
15
|
+
it("selects simple fields", () => {
|
|
16
|
+
const data = { id: "1", name: "Alice", email: "alice@example.com", age: 30 };
|
|
17
|
+
const select = { name: true, email: true };
|
|
18
|
+
|
|
19
|
+
const result = applySelection(data, select);
|
|
20
|
+
|
|
21
|
+
expect(result).toEqual({ id: "1", name: "Alice", email: "alice@example.com" });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("always includes id field", () => {
|
|
25
|
+
const data = { id: "1", name: "Alice" };
|
|
26
|
+
const select = { name: true };
|
|
27
|
+
|
|
28
|
+
const result = applySelection(data, select);
|
|
29
|
+
|
|
30
|
+
expect(result).toEqual({ id: "1", name: "Alice" });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("handles nested selection with { select: ... }", () => {
|
|
34
|
+
const data = {
|
|
35
|
+
id: "1",
|
|
36
|
+
name: "Alice",
|
|
37
|
+
profile: { avatar: "url", bio: "Hello" },
|
|
38
|
+
};
|
|
39
|
+
const select = {
|
|
40
|
+
name: true,
|
|
41
|
+
profile: { select: { avatar: true } },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const result = applySelection(data, select);
|
|
45
|
+
|
|
46
|
+
expect(result).toEqual({
|
|
47
|
+
id: "1",
|
|
48
|
+
name: "Alice",
|
|
49
|
+
profile: { avatar: "url" },
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("handles nested selection with { input: ..., select: ... }", () => {
|
|
54
|
+
const data = {
|
|
55
|
+
id: "1",
|
|
56
|
+
name: "Alice",
|
|
57
|
+
posts: [
|
|
58
|
+
{ id: "p1", title: "Post 1" },
|
|
59
|
+
{ id: "p2", title: "Post 2" },
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
const select = {
|
|
63
|
+
name: true,
|
|
64
|
+
posts: {
|
|
65
|
+
input: { limit: 5 }, // input is ignored for selection, used for resolver args
|
|
66
|
+
select: { title: true },
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const result = applySelection(data, select);
|
|
71
|
+
|
|
72
|
+
expect(result).toEqual({
|
|
73
|
+
id: "1",
|
|
74
|
+
name: "Alice",
|
|
75
|
+
posts: [
|
|
76
|
+
{ id: "p1", title: "Post 1" },
|
|
77
|
+
{ id: "p2", title: "Post 2" },
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("handles arrays", () => {
|
|
83
|
+
const data = [
|
|
84
|
+
{ id: "1", name: "Alice", email: "alice@example.com" },
|
|
85
|
+
{ id: "2", name: "Bob", email: "bob@example.com" },
|
|
86
|
+
];
|
|
87
|
+
const select = { name: true };
|
|
88
|
+
|
|
89
|
+
const result = applySelection(data, select);
|
|
90
|
+
|
|
91
|
+
expect(result).toEqual([
|
|
92
|
+
{ id: "1", name: "Alice" },
|
|
93
|
+
{ id: "2", name: "Bob" },
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns null/undefined as-is", () => {
|
|
98
|
+
expect(applySelection(null, { name: true })).toBeNull();
|
|
99
|
+
expect(applySelection(undefined, { name: true })).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("includes whole field when no nested select", () => {
|
|
103
|
+
const data = {
|
|
104
|
+
id: "1",
|
|
105
|
+
profile: { avatar: "url", bio: "Hello" },
|
|
106
|
+
};
|
|
107
|
+
const select = {
|
|
108
|
+
profile: { input: { size: "large" } }, // input only, no select
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const result = applySelection(data, select);
|
|
112
|
+
|
|
113
|
+
expect(result).toEqual({
|
|
114
|
+
id: "1",
|
|
115
|
+
profile: { avatar: "url", bio: "Hello" },
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// =============================================================================
|
|
121
|
+
// extractNestedInputs Tests
|
|
122
|
+
// =============================================================================
|
|
123
|
+
|
|
124
|
+
describe("extractNestedInputs", () => {
|
|
125
|
+
it("extracts input from nested selection", () => {
|
|
126
|
+
const select = {
|
|
127
|
+
name: true,
|
|
128
|
+
posts: {
|
|
129
|
+
input: { limit: 5, published: true },
|
|
130
|
+
select: { title: true },
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const inputs = extractNestedInputs(select);
|
|
135
|
+
|
|
136
|
+
expect(inputs.size).toBe(1);
|
|
137
|
+
expect(inputs.get("posts")).toEqual({ limit: 5, published: true });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("extracts inputs at multiple levels", () => {
|
|
141
|
+
const select = {
|
|
142
|
+
name: true,
|
|
143
|
+
posts: {
|
|
144
|
+
input: { limit: 5 },
|
|
145
|
+
select: {
|
|
146
|
+
title: true,
|
|
147
|
+
comments: {
|
|
148
|
+
input: { limit: 3 },
|
|
149
|
+
select: { body: true },
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const inputs = extractNestedInputs(select);
|
|
156
|
+
|
|
157
|
+
expect(inputs.size).toBe(2);
|
|
158
|
+
expect(inputs.get("posts")).toEqual({ limit: 5 });
|
|
159
|
+
expect(inputs.get("posts.comments")).toEqual({ limit: 3 });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("returns empty map when no nested inputs", () => {
|
|
163
|
+
const select = {
|
|
164
|
+
name: true,
|
|
165
|
+
posts: { select: { title: true } },
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const inputs = extractNestedInputs(select);
|
|
169
|
+
|
|
170
|
+
expect(inputs.size).toBe(0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("handles deeply nested inputs", () => {
|
|
174
|
+
const select = {
|
|
175
|
+
author: {
|
|
176
|
+
input: { includeDeleted: false },
|
|
177
|
+
select: {
|
|
178
|
+
posts: {
|
|
179
|
+
input: { status: "published" },
|
|
180
|
+
select: {
|
|
181
|
+
comments: {
|
|
182
|
+
input: { limit: 10 },
|
|
183
|
+
select: {
|
|
184
|
+
replies: {
|
|
185
|
+
input: { depth: 2 },
|
|
186
|
+
select: { body: true },
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const inputs = extractNestedInputs(select);
|
|
197
|
+
|
|
198
|
+
expect(inputs.size).toBe(4);
|
|
199
|
+
expect(inputs.get("author")).toEqual({ includeDeleted: false });
|
|
200
|
+
expect(inputs.get("author.posts")).toEqual({ status: "published" });
|
|
201
|
+
expect(inputs.get("author.posts.comments")).toEqual({ limit: 10 });
|
|
202
|
+
expect(inputs.get("author.posts.comments.replies")).toEqual({ depth: 2 });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("handles mixed selection (some with input, some without)", () => {
|
|
206
|
+
const select = {
|
|
207
|
+
name: true,
|
|
208
|
+
posts: {
|
|
209
|
+
input: { limit: 5 },
|
|
210
|
+
select: { title: true },
|
|
211
|
+
},
|
|
212
|
+
followers: {
|
|
213
|
+
select: { name: true }, // no input
|
|
214
|
+
},
|
|
215
|
+
settings: true, // simple selection
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const inputs = extractNestedInputs(select);
|
|
219
|
+
|
|
220
|
+
expect(inputs.size).toBe(1);
|
|
221
|
+
expect(inputs.get("posts")).toEqual({ limit: 5 });
|
|
222
|
+
expect(inputs.has("followers")).toBe(false);
|
|
223
|
+
expect(inputs.has("settings")).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("handles input with empty object", () => {
|
|
227
|
+
const select = {
|
|
228
|
+
posts: {
|
|
229
|
+
input: {},
|
|
230
|
+
select: { title: true },
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const inputs = extractNestedInputs(select);
|
|
235
|
+
|
|
236
|
+
// Empty input object is still recorded
|
|
237
|
+
expect(inputs.size).toBe(1);
|
|
238
|
+
expect(inputs.get("posts")).toEqual({});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("handles selection with input but no select", () => {
|
|
242
|
+
const select = {
|
|
243
|
+
posts: {
|
|
244
|
+
input: { limit: 5 },
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const inputs = extractNestedInputs(select);
|
|
249
|
+
|
|
250
|
+
expect(inputs.size).toBe(1);
|
|
251
|
+
expect(inputs.get("posts")).toEqual({ limit: 5 });
|
|
252
|
+
});
|
|
253
|
+
});
|