@reventlessdev/reventless-local 3.0.0-alpha.233 → 3.0.0-alpha.234
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/CHANGELOG.md +8 -0
- package/package.json +12 -12
- package/src/adapter/DomainGraphQL_Server.res +36 -0
- package/src/adapter/DomainGraphQL_Server.res.mjs +40 -1
- package/src/adapter/GraphQL_Server.res.mjs +6 -0
- package/src/adapter/PlatformGraphQL_Server.res +5 -0
- package/src/adapter/PlatformGraphQL_Server.res.mjs +4 -0
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +171 -2
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs +118 -1
- package/tests/adapter/QueryDbResolvedFieldsTest.res +253 -0
- package/tests/adapter/QueryDbResolvedFieldsTest.res.mjs +291 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 3.0.0-alpha.234 (2026-08-21)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **core:** make @resolves/[@resolves](https://github.com/resolves)Many work end to end ([4c52957](https://github.com/ReventlessDev/reventless-core/commit/4c5295759dcb4b3eda4f0f4f2c1bc387fed88fcb))
|
|
11
|
+
* **deps:** update sury to 11.0.0-rc.2 to fix unreachable union constructors ([fa5744f](https://github.com/ReventlessDev/reventless-core/commit/fa5744fed8de975e2f14725c856c6e5ce7d04a74))
|
|
12
|
+
|
|
13
|
+
|
|
6
14
|
# 3.0.0-alpha.233 (2026-08-20)
|
|
7
15
|
|
|
8
16
|
### Features
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-local",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.234",
|
|
4
4
|
"description": "Local platform for Reventless (in-memory or SQLite backend, for development and testing without AWS)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"bin": {
|
|
@@ -32,25 +32,25 @@
|
|
|
32
32
|
"graphql": "^16.13.2",
|
|
33
33
|
"graphql-ws": "^5.16.0",
|
|
34
34
|
"graphql-yoga": "^5.21.0",
|
|
35
|
-
"sury": "11.0.0-rc.
|
|
35
|
+
"sury": "11.0.0-rc.2",
|
|
36
36
|
"ws": "^8.18.0",
|
|
37
|
-
"@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.27",
|
|
38
37
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
39
|
-
"@reventlessdev/rescript-
|
|
40
|
-
"@reventlessdev/rescript-node": "2.0.0-alpha.8",
|
|
38
|
+
"@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.27",
|
|
41
39
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
40
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.8",
|
|
41
|
+
"@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
|
|
42
42
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
43
|
-
"@reventlessdev/reventless-
|
|
44
|
-
"@reventlessdev/reventless-
|
|
43
|
+
"@reventlessdev/reventless-graphql-server": "1.0.0-alpha.94",
|
|
44
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.246",
|
|
45
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.149",
|
|
46
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.110",
|
|
45
47
|
"@reventlessdev/reventless-seed": "1.0.0-alpha.17",
|
|
46
|
-
"@reventlessdev/reventless-
|
|
47
|
-
"@reventlessdev/reventless-
|
|
48
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.148",
|
|
49
|
-
"@reventlessdev/reventless-graphql-server": "1.0.0-alpha.93"
|
|
48
|
+
"@reventlessdev/reventless-gwt": "1.0.0-alpha.193",
|
|
49
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.121"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"rescript": "12.3.0",
|
|
53
|
-
"sury-ppx": "11.0.0-rc.
|
|
53
|
+
"sury-ppx": "11.0.0-rc.2",
|
|
54
54
|
"@reventlessdev/reventless-ppx": "1.0.0-alpha.72"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
@@ -347,6 +347,10 @@ type bucket = {
|
|
|
347
347
|
mutationResolvers: dict<resolverFn>,
|
|
348
348
|
queryResolvers: dict<resolverFn>,
|
|
349
349
|
subscriptionResolvers: dict<resolverFn>,
|
|
350
|
+
// Object-type field resolvers, keyed type name → field name. Carries the
|
|
351
|
+
// cross-table fields `@resolves` / `@resolvesMany` add to a queryable; their
|
|
352
|
+
// SDL rides the plugin's schema fragment, so only the map lives here.
|
|
353
|
+
fieldResolvers: dict<dict<resolverFn>>,
|
|
350
354
|
}
|
|
351
355
|
|
|
352
356
|
let makeBucket = (~scope: string): bucket => {
|
|
@@ -363,6 +367,7 @@ let makeBucket = (~scope: string): bucket => {
|
|
|
363
367
|
mutationResolvers: Dict.make(),
|
|
364
368
|
queryResolvers: Dict.make(),
|
|
365
369
|
subscriptionResolvers: Dict.make(),
|
|
370
|
+
fieldResolvers: Dict.make(),
|
|
366
371
|
}
|
|
367
372
|
|
|
368
373
|
// Insertion order = composition order (platform bucket is forced first in
|
|
@@ -420,6 +425,15 @@ let relabelScope = (~from: string, ~to_: string) =>
|
|
|
420
425
|
b.subscriptionResolvers
|
|
421
426
|
->Dict.toArray
|
|
422
427
|
->Array.forEach(((k, v)) => existing.subscriptionResolvers->Dict.set(k, v))
|
|
428
|
+
b.fieldResolvers
|
|
429
|
+
->Dict.toArray
|
|
430
|
+
->Array.forEach(((typeName, byField)) =>
|
|
431
|
+
switch existing.fieldResolvers->Dict.get(typeName) {
|
|
432
|
+
| Some(target) =>
|
|
433
|
+
byField->Dict.toArray->Array.forEach(((k, v)) => target->Dict.set(k, v))
|
|
434
|
+
| None => existing.fieldResolvers->Dict.set(typeName, byField)
|
|
435
|
+
}
|
|
436
|
+
)
|
|
423
437
|
}
|
|
424
438
|
if currentScope.contents == from {
|
|
425
439
|
currentScope.contents = to_
|
|
@@ -453,6 +467,24 @@ let registerSubscriptions = (~sdlFields: array<string>, ~resolvers: dict<resolve
|
|
|
453
467
|
resolvers->Dict.toArray->Array.forEach(((k, v)) => b.subscriptionResolvers->Dict.set(k, v))
|
|
454
468
|
}
|
|
455
469
|
|
|
470
|
+
let registerFieldResolvers = (~typeName: string, ~resolvers: dict<resolverFn>) => {
|
|
471
|
+
let b = currentBucket()
|
|
472
|
+
let forType = switch b.fieldResolvers->Dict.get(typeName) {
|
|
473
|
+
| Some(d) => d
|
|
474
|
+
| None =>
|
|
475
|
+
let d = Dict.make()
|
|
476
|
+
b.fieldResolvers->Dict.set(typeName, d)
|
|
477
|
+
d
|
|
478
|
+
}
|
|
479
|
+
resolvers->Dict.toArray->Array.forEach(((k, v)) => forType->Dict.set(k, v))
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Look up a registered object-type field resolver. Searches all scope buckets. */
|
|
483
|
+
let getFieldResolver = (~typeName: string, fieldName: string): option<resolverFn> =>
|
|
484
|
+
buckets.contents
|
|
485
|
+
->Dict.valuesToArray
|
|
486
|
+
->Array.findMap(b => b.fieldResolvers->Dict.get(typeName)->Option.flatMap(d => d->Dict.get(fieldName)))
|
|
487
|
+
|
|
456
488
|
/** Look up a registered mutation resolver by field name (used by MCP_Server).
|
|
457
489
|
Searches all scope buckets. */
|
|
458
490
|
let getMutationResolver = (fieldName: string): option<resolverFn> =>
|
|
@@ -620,6 +652,9 @@ let scopeResolverMap = (b: bucket, ~withRootDefaults: bool): dict<dict<resolverF
|
|
|
620
652
|
if b.subscriptionResolvers->Dict.keysToArray->Array.length > 0 {
|
|
621
653
|
resolvers->Dict.set("Subscription", b.subscriptionResolvers)
|
|
622
654
|
}
|
|
655
|
+
b.fieldResolvers
|
|
656
|
+
->Dict.toArray
|
|
657
|
+
->Array.forEach(((typeName, byField)) => resolvers->Dict.set(typeName, byField))
|
|
623
658
|
resolvers
|
|
624
659
|
}
|
|
625
660
|
|
|
@@ -913,6 +948,7 @@ let asInterface: ReventlessGraphqlServer.GraphQL_ServerInstance.t = {
|
|
|
913
948
|
registerQueries,
|
|
914
949
|
registerSubscriptions,
|
|
915
950
|
registerTypes,
|
|
951
|
+
registerFieldResolvers,
|
|
916
952
|
getMutationResolver,
|
|
917
953
|
getQueryResolver,
|
|
918
954
|
start,
|
|
@@ -252,7 +252,8 @@ function makeBucket(scope) {
|
|
|
252
252
|
typeDefinitions: scope === platformScope ? [] : GraphQL_Stitcher$ReventlessCore.relayBaseTypes.slice(),
|
|
253
253
|
mutationResolvers: {},
|
|
254
254
|
queryResolvers: {},
|
|
255
|
-
subscriptionResolvers: {}
|
|
255
|
+
subscriptionResolvers: {},
|
|
256
|
+
fieldResolvers: {}
|
|
256
257
|
};
|
|
257
258
|
}
|
|
258
259
|
|
|
@@ -316,6 +317,18 @@ function relabelScope(from, to_) {
|
|
|
316
317
|
Object.entries(b.subscriptionResolvers).forEach(param => {
|
|
317
318
|
existing.subscriptionResolvers[param[0]] = param[1];
|
|
318
319
|
});
|
|
320
|
+
Object.entries(b.fieldResolvers).forEach(param => {
|
|
321
|
+
let byField = param[1];
|
|
322
|
+
let typeName = param[0];
|
|
323
|
+
let target = existing.fieldResolvers[typeName];
|
|
324
|
+
if (target !== undefined) {
|
|
325
|
+
Object.entries(byField).forEach(param => {
|
|
326
|
+
target[param[0]] = param[1];
|
|
327
|
+
});
|
|
328
|
+
} else {
|
|
329
|
+
existing.fieldResolvers[typeName] = byField;
|
|
330
|
+
}
|
|
331
|
+
});
|
|
319
332
|
} else {
|
|
320
333
|
buckets.contents[to_] = b;
|
|
321
334
|
}
|
|
@@ -354,6 +367,26 @@ function registerSubscriptions(sdlFields, resolvers) {
|
|
|
354
367
|
});
|
|
355
368
|
}
|
|
356
369
|
|
|
370
|
+
function registerFieldResolvers(typeName, resolvers) {
|
|
371
|
+
let b = bucketFor(currentScope.contents);
|
|
372
|
+
let d = b.fieldResolvers[typeName];
|
|
373
|
+
let forType;
|
|
374
|
+
if (d !== undefined) {
|
|
375
|
+
forType = d;
|
|
376
|
+
} else {
|
|
377
|
+
let d$1 = {};
|
|
378
|
+
b.fieldResolvers[typeName] = d$1;
|
|
379
|
+
forType = d$1;
|
|
380
|
+
}
|
|
381
|
+
Object.entries(resolvers).forEach(param => {
|
|
382
|
+
forType[param[0]] = param[1];
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function getFieldResolver(typeName, fieldName) {
|
|
387
|
+
return Stdlib_Array.findMap(Object.values(buckets.contents), b => Stdlib_Option.flatMap(b.fieldResolvers[typeName], d => d[fieldName]));
|
|
388
|
+
}
|
|
389
|
+
|
|
357
390
|
function getMutationResolver(fieldName) {
|
|
358
391
|
return Stdlib_Array.findMap(Object.values(buckets.contents), b => b.mutationResolvers[fieldName]);
|
|
359
392
|
}
|
|
@@ -486,6 +519,9 @@ function scopeResolverMap(b, withRootDefaults) {
|
|
|
486
519
|
if (Object.keys(b.subscriptionResolvers).length !== 0) {
|
|
487
520
|
resolvers["Subscription"] = b.subscriptionResolvers;
|
|
488
521
|
}
|
|
522
|
+
Object.entries(b.fieldResolvers).forEach(param => {
|
|
523
|
+
resolvers[param[0]] = param[1];
|
|
524
|
+
});
|
|
489
525
|
return resolvers;
|
|
490
526
|
}
|
|
491
527
|
|
|
@@ -749,6 +785,7 @@ let asInterface = {
|
|
|
749
785
|
registerQueries: registerQueries,
|
|
750
786
|
registerSubscriptions: registerSubscriptions,
|
|
751
787
|
registerTypes: registerTypes,
|
|
788
|
+
registerFieldResolvers: registerFieldResolvers,
|
|
752
789
|
getMutationResolver: getMutationResolver,
|
|
753
790
|
getQueryResolver: getQueryResolver,
|
|
754
791
|
start: start,
|
|
@@ -798,6 +835,8 @@ export {
|
|
|
798
835
|
registerMutations,
|
|
799
836
|
registerQueries,
|
|
800
837
|
registerSubscriptions,
|
|
838
|
+
registerFieldResolvers,
|
|
839
|
+
getFieldResolver,
|
|
801
840
|
getMutationResolver,
|
|
802
841
|
getQueryResolver,
|
|
803
842
|
registerTypes,
|
|
@@ -62,6 +62,10 @@ let registerQueries = DomainGraphQL_Server$ReventlessLocal.registerQueries;
|
|
|
62
62
|
|
|
63
63
|
let registerSubscriptions = DomainGraphQL_Server$ReventlessLocal.registerSubscriptions;
|
|
64
64
|
|
|
65
|
+
let registerFieldResolvers = DomainGraphQL_Server$ReventlessLocal.registerFieldResolvers;
|
|
66
|
+
|
|
67
|
+
let getFieldResolver = DomainGraphQL_Server$ReventlessLocal.getFieldResolver;
|
|
68
|
+
|
|
65
69
|
let getMutationResolver = DomainGraphQL_Server$ReventlessLocal.getMutationResolver;
|
|
66
70
|
|
|
67
71
|
let getQueryResolver = DomainGraphQL_Server$ReventlessLocal.getQueryResolver;
|
|
@@ -157,6 +161,8 @@ export {
|
|
|
157
161
|
registerMutations,
|
|
158
162
|
registerQueries,
|
|
159
163
|
registerSubscriptions,
|
|
164
|
+
registerFieldResolvers,
|
|
165
|
+
getFieldResolver,
|
|
160
166
|
getMutationResolver,
|
|
161
167
|
getQueryResolver,
|
|
162
168
|
registerTypes,
|
|
@@ -48,6 +48,10 @@ let registerQueries = (~sdlFields, ~resolvers) =>
|
|
|
48
48
|
instance.registerQueries(~sdlFields, ~resolvers=wrapAdmin(resolvers))
|
|
49
49
|
let registerSubscriptions = instance.registerSubscriptions
|
|
50
50
|
let registerTypes = instance.registerTypes
|
|
51
|
+
// Not admin-wrapped: a field resolver is only reached through a parent field
|
|
52
|
+
// that already met the group check, so wrapping would re-refuse a caller the
|
|
53
|
+
// root already admitted.
|
|
54
|
+
let registerFieldResolvers = instance.registerFieldResolvers
|
|
51
55
|
let getMutationResolver = instance.getMutationResolver
|
|
52
56
|
let getQueryResolver = instance.getQueryResolver
|
|
53
57
|
let start = instance.start
|
|
@@ -67,6 +71,7 @@ let asInterface: ReventlessGraphqlServer.GraphQL_ServerInstance.t = {
|
|
|
67
71
|
registerQueries,
|
|
68
72
|
registerSubscriptions,
|
|
69
73
|
registerTypes,
|
|
74
|
+
registerFieldResolvers,
|
|
70
75
|
getMutationResolver,
|
|
71
76
|
getQueryResolver,
|
|
72
77
|
start,
|
|
@@ -25,6 +25,8 @@ let registerSubscriptions = instance.registerSubscriptions;
|
|
|
25
25
|
|
|
26
26
|
let registerTypes = instance.registerTypes;
|
|
27
27
|
|
|
28
|
+
let registerFieldResolvers = instance.registerFieldResolvers;
|
|
29
|
+
|
|
28
30
|
let getMutationResolver = instance.getMutationResolver;
|
|
29
31
|
|
|
30
32
|
let getQueryResolver = instance.getQueryResolver;
|
|
@@ -50,6 +52,7 @@ let asInterface = {
|
|
|
50
52
|
registerQueries: registerQueries,
|
|
51
53
|
registerSubscriptions: registerSubscriptions,
|
|
52
54
|
registerTypes: registerTypes,
|
|
55
|
+
registerFieldResolvers: registerFieldResolvers,
|
|
53
56
|
getMutationResolver: getMutationResolver,
|
|
54
57
|
getQueryResolver: getQueryResolver,
|
|
55
58
|
start: start,
|
|
@@ -69,6 +72,7 @@ export {
|
|
|
69
72
|
registerQueries,
|
|
70
73
|
registerSubscriptions,
|
|
71
74
|
registerTypes,
|
|
75
|
+
registerFieldResolvers,
|
|
72
76
|
getMutationResolver,
|
|
73
77
|
getQueryResolver,
|
|
74
78
|
start,
|
|
@@ -72,8 +72,8 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
72
72
|
~dataSourceName as _,
|
|
73
73
|
~indexes,
|
|
74
74
|
~subIdField,
|
|
75
|
-
~idResolverConfigs
|
|
76
|
-
~idsResolverConfigs
|
|
75
|
+
~idResolverConfigs,
|
|
76
|
+
~idsResolverConfigs,
|
|
77
77
|
~authorization,
|
|
78
78
|
~opts as _,
|
|
79
79
|
) => {
|
|
@@ -913,6 +913,175 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
913
913
|
},
|
|
914
914
|
)
|
|
915
915
|
|
|
916
|
+
// -- Cross-table fields: @resolves / @resolvesMany -------------------------
|
|
917
|
+
// Field resolvers on this view's object type, following a foreign key into
|
|
918
|
+
// another queryable's rows. Their SDL rides the plugin's schema fragment
|
|
919
|
+
// (`GraphQL_FragmentGenerator` emits it from the same config), so only the
|
|
920
|
+
// resolvers are registered here.
|
|
921
|
+
//
|
|
922
|
+
// The rows are the TARGET's, so the target's `@owner` / `@retired` rules
|
|
923
|
+
// narrow them — the same reading the AppSync resolver bakes into its
|
|
924
|
+
// response. A nested field takes no `includeRetired` argument, so a retired
|
|
925
|
+
// row never travels through one; `{list}Refs` is the door that names those.
|
|
926
|
+
let targetAllows = (~ctx, ~target: string, item: JSON.t): bool => {
|
|
927
|
+
let schema = Plugin_Helpers.stateSchemaRegistry->Dict.get(target)
|
|
928
|
+
let cell = field => item->JSON.Decode.object->Option.flatMap(d => d->Dict.get(field))
|
|
929
|
+
let identity = extractIdentity(ctx)
|
|
930
|
+
let ownerOk = switch identity->Reventless.OwnerScope.decide(
|
|
931
|
+
~ownerField=schema->Option.flatMap(s => Reventless.Owner.fieldNames(s)->Array.get(0)),
|
|
932
|
+
) {
|
|
933
|
+
| Unscoped => true
|
|
934
|
+
| RefuseOwned => false
|
|
935
|
+
| ScopeTo(field, required) =>
|
|
936
|
+
cell(field)->Option.flatMap(JSON.Decode.string)->Option.mapOr(false, v => v == required)
|
|
937
|
+
}
|
|
938
|
+
let retiredSpec =
|
|
939
|
+
schema
|
|
940
|
+
->Option.flatMap(Reventless.StateAnnotations.getSpec)
|
|
941
|
+
->Option.flatMap(spec => spec.retired)
|
|
942
|
+
let retiredOk = switch identity
|
|
943
|
+
->Reventless.OwnerScope.decideRetired(
|
|
944
|
+
~retiredField=retiredSpec->Option.map(r => r.field),
|
|
945
|
+
~retiredValues=?retiredSpec->Option.flatMap(r => r.values),
|
|
946
|
+
~asked=false,
|
|
947
|
+
)
|
|
948
|
+
->Reventless.OwnerScope.retiredScopeOf {
|
|
949
|
+
| None => true
|
|
950
|
+
| Some(scope) => !(scope->Reventless.OwnerScope.isRetiredValue(cell(scope.field)))
|
|
951
|
+
}
|
|
952
|
+
ownerOk && retiredOk
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// The storage key the row was loaded by, reported as `id` — the nested type
|
|
956
|
+
// is a `Node`, so the field is non-null, and `loadStream` hands back the
|
|
957
|
+
// stored value without one.
|
|
958
|
+
let withId = (~key: string, item: JSON.t): JSON.t => {
|
|
959
|
+
let obj = item->JSON.Decode.object->Option.mapOr(Dict.make(), Dict.copy)
|
|
960
|
+
obj->Dict.set("id", JSON.Encode.string(key))
|
|
961
|
+
JSON.Encode.object(obj)
|
|
962
|
+
}
|
|
963
|
+
let loadFrom = async (~target: string, key: string): array<JSON.t> =>
|
|
964
|
+
switch Bus.getQueryDb(target) {
|
|
965
|
+
| Some(ops) =>
|
|
966
|
+
await ops.loadStream(key)
|
|
967
|
+
->Stream.runCollect
|
|
968
|
+
->Effect.catchAll(_ => Effect.succeed([]))
|
|
969
|
+
->Effect.runPromise
|
|
970
|
+
| None => []
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
let lookupByIndexIn = (~target: string, ~field: string, value: string): array<JSON.t> =>
|
|
974
|
+
switch Bus.getQueryDbIndexLookup(target) {
|
|
975
|
+
| Some(lookup) => lookup(field, value)
|
|
976
|
+
| None =>
|
|
977
|
+
switch Bus.getQueryDbScan(target) {
|
|
978
|
+
| Some(scanAll) =>
|
|
979
|
+
scanAll()->Array.filter(item =>
|
|
980
|
+
item
|
|
981
|
+
->JSON.Decode.object
|
|
982
|
+
->Option.flatMap(d => d->Dict.get(field))
|
|
983
|
+
->Option.flatMap(JSON.Decode.string)
|
|
984
|
+
->Option.mapOr(false, v => v == value)
|
|
985
|
+
)
|
|
986
|
+
| None => []
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
let sourceField = (root: JSON.t, field: string): option<JSON.t> =>
|
|
991
|
+
root->JSON.Decode.object->Option.flatMap(d => d->Dict.get(field))
|
|
992
|
+
|
|
993
|
+
let fieldResolvers = Dict.make()
|
|
994
|
+
|
|
995
|
+
idResolverConfigs->Array.forEach((config: Reventless.ReadModel.idResolverConfig) => {
|
|
996
|
+
let {source: {idField, subId, resolvedField}, target} = config
|
|
997
|
+
let (fieldName, multi) = switch resolvedField {
|
|
998
|
+
| Single(f) => (f, false)
|
|
999
|
+
| Multi(f) => (f, true)
|
|
1000
|
+
}
|
|
1001
|
+
let targetName = target.tableName
|
|
1002
|
+
let resolver: ReventlessGraphqlServer.GraphQL_ServerInstance.resolverFn = async (
|
|
1003
|
+
root,
|
|
1004
|
+
args,
|
|
1005
|
+
ctx,
|
|
1006
|
+
) => {
|
|
1007
|
+
let key =
|
|
1008
|
+
root->sourceField(idField)->Option.flatMap(JSON.Decode.string)->Option.getOr("")
|
|
1009
|
+
let rows = if key == "" {
|
|
1010
|
+
[]
|
|
1011
|
+
} else {
|
|
1012
|
+
switch target.idField {
|
|
1013
|
+
| Id => (await loadFrom(~target=targetName, key))->Array.map(withId(~key, ...))
|
|
1014
|
+
| Index(index) => lookupByIndexIn(~target=targetName, ~field=index, key)
|
|
1015
|
+
| IndexWithId(_, targetIdField) =>
|
|
1016
|
+
lookupByIndexIn(~target=targetName, ~field=targetIdField, key)
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
// The target's sort key, taken from the parent row or from an argument on
|
|
1020
|
+
// the field — the same two sources the AppSync template reads.
|
|
1021
|
+
let narrowed = switch (subId, target.subIdField) {
|
|
1022
|
+
| (Field(sourceSortField), Some(targetSortField)) =>
|
|
1023
|
+
let want = root->sourceField(sourceSortField)->Option.flatMap(JSON.Decode.string)
|
|
1024
|
+
rows->Array.filter(item =>
|
|
1025
|
+
item
|
|
1026
|
+
->JSON.Decode.object
|
|
1027
|
+
->Option.flatMap(d => d->Dict.get(targetSortField))
|
|
1028
|
+
->Option.flatMap(JSON.Decode.string) == want
|
|
1029
|
+
)
|
|
1030
|
+
| (Argument(sortArgument), Some(targetSortField)) =>
|
|
1031
|
+
switch args->JSON.Decode.object->Option.flatMap(d => d->Dict.get(sortArgument)) {
|
|
1032
|
+
| None => rows
|
|
1033
|
+
| Some(want) =>
|
|
1034
|
+
rows->Array.filter(item =>
|
|
1035
|
+
item
|
|
1036
|
+
->JSON.Decode.object
|
|
1037
|
+
->Option.flatMap(d => d->Dict.get(targetSortField))
|
|
1038
|
+
->Option.flatMap(JSON.Decode.string) == want->JSON.Decode.string
|
|
1039
|
+
)
|
|
1040
|
+
}
|
|
1041
|
+
| _ => rows
|
|
1042
|
+
}
|
|
1043
|
+
let allowed = narrowed->Array.filter(item => targetAllows(~ctx, ~target=targetName, item))
|
|
1044
|
+
if multi {
|
|
1045
|
+
allowed->JSON.Encode.array
|
|
1046
|
+
} else {
|
|
1047
|
+
allowed->Array.get(0)->Option.getOr(JSON.Encode.null)
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
fieldResolvers->Dict.set(fieldName, resolver)
|
|
1051
|
+
})
|
|
1052
|
+
|
|
1053
|
+
idsResolverConfigs->Array.forEach((config: Reventless.ReadModel.idsResolverConfig) => {
|
|
1054
|
+
let {source: {idsField, resolvedField}, target} = config
|
|
1055
|
+
let targetName = target.tableName
|
|
1056
|
+
let resolver: ReventlessGraphqlServer.GraphQL_ServerInstance.resolverFn = async (
|
|
1057
|
+
root,
|
|
1058
|
+
_args,
|
|
1059
|
+
ctx,
|
|
1060
|
+
) => {
|
|
1061
|
+
let ids =
|
|
1062
|
+
root
|
|
1063
|
+
->sourceField(idsField)
|
|
1064
|
+
->Option.flatMap(JSON.Decode.array)
|
|
1065
|
+
->Option.getOr([])
|
|
1066
|
+
->Array.filterMap(JSON.Decode.string)
|
|
1067
|
+
// Missing ids drop out rather than becoming nulls, matching BatchGetItem
|
|
1068
|
+
// and the by-ids door built on it.
|
|
1069
|
+
let rows = await ids->Array.map(async key =>
|
|
1070
|
+
(await loadFrom(~target=targetName, key))->Array.get(0)->Option.map(withId(~key, ...))
|
|
1071
|
+
)->Promise.all
|
|
1072
|
+
rows
|
|
1073
|
+
->Array.filterMap(row =>
|
|
1074
|
+
row->Option.filter(item => targetAllows(~ctx, ~target=targetName, item))
|
|
1075
|
+
)
|
|
1076
|
+
->JSON.Encode.array
|
|
1077
|
+
}
|
|
1078
|
+
fieldResolvers->Dict.set(resolvedField, resolver)
|
|
1079
|
+
})
|
|
1080
|
+
|
|
1081
|
+
if fieldResolvers->Dict.keysToArray->Array.length > 0 {
|
|
1082
|
+
server.registerFieldResolvers(~typeName=returnTypeName, ~resolvers=fieldResolvers)
|
|
1083
|
+
}
|
|
1084
|
+
|
|
916
1085
|
// -- Register all fields --------------------------------------------------
|
|
917
1086
|
let allSdl =
|
|
918
1087
|
[byIdSdl]
|
|
@@ -8,6 +8,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
|
8
8
|
import * as Effect from "effect/Effect";
|
|
9
9
|
import * as Stdlib_Nullable from "@rescript/runtime/lib/es6/Stdlib_Nullable.js";
|
|
10
10
|
import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
|
|
11
|
+
import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
|
|
11
12
|
import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
|
|
12
13
|
import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
|
|
13
14
|
import * as Api_Ids$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/Api_Ids.res.mjs";
|
|
@@ -54,7 +55,7 @@ function Make(Bus) {
|
|
|
54
55
|
nodeTypeRegistry: DomainGraphQL_Server$ReventlessLocal.nodeTypeRegistry
|
|
55
56
|
}
|
|
56
57
|
};
|
|
57
|
-
let make = (name, param, param$1, param$2, indexes, subIdField,
|
|
58
|
+
let make = (name, param, param$1, param$2, indexes, subIdField, idResolverConfigs, idsResolverConfigs, authorization, param$3) => {
|
|
58
59
|
let server = serverRef.contents;
|
|
59
60
|
let relay = relayRef.contents;
|
|
60
61
|
if (relay !== undefined) {
|
|
@@ -617,6 +618,122 @@ function Make(Bus) {
|
|
|
617
618
|
resolver
|
|
618
619
|
];
|
|
619
620
|
});
|
|
621
|
+
let targetAllows = (ctx, target, item) => {
|
|
622
|
+
let schema = Plugin_Helpers$ReventlessCore.stateSchemaRegistry[target];
|
|
623
|
+
let cell = field => Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[field]);
|
|
624
|
+
let identity = extractIdentity(ctx);
|
|
625
|
+
let match = OwnerScope$Reventless.decide(identity, Stdlib_Option.flatMap(schema, s => Owner$Reventless.fieldNames(s)[0]), undefined);
|
|
626
|
+
let ownerOk;
|
|
627
|
+
if (typeof match !== "object") {
|
|
628
|
+
ownerOk = match === "Unscoped";
|
|
629
|
+
} else {
|
|
630
|
+
let required = match._1;
|
|
631
|
+
ownerOk = Stdlib_Option.mapOr(Stdlib_Option.flatMap(cell(match._0), Stdlib_JSON.Decode.string), false, v => v === required);
|
|
632
|
+
}
|
|
633
|
+
let retiredSpec = Stdlib_Option.flatMap(Stdlib_Option.flatMap(schema, StateAnnotations$Reventless.getSpec), spec => spec.retired);
|
|
634
|
+
let scope = OwnerScope$Reventless.retiredScopeOf(OwnerScope$Reventless.decideRetired(identity, Stdlib_Option.map(retiredSpec, r => r.field), Stdlib_Option.flatMap(retiredSpec, r => r.values), false, undefined));
|
|
635
|
+
let retiredOk = scope !== undefined ? !OwnerScope$Reventless.isRetiredValue(scope, cell(scope.field)) : true;
|
|
636
|
+
if (ownerOk) {
|
|
637
|
+
return retiredOk;
|
|
638
|
+
} else {
|
|
639
|
+
return false;
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
let withId = (key, item) => {
|
|
643
|
+
let obj = Stdlib_Option.mapOr(Stdlib_JSON.Decode.object(item), {}, prim => Object.assign({}, prim));
|
|
644
|
+
obj["id"] = key;
|
|
645
|
+
return obj;
|
|
646
|
+
};
|
|
647
|
+
let loadFrom = async (target, key) => {
|
|
648
|
+
let ops = Bus.getQueryDb(target);
|
|
649
|
+
if (ops !== undefined) {
|
|
650
|
+
return await Effect.runPromise(Effect.catchAll(Stream.runCollect(ops.loadStream(key)), param => Effect.succeed([])));
|
|
651
|
+
} else {
|
|
652
|
+
return [];
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
let lookupByIndexIn = (target, field, value) => {
|
|
656
|
+
let lookup = Bus.getQueryDbIndexLookup(target);
|
|
657
|
+
if (lookup !== undefined) {
|
|
658
|
+
return lookup(field, value);
|
|
659
|
+
}
|
|
660
|
+
let scanAll = Bus.getQueryDbScan(target);
|
|
661
|
+
if (scanAll !== undefined) {
|
|
662
|
+
return scanAll().filter(item => Stdlib_Option.mapOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[field]), Stdlib_JSON.Decode.string), false, v => v === value));
|
|
663
|
+
} else {
|
|
664
|
+
return [];
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
let sourceField = (root, field) => Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(root), d => d[field]);
|
|
668
|
+
let fieldResolvers = {};
|
|
669
|
+
idResolverConfigs.forEach(config => {
|
|
670
|
+
let target = config.target;
|
|
671
|
+
let match = config.source;
|
|
672
|
+
let resolvedField = match.resolvedField;
|
|
673
|
+
let subId = match.subId;
|
|
674
|
+
let idField = match.idField;
|
|
675
|
+
let match$1;
|
|
676
|
+
match$1 = resolvedField.TAG === "Single" ? [
|
|
677
|
+
resolvedField._0,
|
|
678
|
+
false
|
|
679
|
+
] : [
|
|
680
|
+
resolvedField._0,
|
|
681
|
+
true
|
|
682
|
+
];
|
|
683
|
+
let multi = match$1[1];
|
|
684
|
+
let targetName = target.tableName;
|
|
685
|
+
let resolver = async (root, args, ctx) => {
|
|
686
|
+
let key = Stdlib_Option.getOr(Stdlib_Option.flatMap(sourceField(root, idField), Stdlib_JSON.Decode.string), "");
|
|
687
|
+
let rows;
|
|
688
|
+
if (key === "") {
|
|
689
|
+
rows = [];
|
|
690
|
+
} else {
|
|
691
|
+
let index = target.idField;
|
|
692
|
+
rows = typeof index !== "object" ? (await loadFrom(targetName, key)).map(extra => withId(key, extra)) : (
|
|
693
|
+
index.TAG === "Index" ? lookupByIndexIn(targetName, index._0, key) : lookupByIndexIn(targetName, index._1, key)
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
let match = target.subIdField;
|
|
697
|
+
let narrowed;
|
|
698
|
+
if (typeof subId !== "object") {
|
|
699
|
+
narrowed = rows;
|
|
700
|
+
} else if (subId.TAG === "Field") {
|
|
701
|
+
if (match !== undefined) {
|
|
702
|
+
let want = Stdlib_Option.flatMap(sourceField(root, subId._0), Stdlib_JSON.Decode.string);
|
|
703
|
+
narrowed = rows.filter(item => Primitive_object.equal(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[match]), Stdlib_JSON.Decode.string), want));
|
|
704
|
+
} else {
|
|
705
|
+
narrowed = rows;
|
|
706
|
+
}
|
|
707
|
+
} else if (match !== undefined) {
|
|
708
|
+
let sortArgument = subId._0;
|
|
709
|
+
let want$1 = Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(args), d => d[sortArgument]);
|
|
710
|
+
narrowed = want$1 !== undefined ? rows.filter(item => Primitive_object.equal(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[match]), Stdlib_JSON.Decode.string), Stdlib_JSON.Decode.string(want$1))) : rows;
|
|
711
|
+
} else {
|
|
712
|
+
narrowed = rows;
|
|
713
|
+
}
|
|
714
|
+
let allowed = narrowed.filter(item => targetAllows(ctx, targetName, item));
|
|
715
|
+
if (multi) {
|
|
716
|
+
return allowed;
|
|
717
|
+
} else {
|
|
718
|
+
return Stdlib_Option.getOr(allowed[0], null);
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
fieldResolvers[match$1[0]] = resolver;
|
|
722
|
+
});
|
|
723
|
+
idsResolverConfigs.forEach(config => {
|
|
724
|
+
let match = config.source;
|
|
725
|
+
let idsField = match.idsField;
|
|
726
|
+
let targetName = config.target.tableName;
|
|
727
|
+
let resolver = async (root, _args, ctx) => {
|
|
728
|
+
let ids = Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(sourceField(root, idsField), Stdlib_JSON.Decode.array), []), Stdlib_JSON.Decode.string);
|
|
729
|
+
let rows = await Promise.all(ids.map(async key => Stdlib_Option.map((await loadFrom(targetName, key))[0], extra => withId(key, extra))));
|
|
730
|
+
return Stdlib_Array.filterMap(rows, row => Stdlib_Option.filter(row, item => targetAllows(ctx, targetName, item)));
|
|
731
|
+
};
|
|
732
|
+
fieldResolvers[match.resolvedField] = resolver;
|
|
733
|
+
});
|
|
734
|
+
if (Object.keys(fieldResolvers).length !== 0) {
|
|
735
|
+
server.registerFieldResolvers(returnTypeName, fieldResolvers);
|
|
736
|
+
}
|
|
620
737
|
let allSdl = [byIdSdl].concat(byIdsSdl).concat(refsSdl).concat(match[0]).concat(itemsSdl).concat(indexSdlFields);
|
|
621
738
|
let resolvers = {};
|
|
622
739
|
resolvers[singleQueryName] = byIdResolver;
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// Behavioural tests for the in-memory cross-table field resolvers
|
|
2
|
+
// (`@resolves` / `@resolvesMany`). An Orders view carries a foreign `productId`
|
|
3
|
+
// and a `productIds` array; both follow into a Products view's rows.
|
|
4
|
+
//
|
|
5
|
+
// The rows handed back are the TARGET's, so the target's `@retired` rule narrows
|
|
6
|
+
// them — the same reading the AppSync response bakes in. A nested field takes no
|
|
7
|
+
// `includeRetired` argument, so a retired row never travels through one.
|
|
8
|
+
|
|
9
|
+
@@warning("-44")
|
|
10
|
+
|
|
11
|
+
open JestGlobals
|
|
12
|
+
|
|
13
|
+
let _ = TestRunner.setup()
|
|
14
|
+
|
|
15
|
+
@schema
|
|
16
|
+
type productRow = {productId: string, name: string, archived: bool}
|
|
17
|
+
|
|
18
|
+
@schema
|
|
19
|
+
type orderRow = {orderId: string, productId: string, productIds: array<string>}
|
|
20
|
+
|
|
21
|
+
let annotations = (~retired: option<Reventless.StateAnnotations.retiredSpec>) => {
|
|
22
|
+
Reventless.StateAnnotations.ids: [],
|
|
23
|
+
compositeIds: [],
|
|
24
|
+
subIds: [],
|
|
25
|
+
compositeSubIds: [],
|
|
26
|
+
indexes: [],
|
|
27
|
+
hidden: [],
|
|
28
|
+
summary: [],
|
|
29
|
+
drillTargets: [],
|
|
30
|
+
drillTargetKeys: [],
|
|
31
|
+
collapsed: [],
|
|
32
|
+
scan: [],
|
|
33
|
+
scanSort: [],
|
|
34
|
+
semantic: [],
|
|
35
|
+
metric: [],
|
|
36
|
+
lifecycle: None,
|
|
37
|
+
groupBy: None,
|
|
38
|
+
visibility: None,
|
|
39
|
+
live: None,
|
|
40
|
+
retired,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let ctxFor = (~groups: array<string>): JSON.t =>
|
|
44
|
+
JSON.Encode.object(
|
|
45
|
+
Dict.fromArray([
|
|
46
|
+
(
|
|
47
|
+
"request",
|
|
48
|
+
JSON.Encode.object(Dict.fromArray([("headers", JSON.Encode.object(Dict.make()))])),
|
|
49
|
+
),
|
|
50
|
+
(
|
|
51
|
+
"identity",
|
|
52
|
+
(
|
|
53
|
+
{
|
|
54
|
+
...Reventless.Identity.anonymous,
|
|
55
|
+
userId: "test-user",
|
|
56
|
+
username: "test-user",
|
|
57
|
+
groups,
|
|
58
|
+
}: Reventless.Identity.t
|
|
59
|
+
)->Obj.magic,
|
|
60
|
+
),
|
|
61
|
+
]),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
let plainCtx = ctxFor(~groups=["User"])
|
|
65
|
+
|
|
66
|
+
let noArgs: JSON.t = JSON.Encode.object(Dict.make())
|
|
67
|
+
|
|
68
|
+
let getString = (json: JSON.t, key: string): option<string> =>
|
|
69
|
+
json->JSON.Decode.object->Option.flatMap(d => d->Dict.get(key))->Option.flatMap(JSON.Decode.string)
|
|
70
|
+
|
|
71
|
+
let names = (json: JSON.t): array<string> =>
|
|
72
|
+
json->JSON.Decode.array->Option.getOr([])->Array.filterMap(item => item->getString("name"))
|
|
73
|
+
|
|
74
|
+
// One Bus carrying both views, resolvers registered for the Orders view with the
|
|
75
|
+
// two configs the PPX writes from `@resolves` / `@resolvesMany`.
|
|
76
|
+
let buildFixture = async (~suffix: string, ~retired: option<Reventless.StateAnnotations.retiredSpec>) => {
|
|
77
|
+
module Bus = LocalBus.Make()
|
|
78
|
+
module Storage = LocalQueryDbStorage.Make(Bus)
|
|
79
|
+
module Resolvers = QueryDbResolvers_GraphQL.Make(Bus)
|
|
80
|
+
|
|
81
|
+
let productsName = "Products" ++ suffix
|
|
82
|
+
let ordersName = "Orders" ++ suffix
|
|
83
|
+
let orderTypeName = "Shop_Order" ++ suffix
|
|
84
|
+
|
|
85
|
+
module ProductsSpec = {
|
|
86
|
+
module Id = Reventless.Id.StringPure
|
|
87
|
+
let name = productsName
|
|
88
|
+
let moduleUrl: string = %raw(`import.meta.url`)
|
|
89
|
+
@schema
|
|
90
|
+
type state = productRow
|
|
91
|
+
let config = Reventless.ReadModel.config()
|
|
92
|
+
let subIdConfig = None
|
|
93
|
+
let authorization: Reventless.Authorization.permission = AllowAuthenticated
|
|
94
|
+
let visibility: Reventless.Visibility.t = Public
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module OrdersSpec = {
|
|
98
|
+
module Id = Reventless.Id.StringPure
|
|
99
|
+
let name = ordersName
|
|
100
|
+
let moduleUrl: string = %raw(`import.meta.url`)
|
|
101
|
+
@schema
|
|
102
|
+
type state = orderRow
|
|
103
|
+
let config = Reventless.ReadModel.config(
|
|
104
|
+
~idResolvers=[
|
|
105
|
+
{
|
|
106
|
+
source: {
|
|
107
|
+
Reventless.ReadModel.idField: "productId",
|
|
108
|
+
subId: NoSubId,
|
|
109
|
+
resolvedField: Single("product"),
|
|
110
|
+
},
|
|
111
|
+
target: {Reventless.ReadModel.tableName: productsName, idField: Id},
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
~idsResolvers=[
|
|
115
|
+
{
|
|
116
|
+
source: {Reventless.ReadModel.idsField: "productIds", resolvedField: "products"},
|
|
117
|
+
target: {Reventless.ReadModel.tableName: productsName},
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
)
|
|
121
|
+
let subIdConfig = None
|
|
122
|
+
let authorization: Reventless.Authorization.permission = AllowAuthenticated
|
|
123
|
+
let visibility: Reventless.Visibility.t = Public
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module NoResolvers = ReventlessCore.QueryDb_Adapter.NoResolvers(Storage)
|
|
127
|
+
module ProductsDb = ReventlessCore.QueryDb_Builder.Make(ProductsSpec, Storage, NoResolvers)
|
|
128
|
+
module OrdersDb = ReventlessCore.QueryDb_Builder.Make(OrdersSpec, Storage, NoResolvers)
|
|
129
|
+
|
|
130
|
+
let register = (~viewName, ~returnTypeName) =>
|
|
131
|
+
ReventlessCore.Plugin_Helpers.queryFieldNamesRegistry->Dict.set(
|
|
132
|
+
viewName,
|
|
133
|
+
{
|
|
134
|
+
singleFieldName: returnTypeName,
|
|
135
|
+
listFieldName: returnTypeName ++ "s",
|
|
136
|
+
returnTypeName,
|
|
137
|
+
pluralTypeName: returnTypeName ++ "s",
|
|
138
|
+
includeIdParam: true,
|
|
139
|
+
connectionSpec: true,
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
register(~viewName=productsName, ~returnTypeName="Shop_Product" ++ suffix)
|
|
143
|
+
register(~viewName=ordersName, ~returnTypeName=orderTypeName)
|
|
144
|
+
ReventlessCore.Plugin_Helpers.stateSchemaRegistry->Dict.set(
|
|
145
|
+
productsName,
|
|
146
|
+
productRowSchema
|
|
147
|
+
->S.Metadata.set(
|
|
148
|
+
~id=Reventless.StateAnnotations.stateAnnotationsId,
|
|
149
|
+
annotations(~retired),
|
|
150
|
+
)
|
|
151
|
+
->S.castToUnknown,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
let productsDb = ProductsDb.make(~api=(), ~apiRole=())
|
|
155
|
+
let productOps = await productsDb->ReventlessCore.Component.operations->TestRunner.resolve
|
|
156
|
+
let ordersDb = OrdersDb.make(~api=(), ~apiRole=())
|
|
157
|
+
let orderOps = await ordersDb->ReventlessCore.Component.operations->TestRunner.resolve
|
|
158
|
+
|
|
159
|
+
let _: ReventlessCore.QueryDb_Adapter.resolvers = Resolvers.make(
|
|
160
|
+
~name=ordersName,
|
|
161
|
+
~api=(),
|
|
162
|
+
~apiRole=(),
|
|
163
|
+
~dataSourceName=""->Pulumi.Output.make,
|
|
164
|
+
~indexes=[],
|
|
165
|
+
~subIdField=None,
|
|
166
|
+
~idResolverConfigs=OrdersSpec.config.idResolvers,
|
|
167
|
+
~idsResolverConfigs=OrdersSpec.config.idsResolvers,
|
|
168
|
+
~authorization=Reventless.Authorization.AllowAuthenticated,
|
|
169
|
+
~opts=({}: Pulumi.CustomResourceOptions.t),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
let _ = await productOps.save("p-1", {productId: "p-1", name: "Book", archived: false}, Init, None)
|
|
173
|
+
let _ = await productOps.save("p-2", {productId: "p-2", name: "Pen", archived: true}, Init, None)
|
|
174
|
+
let _ = await orderOps.save(
|
|
175
|
+
"o-1",
|
|
176
|
+
{orderId: "o-1", productId: "p-1", productIds: ["p-1", "gone", "p-2"]},
|
|
177
|
+
Init,
|
|
178
|
+
None,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
let resolverFor = field =>
|
|
182
|
+
switch DomainGraphQL_Server.getFieldResolver(~typeName=orderTypeName, field) {
|
|
183
|
+
| Some(r) => r
|
|
184
|
+
| None => JsError.throwWithMessage("field resolver not registered: " ++ field)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let order = JSON.Encode.object(
|
|
188
|
+
Dict.fromArray([
|
|
189
|
+
("orderId", JSON.Encode.string("o-1")),
|
|
190
|
+
("productId", JSON.Encode.string("p-1")),
|
|
191
|
+
(
|
|
192
|
+
"productIds",
|
|
193
|
+
["p-1", "gone", "p-2"]->Array.map(JSON.Encode.string)->JSON.Encode.array,
|
|
194
|
+
),
|
|
195
|
+
]),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
(resolverFor, order)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
describe("QueryDb cross-table field resolvers", () => {
|
|
202
|
+
beforeEach(() => {
|
|
203
|
+
DomainGraphQL_Server.reset()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
testPromise("@resolves follows the foreign key into the target's row", async () => {
|
|
207
|
+
let (resolverFor, order) = await buildFixture(~suffix="A", ~retired=None)
|
|
208
|
+
let product = await resolverFor("product")(order, noArgs, plainCtx)
|
|
209
|
+
expect((product->getString("name"), product->getString("id")))->toEqual((
|
|
210
|
+
Some("Book"),
|
|
211
|
+
Some("p-1"),
|
|
212
|
+
))
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
testPromise("@resolvesMany batch-follows the id array", async () => {
|
|
216
|
+
let (resolverFor, order) = await buildFixture(~suffix="B", ~retired=None)
|
|
217
|
+
let products = await resolverFor("products")(order, noArgs, plainCtx)
|
|
218
|
+
// "gone" names no row and drops out rather than becoming a null, matching
|
|
219
|
+
// BatchGetItem and the by-ids door built on it.
|
|
220
|
+
expect(products->names)->toEqual(["Book", "Pen"])
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
testPromise("a foreign key naming no row resolves to nothing", async () => {
|
|
224
|
+
let (resolverFor, _) = await buildFixture(~suffix="C", ~retired=None)
|
|
225
|
+
let orphan = JSON.Encode.object(
|
|
226
|
+
Dict.fromArray([("productId", JSON.Encode.string("nope"))]),
|
|
227
|
+
)
|
|
228
|
+
let product = await resolverFor("product")(orphan, noArgs, plainCtx)
|
|
229
|
+
expect(product)->toBe(JSON.Encode.null)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
// The narrowing is the target's: Products declares the retirement, Orders
|
|
233
|
+
// declares the field, and the caller reading through Orders is answered by
|
|
234
|
+
// the Products rule.
|
|
235
|
+
testPromise("a retired target row is withheld from both forms", async () => {
|
|
236
|
+
let retired: Reventless.StateAnnotations.retiredSpec = {
|
|
237
|
+
field: "archived",
|
|
238
|
+
label: "",
|
|
239
|
+
showWhenFalse: false,
|
|
240
|
+
values: None,
|
|
241
|
+
namedWhenRetired: false,
|
|
242
|
+
}
|
|
243
|
+
let (resolverFor, order) = await buildFixture(~suffix="D", ~retired=Some(retired))
|
|
244
|
+
let products = await resolverFor("products")(order, noArgs, plainCtx)
|
|
245
|
+
expect(products->names)->toEqual(["Book"])
|
|
246
|
+
|
|
247
|
+
let archivedOnly = JSON.Encode.object(
|
|
248
|
+
Dict.fromArray([("productId", JSON.Encode.string("p-2"))]),
|
|
249
|
+
)
|
|
250
|
+
let product = await resolverFor("product")(archivedOnly, noArgs, plainCtx)
|
|
251
|
+
expect(product)->toBe(JSON.Encode.null)
|
|
252
|
+
})
|
|
253
|
+
})
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Sury from "sury";
|
|
4
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
5
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
6
|
+
import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
|
|
7
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
8
|
+
import * as Pulumi from "@pulumi/pulumi";
|
|
9
|
+
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
10
|
+
import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
|
|
11
|
+
import * as ReadModel$Reventless from "@reventlessdev/reventless-spec/src/components/ReadModel.res.mjs";
|
|
12
|
+
import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/components/Component.res.mjs";
|
|
13
|
+
import * as LocalBus$ReventlessLocal from "../../src/adapter/LocalBus.res.mjs";
|
|
14
|
+
import * as TestRunner$ReventlessLocal from "../../src/test/TestRunner.res.mjs";
|
|
15
|
+
import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
|
|
16
|
+
import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_Helpers.res.mjs";
|
|
17
|
+
import * as QueryDb_Adapter$ReventlessCore from "@reventlessdev/reventless-core/src/components/QueryDb/QueryDb_Adapter.res.mjs";
|
|
18
|
+
import * as QueryDb_Builder$ReventlessCore from "@reventlessdev/reventless-core/src/components/QueryDb/QueryDb_Builder.res.mjs";
|
|
19
|
+
import * as LocalQueryDbStorage$ReventlessLocal from "../../src/adapter/QueryDb/LocalQueryDbStorage.res.mjs";
|
|
20
|
+
import * as DomainGraphQL_Server$ReventlessLocal from "../../src/adapter/DomainGraphQL_Server.res.mjs";
|
|
21
|
+
import * as QueryDbResolvers_GraphQL$ReventlessLocal from "../../src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs";
|
|
22
|
+
|
|
23
|
+
TestRunner$ReventlessLocal.setup();
|
|
24
|
+
|
|
25
|
+
let productRowSchema = Sury.$schema(s => ({
|
|
26
|
+
productId: s.m(Sury.string),
|
|
27
|
+
name: s.m(Sury.string),
|
|
28
|
+
archived: s.m(Sury.bool)
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
let orderRowSchema = Sury.$schema(s => ({
|
|
32
|
+
orderId: s.m(Sury.string),
|
|
33
|
+
productId: s.m(Sury.string),
|
|
34
|
+
productIds: s.m(Sury.array(Sury.string))
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
function annotations(retired) {
|
|
38
|
+
return {
|
|
39
|
+
ids: [],
|
|
40
|
+
compositeIds: [],
|
|
41
|
+
subIds: [],
|
|
42
|
+
compositeSubIds: [],
|
|
43
|
+
indexes: [],
|
|
44
|
+
hidden: [],
|
|
45
|
+
summary: [],
|
|
46
|
+
drillTargets: [],
|
|
47
|
+
drillTargetKeys: [],
|
|
48
|
+
collapsed: [],
|
|
49
|
+
scan: [],
|
|
50
|
+
scanSort: [],
|
|
51
|
+
semantic: [],
|
|
52
|
+
metric: [],
|
|
53
|
+
lifecycle: undefined,
|
|
54
|
+
groupBy: undefined,
|
|
55
|
+
visibility: undefined,
|
|
56
|
+
live: undefined,
|
|
57
|
+
retired: retired
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ctxFor(groups) {
|
|
62
|
+
let newrecord = {...Identity$Reventless.anonymous};
|
|
63
|
+
return Object.fromEntries([
|
|
64
|
+
[
|
|
65
|
+
"request",
|
|
66
|
+
Object.fromEntries([[
|
|
67
|
+
"headers",
|
|
68
|
+
{}
|
|
69
|
+
]])
|
|
70
|
+
],
|
|
71
|
+
[
|
|
72
|
+
"identity",
|
|
73
|
+
(newrecord.groups = groups, newrecord.username = "test-user", newrecord.userId = "test-user", newrecord)
|
|
74
|
+
]
|
|
75
|
+
]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let plainCtx = ctxFor(["User"]);
|
|
79
|
+
|
|
80
|
+
let noArgs = {};
|
|
81
|
+
|
|
82
|
+
function getString(json, key) {
|
|
83
|
+
return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), d => d[key]), Stdlib_JSON.Decode.string);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function names(json) {
|
|
87
|
+
return Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_JSON.Decode.array(json), []), item => getString(item, "name"));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function buildFixture(suffix, retired) {
|
|
91
|
+
let Bus = LocalBus$ReventlessLocal.Make({});
|
|
92
|
+
let Storage = LocalQueryDbStorage$ReventlessLocal.Make(Bus);
|
|
93
|
+
let Resolvers = QueryDbResolvers_GraphQL$ReventlessLocal.Make(Bus);
|
|
94
|
+
let productsName = "Products" + suffix;
|
|
95
|
+
let ordersName = "Orders" + suffix;
|
|
96
|
+
let orderTypeName = "Shop_Order" + suffix;
|
|
97
|
+
let moduleUrl = import.meta.url;
|
|
98
|
+
let config = ReadModel$Reventless.config(undefined, undefined, undefined);
|
|
99
|
+
let moduleUrl$1 = import.meta.url;
|
|
100
|
+
let config$1 = ReadModel$Reventless.config([{
|
|
101
|
+
source: {
|
|
102
|
+
idField: "productId",
|
|
103
|
+
subId: "NoSubId",
|
|
104
|
+
resolvedField: {
|
|
105
|
+
TAG: "Single",
|
|
106
|
+
_0: "product"
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
target: {
|
|
110
|
+
tableName: productsName,
|
|
111
|
+
idField: "Id"
|
|
112
|
+
}
|
|
113
|
+
}], [{
|
|
114
|
+
source: {
|
|
115
|
+
idsField: "productIds",
|
|
116
|
+
resolvedField: "products"
|
|
117
|
+
},
|
|
118
|
+
target: {
|
|
119
|
+
tableName: productsName
|
|
120
|
+
}
|
|
121
|
+
}], undefined);
|
|
122
|
+
let NoResolvers = QueryDb_Adapter$ReventlessCore.NoResolvers({
|
|
123
|
+
make: Storage.make
|
|
124
|
+
});
|
|
125
|
+
let ProductsDb = QueryDb_Builder$ReventlessCore.Make({
|
|
126
|
+
Id: {
|
|
127
|
+
schema: Id$Reventless.StringPure.schema,
|
|
128
|
+
make: prim => prim,
|
|
129
|
+
makeFromString: prim => prim,
|
|
130
|
+
toString: prim => prim,
|
|
131
|
+
cmp: Id$Reventless.StringPure.cmp
|
|
132
|
+
},
|
|
133
|
+
name: productsName,
|
|
134
|
+
moduleUrl: moduleUrl,
|
|
135
|
+
stateSchema: productRowSchema,
|
|
136
|
+
config: config,
|
|
137
|
+
subIdConfig: undefined,
|
|
138
|
+
authorization: "AllowAuthenticated",
|
|
139
|
+
visibility: "Public"
|
|
140
|
+
})({
|
|
141
|
+
make: Storage.make
|
|
142
|
+
})(NoResolvers);
|
|
143
|
+
let OrdersDb = QueryDb_Builder$ReventlessCore.Make({
|
|
144
|
+
Id: {
|
|
145
|
+
schema: Id$Reventless.StringPure.schema,
|
|
146
|
+
make: prim => prim,
|
|
147
|
+
makeFromString: prim => prim,
|
|
148
|
+
toString: prim => prim,
|
|
149
|
+
cmp: Id$Reventless.StringPure.cmp
|
|
150
|
+
},
|
|
151
|
+
name: ordersName,
|
|
152
|
+
moduleUrl: moduleUrl$1,
|
|
153
|
+
stateSchema: orderRowSchema,
|
|
154
|
+
config: config$1,
|
|
155
|
+
subIdConfig: undefined,
|
|
156
|
+
authorization: "AllowAuthenticated",
|
|
157
|
+
visibility: "Public"
|
|
158
|
+
})({
|
|
159
|
+
make: Storage.make
|
|
160
|
+
})(NoResolvers);
|
|
161
|
+
let register = (viewName, returnTypeName) => {
|
|
162
|
+
Plugin_Helpers$ReventlessCore.queryFieldNamesRegistry[viewName] = {
|
|
163
|
+
singleFieldName: returnTypeName,
|
|
164
|
+
listFieldName: returnTypeName + "s",
|
|
165
|
+
returnTypeName: returnTypeName,
|
|
166
|
+
pluralTypeName: returnTypeName + "s",
|
|
167
|
+
includeIdParam: true,
|
|
168
|
+
connectionSpec: true
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
register(productsName, "Shop_Product" + suffix);
|
|
172
|
+
register(ordersName, orderTypeName);
|
|
173
|
+
Plugin_Helpers$ReventlessCore.stateSchemaRegistry[productsName] = Sury.$Metadata_set(productRowSchema, StateAnnotations$Reventless.stateAnnotationsId, annotations(retired));
|
|
174
|
+
let productsDb = ProductsDb.make(undefined, undefined, undefined, undefined, undefined);
|
|
175
|
+
let productOps = await TestRunner$ReventlessLocal.resolve(Component$ReventlessCore.operations(productsDb));
|
|
176
|
+
let ordersDb = OrdersDb.make(undefined, undefined, undefined, undefined, undefined);
|
|
177
|
+
let orderOps = await TestRunner$ReventlessLocal.resolve(Component$ReventlessCore.operations(ordersDb));
|
|
178
|
+
Resolvers.make(ordersName, undefined, undefined, Pulumi.output(""), [], undefined, config$1.idResolvers, config$1.idsResolvers, "AllowAuthenticated", {});
|
|
179
|
+
await productOps.save("p-1", {
|
|
180
|
+
productId: "p-1",
|
|
181
|
+
name: "Book",
|
|
182
|
+
archived: false
|
|
183
|
+
}, "Init", undefined);
|
|
184
|
+
await productOps.save("p-2", {
|
|
185
|
+
productId: "p-2",
|
|
186
|
+
name: "Pen",
|
|
187
|
+
archived: true
|
|
188
|
+
}, "Init", undefined);
|
|
189
|
+
await orderOps.save("o-1", {
|
|
190
|
+
orderId: "o-1",
|
|
191
|
+
productId: "p-1",
|
|
192
|
+
productIds: [
|
|
193
|
+
"p-1",
|
|
194
|
+
"gone",
|
|
195
|
+
"p-2"
|
|
196
|
+
]
|
|
197
|
+
}, "Init", undefined);
|
|
198
|
+
let resolverFor = field => {
|
|
199
|
+
let r = DomainGraphQL_Server$ReventlessLocal.getFieldResolver(orderTypeName, field);
|
|
200
|
+
if (r !== undefined) {
|
|
201
|
+
return r;
|
|
202
|
+
} else {
|
|
203
|
+
return Stdlib_JsError.throwWithMessage("field resolver not registered: " + field);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
let order = Object.fromEntries([
|
|
207
|
+
[
|
|
208
|
+
"orderId",
|
|
209
|
+
"o-1"
|
|
210
|
+
],
|
|
211
|
+
[
|
|
212
|
+
"productId",
|
|
213
|
+
"p-1"
|
|
214
|
+
],
|
|
215
|
+
[
|
|
216
|
+
"productIds",
|
|
217
|
+
[
|
|
218
|
+
"p-1",
|
|
219
|
+
"gone",
|
|
220
|
+
"p-2"
|
|
221
|
+
].map(prim => prim)
|
|
222
|
+
]
|
|
223
|
+
]);
|
|
224
|
+
return [
|
|
225
|
+
resolverFor,
|
|
226
|
+
order
|
|
227
|
+
];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
globalThis.describe("QueryDb cross-table field resolvers", () => {
|
|
231
|
+
globalThis.beforeEach(() => DomainGraphQL_Server$ReventlessLocal.reset());
|
|
232
|
+
globalThis.test("@resolves follows the foreign key into the target's row", async () => {
|
|
233
|
+
let match = await buildFixture("A", undefined);
|
|
234
|
+
let product = await match[0]("product")(match[1], noArgs, plainCtx);
|
|
235
|
+
globalThis.expect([
|
|
236
|
+
getString(product, "name"),
|
|
237
|
+
getString(product, "id")
|
|
238
|
+
]).toEqual([
|
|
239
|
+
"Book",
|
|
240
|
+
"p-1"
|
|
241
|
+
]);
|
|
242
|
+
});
|
|
243
|
+
globalThis.test("@resolvesMany batch-follows the id array", async () => {
|
|
244
|
+
let match = await buildFixture("B", undefined);
|
|
245
|
+
let products = await match[0]("products")(match[1], noArgs, plainCtx);
|
|
246
|
+
globalThis.expect(names(products)).toEqual([
|
|
247
|
+
"Book",
|
|
248
|
+
"Pen"
|
|
249
|
+
]);
|
|
250
|
+
});
|
|
251
|
+
globalThis.test("a foreign key naming no row resolves to nothing", async () => {
|
|
252
|
+
let match = await buildFixture("C", undefined);
|
|
253
|
+
let orphan = Object.fromEntries([[
|
|
254
|
+
"productId",
|
|
255
|
+
"nope"
|
|
256
|
+
]]);
|
|
257
|
+
let product = await match[0]("product")(orphan, noArgs, plainCtx);
|
|
258
|
+
globalThis.expect(product).toBe(null);
|
|
259
|
+
});
|
|
260
|
+
globalThis.test("a retired target row is withheld from both forms", async () => {
|
|
261
|
+
let match = await buildFixture("D", {
|
|
262
|
+
field: "archived",
|
|
263
|
+
label: "",
|
|
264
|
+
showWhenFalse: false,
|
|
265
|
+
values: undefined,
|
|
266
|
+
namedWhenRetired: false
|
|
267
|
+
});
|
|
268
|
+
let resolverFor = match[0];
|
|
269
|
+
let products = await resolverFor("products")(match[1], noArgs, plainCtx);
|
|
270
|
+
globalThis.expect(names(products)).toEqual(["Book"]);
|
|
271
|
+
let archivedOnly = Object.fromEntries([[
|
|
272
|
+
"productId",
|
|
273
|
+
"p-2"
|
|
274
|
+
]]);
|
|
275
|
+
let product = await resolverFor("product")(archivedOnly, noArgs, plainCtx);
|
|
276
|
+
globalThis.expect(product).toBe(null);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
export {
|
|
281
|
+
productRowSchema,
|
|
282
|
+
orderRowSchema,
|
|
283
|
+
annotations,
|
|
284
|
+
ctxFor,
|
|
285
|
+
plainCtx,
|
|
286
|
+
noArgs,
|
|
287
|
+
getString,
|
|
288
|
+
names,
|
|
289
|
+
buildFixture,
|
|
290
|
+
}
|
|
291
|
+
/* Not a pure module */
|