@reventlessdev/reventless-core 3.0.0-alpha.245 → 3.0.0-alpha.247

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.
@@ -572,3 +572,144 @@ describe("@internal keeps a field off the generated type", () => {
572
572
  ))
573
573
  })
574
574
  })
575
+
576
+ // `@resolves` / `@resolvesMany` add a field to a queryable's object type that the
577
+ // state record does not carry — the row on the other side of a foreign key. The
578
+ // PPX has written those configs into `config.idResolvers` / `config.idsResolvers`
579
+ // all along and nothing emitted the field, so every backend provisioned a
580
+ // resolver for a field its own schema never declared.
581
+
582
+ module ResolvedFields = {
583
+ @schema
584
+ type product = {productId: string, name: string}
585
+
586
+ @schema
587
+ type order = {orderId: string, productId: string, productIds: array<string>}
588
+
589
+ let entries = (
590
+ ~resolvedFields,
591
+ ~targetPermission: option<Reventless.Authorization.permission>=?,
592
+ ): array<ReventlessInfra.Api.querySchemaEntry> => [
593
+ {
594
+ singleFieldName: "Catalog_Order",
595
+ listFieldName: "Catalog_Orders",
596
+ returnTypeName: "Catalog_Order",
597
+ stateSchema: orderSchema->S.castToUnknown,
598
+ authorization: None,
599
+ resolvedFields: ?resolvedFields,
600
+ },
601
+ {
602
+ singleFieldName: "Catalog_Product",
603
+ listFieldName: "Catalog_Products",
604
+ returnTypeName: "Catalog_Product",
605
+ stateSchema: productSchema->S.castToUnknown,
606
+ authorization: None,
607
+ permission: ?targetPermission,
608
+ },
609
+ ]
610
+
611
+ let bothFields: array<ReventlessInfra.Api.resolvedFieldEntry> = [
612
+ {fieldName: "product", typeName: "Catalog_Product", multi: false},
613
+ {fieldName: "products", typeName: "Catalog_Product", multi: true},
614
+ ]
615
+ }
616
+
617
+ describe("@resolves / @resolvesMany reach the SDL", () => {
618
+ let refused = f =>
619
+ try {
620
+ let _ = f()
621
+ false
622
+ } catch {
623
+ | _ => true
624
+ }
625
+
626
+ let orderType = (~resolvedFields, ~targetPermission=?) =>
627
+ GraphQL_Stitcher.decode(
628
+ GraphQL_FragmentGenerator.generate(
629
+ ~mutationEntries=[],
630
+ ~queryEntries=ResolvedFields.entries(~resolvedFields, ~targetPermission?),
631
+ ),
632
+ ).types
633
+ ->Array.find(t => t->String.startsWith("type Catalog_Order "))
634
+ ->Option.getOr("")
635
+
636
+ testSync("the single form returns the target's type", () =>
637
+ expect(
638
+ orderType(~resolvedFields=Some(ResolvedFields.bothFields))->String.includes(
639
+ "product: Catalog_Product",
640
+ ),
641
+ )->toBe(true)
642
+ )
643
+
644
+ // A list of non-null items rather than `[T]`: the batch read drops ids it did
645
+ // not find instead of padding the gap with nulls, so the field is shorter.
646
+ testSync("the batch form returns a list of the target's type", () =>
647
+ expect(
648
+ orderType(~resolvedFields=Some(ResolvedFields.bothFields))->String.includes(
649
+ "products: [Catalog_Product!]",
650
+ ),
651
+ )->toBe(true)
652
+ )
653
+
654
+ testSync("the state's own fields are untouched", () => {
655
+ let sdl = orderType(~resolvedFields=Some(ResolvedFields.bothFields))
656
+ expect((sdl->String.includes("orderId:"), sdl->String.includes("id: ID!")))->toEqual((
657
+ true,
658
+ true,
659
+ ))
660
+ })
661
+
662
+ // The control: without the entry the type is exactly what it was, which is what
663
+ // made the annotation inert rather than wrong.
664
+ testSync("a view that declares none gets no extra field", () =>
665
+ expect(orderType(~resolvedFields=None)->String.includes("Catalog_Product"))->toBe(false)
666
+ )
667
+
668
+ // A plugin's document is validated standalone before the merge, so a field
669
+ // returning a name nothing declares fails the whole document — every type in
670
+ // it, not just this field. Refused where the declaration is, naming it.
671
+ testSync("a target this plugin does not expose is refused", () =>
672
+ expect(
673
+ refused(() =>
674
+ orderType(
675
+ ~resolvedFields=Some([{fieldName: "supplier", typeName: "Supply_Supplier", multi: false}]),
676
+ )
677
+ ),
678
+ )->toBe(true)
679
+ )
680
+
681
+ // A nested field is read through its parent, so it answers under the parent's
682
+ // rule. A target guarded more narrowly than the view that embeds it would be
683
+ // handed over by the wider door.
684
+ testSync("a target guarded differently from the parent is refused", () =>
685
+ expect(
686
+ refused(() =>
687
+ orderType(
688
+ ~resolvedFields=Some(ResolvedFields.bothFields),
689
+ ~targetPermission=AllowGroups(["Admin"]),
690
+ )
691
+ ),
692
+ )->toBe(true)
693
+ )
694
+
695
+ // A target open to everyone can only narrow, so it needs no refusal.
696
+ testSync("a target open to everyone is allowed under an authenticated parent", () =>
697
+ expect(
698
+ refused(() =>
699
+ orderType(~resolvedFields=Some(ResolvedFields.bothFields), ~targetPermission=AllowAnonymous)
700
+ ),
701
+ )->toBe(false)
702
+ )
703
+
704
+ testSync("a resolved field colliding with a state field is refused", () =>
705
+ expect(
706
+ refused(() =>
707
+ orderType(
708
+ ~resolvedFields=Some([
709
+ {fieldName: "productId", typeName: "Catalog_Product", multi: false},
710
+ ]),
711
+ )
712
+ ),
713
+ )->toBe(true)
714
+ )
715
+ })
@@ -246,7 +246,7 @@ globalThis.describe("semantic composites are named once, not once per field", ()
246
246
  globalThis.expect(fragmentTypes.some(t => t.includes("AddProductPrice"))).toBe(false);
247
247
  });
