@sylphx/lens-server 2.2.0 → 2.3.1
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 +142 -24
- package/package.json +2 -2
- package/src/server/create.test.ts +633 -1
- package/src/server/create.ts +308 -26
- 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,14 +287,66 @@ 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
|
+
// Detect array output type: [EntityDef] is stored as single-element array
|
|
348
|
+
const isArrayOutput = Array.isArray(def._output);
|
|
349
|
+
const emit = createEmit(emitHandler, isArrayOutput);
|
|
288
350
|
const onCleanup = (fn: () => void) => {
|
|
289
351
|
cleanups.push(fn);
|
|
290
352
|
return () => {
|
|
@@ -293,6 +355,20 @@ class LensServerImpl<
|
|
|
293
355
|
};
|
|
294
356
|
};
|
|
295
357
|
|
|
358
|
+
// Create field emit factory for field-level live queries
|
|
359
|
+
const createFieldEmit = isQuery
|
|
360
|
+
? this.createFieldEmitFactory(
|
|
361
|
+
() => currentState,
|
|
362
|
+
(state) => {
|
|
363
|
+
currentState = state;
|
|
364
|
+
},
|
|
365
|
+
emitIfChanged,
|
|
366
|
+
select,
|
|
367
|
+
context,
|
|
368
|
+
onCleanup,
|
|
369
|
+
)
|
|
370
|
+
: undefined;
|
|
371
|
+
|
|
296
372
|
const lensContext = { ...context, emit, onCleanup };
|
|
297
373
|
const result = resolver({ input: cleanInput, ctx: lensContext });
|
|
298
374
|
|
|
@@ -301,8 +377,15 @@ class LensServerImpl<
|
|
|
301
377
|
for await (const value of result) {
|
|
302
378
|
if (cancelled) break;
|
|
303
379
|
currentState = value;
|
|
304
|
-
const processed = await this.processQueryResult(
|
|
305
|
-
|
|
380
|
+
const processed = await this.processQueryResult(
|
|
381
|
+
path,
|
|
382
|
+
value,
|
|
383
|
+
select,
|
|
384
|
+
context,
|
|
385
|
+
onCleanup,
|
|
386
|
+
createFieldEmit,
|
|
387
|
+
);
|
|
388
|
+
emitIfChanged(processed);
|
|
306
389
|
}
|
|
307
390
|
if (!cancelled) {
|
|
308
391
|
observer.complete?.();
|
|
@@ -312,13 +395,18 @@ class LensServerImpl<
|
|
|
312
395
|
const value = await result;
|
|
313
396
|
currentState = value;
|
|
314
397
|
const processed = isQuery
|
|
315
|
-
? await this.processQueryResult(
|
|
398
|
+
? await this.processQueryResult(
|
|
399
|
+
path,
|
|
400
|
+
value,
|
|
401
|
+
select,
|
|
402
|
+
context,
|
|
403
|
+
onCleanup,
|
|
404
|
+
createFieldEmit,
|
|
405
|
+
)
|
|
316
406
|
: value;
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
// For true one-shot, client can unsubscribe after first value
|
|
321
|
-
}
|
|
407
|
+
emitIfChanged(processed);
|
|
408
|
+
// Don't complete immediately - stay open for potential emit calls
|
|
409
|
+
// For true one-shot, client can unsubscribe after first value
|
|
322
410
|
}
|
|
323
411
|
});
|
|
324
412
|
} catch (error) {
|
|
@@ -409,25 +497,149 @@ class LensServerImpl<
|
|
|
409
497
|
// Result Processing
|
|
410
498
|
// =========================================================================
|
|
411
499
|
|
|
500
|
+
/**
|
|
501
|
+
* Factory type for creating field-level emit handlers.
|
|
502
|
+
* Each field gets its own emit that updates just that field path.
|
|
503
|
+
*/
|
|
504
|
+
private createFieldEmitFactory(
|
|
505
|
+
getCurrentState: () => unknown,
|
|
506
|
+
setCurrentState: (state: unknown) => void,
|
|
507
|
+
notifyObserver: (data: unknown) => void,
|
|
508
|
+
select: SelectionObject | undefined,
|
|
509
|
+
context: TContext | undefined,
|
|
510
|
+
onCleanup: ((fn: () => void) => void) | undefined,
|
|
511
|
+
): (fieldPath: string) => ((value: unknown) => void) | undefined {
|
|
512
|
+
return (fieldPath: string) => {
|
|
513
|
+
if (!fieldPath) return undefined;
|
|
514
|
+
|
|
515
|
+
return (newValue: unknown) => {
|
|
516
|
+
// Get current state and update the field at the given path
|
|
517
|
+
const state = getCurrentState();
|
|
518
|
+
if (!state || typeof state !== "object") return;
|
|
519
|
+
|
|
520
|
+
const updatedState = this.setFieldByPath(
|
|
521
|
+
state as Record<string, unknown>,
|
|
522
|
+
fieldPath,
|
|
523
|
+
newValue,
|
|
524
|
+
);
|
|
525
|
+
setCurrentState(updatedState);
|
|
526
|
+
|
|
527
|
+
// Resolve nested fields on the new value and notify observer
|
|
528
|
+
(async () => {
|
|
529
|
+
try {
|
|
530
|
+
const nestedInputs = select ? extractNestedInputs(select) : undefined;
|
|
531
|
+
const processed = await this.resolveEntityFields(
|
|
532
|
+
updatedState,
|
|
533
|
+
nestedInputs,
|
|
534
|
+
context,
|
|
535
|
+
"",
|
|
536
|
+
onCleanup,
|
|
537
|
+
this.createFieldEmitFactory(
|
|
538
|
+
getCurrentState,
|
|
539
|
+
setCurrentState,
|
|
540
|
+
notifyObserver,
|
|
541
|
+
select,
|
|
542
|
+
context,
|
|
543
|
+
onCleanup,
|
|
544
|
+
),
|
|
545
|
+
);
|
|
546
|
+
const result = select ? applySelection(processed, select) : processed;
|
|
547
|
+
notifyObserver(result);
|
|
548
|
+
} catch (err) {
|
|
549
|
+
// Field emit errors are logged but don't break the stream
|
|
550
|
+
console.error(`Field emit error at path "${fieldPath}":`, err);
|
|
551
|
+
}
|
|
552
|
+
})();
|
|
553
|
+
};
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Set a value at a nested path in an object.
|
|
559
|
+
* Creates a shallow copy at each level.
|
|
560
|
+
*/
|
|
561
|
+
private setFieldByPath(
|
|
562
|
+
obj: Record<string, unknown>,
|
|
563
|
+
path: string,
|
|
564
|
+
value: unknown,
|
|
565
|
+
): Record<string, unknown> {
|
|
566
|
+
const parts = path.split(".");
|
|
567
|
+
if (parts.length === 1) {
|
|
568
|
+
return { ...obj, [path]: value };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const [first, ...rest] = parts;
|
|
572
|
+
const nested = obj[first];
|
|
573
|
+
if (nested && typeof nested === "object") {
|
|
574
|
+
return {
|
|
575
|
+
...obj,
|
|
576
|
+
[first]: this.setFieldByPath(nested as Record<string, unknown>, rest.join("."), value),
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
return obj;
|
|
580
|
+
}
|
|
581
|
+
|
|
412
582
|
private async processQueryResult<T>(
|
|
413
583
|
_operationName: string,
|
|
414
584
|
data: T,
|
|
415
585
|
select?: SelectionObject,
|
|
586
|
+
context?: TContext,
|
|
587
|
+
onCleanup?: (fn: () => void) => void,
|
|
588
|
+
createFieldEmit?: (fieldPath: string) => ((value: unknown) => void) | undefined,
|
|
416
589
|
): Promise<T> {
|
|
417
590
|
if (!data) return data;
|
|
418
591
|
|
|
419
|
-
|
|
592
|
+
// Extract nested inputs from selection for field resolver args
|
|
593
|
+
const nestedInputs = select ? extractNestedInputs(select) : undefined;
|
|
594
|
+
|
|
595
|
+
const processed = await this.resolveEntityFields(
|
|
596
|
+
data,
|
|
597
|
+
nestedInputs,
|
|
598
|
+
context,
|
|
599
|
+
"",
|
|
600
|
+
onCleanup,
|
|
601
|
+
createFieldEmit,
|
|
602
|
+
);
|
|
420
603
|
if (select) {
|
|
421
604
|
return applySelection(processed, select) as T;
|
|
422
605
|
}
|
|
423
606
|
return processed as T;
|
|
424
607
|
}
|
|
425
608
|
|
|
426
|
-
|
|
609
|
+
/**
|
|
610
|
+
* Resolve entity fields using field resolvers.
|
|
611
|
+
* Supports nested inputs for field-level arguments (like GraphQL).
|
|
612
|
+
*
|
|
613
|
+
* @param data - The data to resolve
|
|
614
|
+
* @param nestedInputs - Map of field paths to their input args (from extractNestedInputs)
|
|
615
|
+
* @param context - Request context to pass to field resolvers
|
|
616
|
+
* @param fieldPath - Current path for nested field resolution
|
|
617
|
+
* @param onCleanup - Cleanup registration for live query subscriptions
|
|
618
|
+
* @param createFieldEmit - Factory for creating field-specific emit handlers
|
|
619
|
+
*/
|
|
620
|
+
private async resolveEntityFields<T>(
|
|
621
|
+
data: T,
|
|
622
|
+
nestedInputs?: Map<string, Record<string, unknown>>,
|
|
623
|
+
context?: TContext,
|
|
624
|
+
fieldPath = "",
|
|
625
|
+
onCleanup?: (fn: () => void) => void,
|
|
626
|
+
createFieldEmit?: (fieldPath: string) => ((value: unknown) => void) | undefined,
|
|
627
|
+
): Promise<T> {
|
|
427
628
|
if (!data || !this.resolverMap) return data;
|
|
428
629
|
|
|
429
630
|
if (Array.isArray(data)) {
|
|
430
|
-
return Promise.all(
|
|
631
|
+
return Promise.all(
|
|
632
|
+
data.map((item) =>
|
|
633
|
+
this.resolveEntityFields(
|
|
634
|
+
item,
|
|
635
|
+
nestedInputs,
|
|
636
|
+
context,
|
|
637
|
+
fieldPath,
|
|
638
|
+
onCleanup,
|
|
639
|
+
createFieldEmit,
|
|
640
|
+
),
|
|
641
|
+
),
|
|
642
|
+
) as Promise<T>;
|
|
431
643
|
}
|
|
432
644
|
|
|
433
645
|
if (typeof data !== "object") return data;
|
|
@@ -447,37 +659,107 @@ class LensServerImpl<
|
|
|
447
659
|
// Skip exposed fields
|
|
448
660
|
if (resolverDef.isExposed(field)) continue;
|
|
449
661
|
|
|
662
|
+
// Calculate the path for this field (for nested input lookup)
|
|
663
|
+
const currentPath = fieldPath ? `${fieldPath}.${field}` : field;
|
|
664
|
+
|
|
665
|
+
// Get args for this field from nested inputs
|
|
666
|
+
const args = nestedInputs?.get(currentPath) ?? {};
|
|
667
|
+
const hasArgs = Object.keys(args).length > 0;
|
|
668
|
+
|
|
450
669
|
// Skip if value already exists
|
|
451
670
|
const existingValue = result[field];
|
|
452
671
|
if (existingValue !== undefined) {
|
|
453
|
-
result[field] = await this.resolveEntityFields(
|
|
672
|
+
result[field] = await this.resolveEntityFields(
|
|
673
|
+
existingValue,
|
|
674
|
+
nestedInputs,
|
|
675
|
+
context,
|
|
676
|
+
currentPath,
|
|
677
|
+
onCleanup,
|
|
678
|
+
createFieldEmit,
|
|
679
|
+
);
|
|
454
680
|
continue;
|
|
455
681
|
}
|
|
456
682
|
|
|
457
683
|
// Resolve the field
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
684
|
+
if (hasArgs || context) {
|
|
685
|
+
// Direct resolution when we have args or context (skip DataLoader)
|
|
686
|
+
try {
|
|
687
|
+
// Build extended context with emit and onCleanup
|
|
688
|
+
// Lens is a live query library - these are always available
|
|
689
|
+
const extendedCtx = {
|
|
690
|
+
...(context ?? {}),
|
|
691
|
+
emit: createFieldEmit!(currentPath),
|
|
692
|
+
onCleanup: onCleanup!,
|
|
693
|
+
};
|
|
694
|
+
result[field] = await resolverDef.resolveField(field, obj, args, extendedCtx);
|
|
695
|
+
} catch {
|
|
696
|
+
result[field] = null;
|
|
697
|
+
}
|
|
698
|
+
} else {
|
|
699
|
+
// Use DataLoader for batching when no args (default case)
|
|
700
|
+
const loaderKey = `${typeName}.${field}`;
|
|
701
|
+
const loader = this.getOrCreateLoaderForField(loaderKey, resolverDef, field);
|
|
702
|
+
result[field] = await loader.load(obj);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Recursively resolve nested fields
|
|
706
|
+
result[field] = await this.resolveEntityFields(
|
|
707
|
+
result[field],
|
|
708
|
+
nestedInputs,
|
|
709
|
+
context,
|
|
710
|
+
currentPath,
|
|
711
|
+
onCleanup,
|
|
712
|
+
createFieldEmit,
|
|
713
|
+
);
|
|
462
714
|
}
|
|
463
715
|
|
|
464
716
|
return result as T;
|
|
465
717
|
}
|
|
466
718
|
|
|
719
|
+
/**
|
|
720
|
+
* Get the type name for an object by matching against entity definitions.
|
|
721
|
+
*
|
|
722
|
+
* Matching priority:
|
|
723
|
+
* 1. Explicit __typename or _type property
|
|
724
|
+
* 2. Best matching entity (highest field overlap score)
|
|
725
|
+
*
|
|
726
|
+
* Requires at least 50% field match to avoid false positives.
|
|
727
|
+
*/
|
|
467
728
|
private getTypeName(obj: Record<string, unknown>): string | undefined {
|
|
729
|
+
// Priority 1: Explicit type marker
|
|
468
730
|
if ("__typename" in obj) return obj.__typename as string;
|
|
469
731
|
if ("_type" in obj) return obj._type as string;
|
|
470
732
|
|
|
733
|
+
// Priority 2: Find best matching entity by field overlap
|
|
734
|
+
let bestMatch: { name: string; score: number } | undefined;
|
|
735
|
+
|
|
471
736
|
for (const [name, def] of Object.entries(this.entities)) {
|
|
472
|
-
if (isEntityDef(def)
|
|
473
|
-
|
|
737
|
+
if (!isEntityDef(def)) continue;
|
|
738
|
+
|
|
739
|
+
const score = this.getEntityMatchScore(obj, def);
|
|
740
|
+
// Require at least 50% field match to avoid false positives
|
|
741
|
+
if (score >= 0.5 && (!bestMatch || score > bestMatch.score)) {
|
|
742
|
+
bestMatch = { name, score };
|
|
474
743
|
}
|
|
475
744
|
}
|
|
476
|
-
|
|
745
|
+
|
|
746
|
+
return bestMatch?.name;
|
|
477
747
|
}
|
|
478
748
|
|
|
479
|
-
|
|
480
|
-
|
|
749
|
+
/**
|
|
750
|
+
* Calculate how well an object matches an entity definition.
|
|
751
|
+
*
|
|
752
|
+
* @returns Score between 0 and 1 (1 = perfect match, all entity fields present)
|
|
753
|
+
*/
|
|
754
|
+
private getEntityMatchScore(
|
|
755
|
+
obj: Record<string, unknown>,
|
|
756
|
+
entityDef: EntityDef<string, any>,
|
|
757
|
+
): number {
|
|
758
|
+
const fieldNames = Object.keys(entityDef.fields);
|
|
759
|
+
if (fieldNames.length === 0) return 0;
|
|
760
|
+
|
|
761
|
+
const matchingFields = fieldNames.filter((field) => field in obj);
|
|
762
|
+
return matchingFields.length / fieldNames.length;
|
|
481
763
|
}
|
|
482
764
|
|
|
483
765
|
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
|
+
});
|