248
248
  globalThis.test("the output position keeps the bare name", () => {
249
- let types = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested("Catalog_Product", undefined, false, Sury.$schema(s => ({
249
+ let types = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested("Catalog_Product", undefined, false, undefined, Sury.$schema(s => ({
250
250
  productId: s.m(Sury.string),
251
251
  price: s.m(Money$Reventless.schema)
252
252
  })));
@@ -430,7 +430,7 @@ globalThis.describe("the by-index door", () => {
430
430
  });
431
431
 
432
432
  globalThis.describe("@internal keeps a field off the generated type", () => {
433
- let types = excludeFields => GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested("Platform_Widget", excludeFields, undefined, PsInternalFieldsReadModel$ReventlessCore.stateSchema).join("\n");
433
+ let types = excludeFields => GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested("Platform_Widget", excludeFields, undefined, undefined, PsInternalFieldsReadModel$ReventlessCore.stateSchema).join("\n");
434
434
  globalThis.test("the declared fields are absent", () => {
435
435
  globalThis.expect([
436
436
  types(undefined).includes("eventCollector"),
@@ -471,6 +471,112 @@ globalThis.describe("@internal keeps a field off the generated type", () => {
471
471
  });
472
472
  });
473
473
 
474
+ let productSchema = Sury.$schema(s => ({
475
+ productId: s.m(Sury.string),
476
+ name: s.m(Sury.string)
477
+ }));
478
+
479
+ let orderSchema = Sury.$schema(s => ({
480
+ orderId: s.m(Sury.string),
481
+ productId: s.m(Sury.string),
482
+ productIds: s.m(Sury.array(Sury.string))
483
+ }));
484
+
485
+ function entries(resolvedFields, targetPermission) {
486
+ return [
487
+ {
488
+ singleFieldName: "Catalog_Order",
489
+ listFieldName: "Catalog_Orders",
490
+ returnTypeName: "Catalog_Order",
491
+ stateSchema: orderSchema,
492
+ authorization: undefined,
493
+ resolvedFields: resolvedFields
494
+ },
495
+ {
496
+ singleFieldName: "Catalog_Product",
497
+ listFieldName: "Catalog_Products",
498
+ returnTypeName: "Catalog_Product",
499
+ stateSchema: productSchema,
500
+ authorization: undefined,
501
+ permission: targetPermission
502
+ }
503
+ ];
504
+ }
505
+
506
+ let bothFields = [
507
+ {
508
+ fieldName: "product",
509
+ typeName: "Catalog_Product",
510
+ multi: false
511
+ },
512
+ {
513
+ fieldName: "products",
514
+ typeName: "Catalog_Product",
515
+ multi: true
516
+ }
517
+ ];
518
+
519
+ let ResolvedFields = {
520
+ productSchema: productSchema,
521
+ orderSchema: orderSchema,
522
+ entries: entries,
523
+ bothFields: bothFields
524
+ };
525
+
526
+ globalThis.describe("@resolves / @resolvesMany reach the SDL", () => {
527
+ let refused = f => {
528
+ try {
529
+ f();
530
+ return false;
531
+ } catch (exn) {
532
+ return true;
533
+ }
534
+ };
535
+ let orderType = (resolvedFields, targetPermission) => Stdlib_Option.getOr(GraphQL_Stitcher$ReventlessCore.decode(GraphQL_FragmentGenerator$ReventlessCore.generate([], entries(resolvedFields, targetPermission))).types.find(t => t.startsWith("type Catalog_Order ")), "");
536
+ globalThis.test("the single form returns the target's type", () => {
537
+ globalThis.expect(orderType(bothFields, undefined).includes("product: Catalog_Product")).toBe(true);
538
+ });
539
+ globalThis.test("the batch form returns a list of the target's type", () => {
540
+ globalThis.expect(orderType(bothFields, undefined).includes("products: [Catalog_Product!]")).toBe(true);
541
+ });
542
+ globalThis.test("the state's own fields are untouched", () => {
543
+ let sdl = orderType(bothFields, undefined);
544
+ globalThis.expect([
545
+ sdl.includes("orderId:"),
546
+ sdl.includes("id: ID!")
547
+ ]).toEqual([
548
+ true,
549
+ true
550
+ ]);
551
+ });
552
+ globalThis.test("a view that declares none gets no extra field", () => {
553
+ globalThis.expect(orderType(undefined, undefined).includes("Catalog_Product")).toBe(false);
554
+ });
555
+ globalThis.test("a target this plugin does not expose is refused", () => {
556
+ globalThis.expect(refused(() => orderType([{
557
+ fieldName: "supplier",
558
+ typeName: "Supply_Supplier",
559
+ multi: false
560
+ }], undefined))).toBe(true);
561
+ });
562
+ globalThis.test("a target guarded differently from the parent is refused", () => {
563
+ globalThis.expect(refused(() => orderType(bothFields, {
564
+ TAG: "AllowGroups",
565
+ _0: ["Admin"]
566
+ }))).toBe(true);
567
+ });
568
+ globalThis.test("a target open to everyone is allowed under an authenticated parent", () => {
569
+ globalThis.expect(refused(() => orderType(bothFields, "AllowAnonymous"))).toBe(false);
570
+ });
571
+ globalThis.test("a resolved field colliding with a state field is refused", () => {
572
+ globalThis.expect(refused(() => orderType([{
573
+ fieldName: "productId",
574
+ typeName: "Catalog_Product",
575
+ multi: false
576
+ }], undefined))).toBe(true);
577
+ });
578
+ });
579
+
474
580
  export {
475
581
  OrderSliceCmd,
476
582
  mutationFor,
@@ -482,5 +588,6 @@ export {
482
588
  RefDoorRow,
483
589
  queryFor,
484
590
  refDoorFragment,
591
+ ResolvedFields,
485
592
  }
486
593
  /* commandSchema Not a pure module */