@reventlessdev/reventless-core 3.0.0-alpha.240 → 3.0.0-alpha.242
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 +14 -0
- package/package.json +6 -6
- package/src/admin/Platform_Admin.res +1 -16
- package/src/admin/Platform_Admin.res.mjs +2 -30
- package/src/admin/Platform_Admin_Structure.res +25 -0
- package/src/admin/Platform_Admin_Structure.res.mjs +16 -0
- package/src/components/Api/GraphQL_FragmentGenerator.res +49 -1
- package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +23 -1
- package/src/components/Api/SchemaType.res +133 -1
- package/src/components/Api/SchemaType.res.mjs +133 -8
- package/src/components/Api/SuryToJsonSchema.res +36 -0
- package/src/components/Api/SuryToJsonSchema.res.mjs +45 -0
- package/src/components/QueryDb/QueryDb_Operations.res +13 -0
- package/src/components/QueryDb/QueryDb_Operations.res.mjs +5 -0
- package/src/plugin/component/Plugin_Structure.res +9 -0
- package/src/plugin/component/Plugin_Structure.res.mjs +6 -4
- package/src/plugin/component/SchemaWalker.res +16 -3
- package/src/plugin/component/SchemaWalker.res.mjs +17 -6
- package/src/plugin/lifecycle/PluginsReadModelSpec.res +1 -1
- package/tests/admin/Platform_Admin_StructureTest.res +2 -2
- package/tests/api/TaggedUnionSchemaTest.res +198 -0
- package/tests/api/TaggedUnionSchemaTest.res.mjs +176 -0
- package/tests/fixtures/TaggedUnionFixtures.res +88 -0
- package/tests/fixtures/TaggedUnionFixtures.res.mjs +117 -0
- package/tests/querydb/TaggedUnionStampTest.res +163 -0
- package/tests/querydb/TaggedUnionStampTest.res.mjs +267 -0
|
@@ -196,9 +196,45 @@ let rec fromSchemaType = (st: SchemaType.schemaType): JSON.t =>
|
|
|
196
196
|
("enum", JSON.Encode.array(values->Array.map(JSON.Encode.string))),
|
|
197
197
|
])
|
|
198
198
|
| Semantic(sem, inner) => fromSchemaType(inner)->withSemantic(sem)
|
|
199
|
+
// A union is `oneOf` its arms, each an object carrying the `TAG` const it is
|
|
200
|
+
// discriminated by. The discriminator is what tells a union apart from a
|
|
201
|
+
// nullable object, which is also a `oneOf` of objects — a reader that misses
|
|
202
|
+
// it selects one arm's fields as though they were the field's own and produces
|
|
203
|
+
// a query that looks plausible and is invalid.
|
|
204
|
+
| TaggedUnion(name, arms) =>
|
|
205
|
+
let members = arms->Array.map(((tag, armType)) => armToJsonSchema(~tag, armType))
|
|
206
|
+
jsonObject([("oneOf", JSON.Encode.array(members)), ("x-reventless-union", str(name))])
|
|
199
207
|
| Unknown => jsonObject([("type", str("string"))])
|
|
200
208
|
}
|
|
201
209
|
|
|
210
|
+
// One arm. `TAG` is declared rather than filtered out — the raw value on the
|
|
211
|
+
// wire and in the live change channel carries it, and it is the only thing in
|
|
212
|
+
// the payload that says which arm this is. The GraphQL member type name rides
|
|
213
|
+
// alongside so a reader mapping a raw payload to a selection does not have to
|
|
214
|
+
// re-derive the naming rule in a second repo.
|
|
215
|
+
and armToJsonSchema = (~tag: string, armType: SchemaType.schemaType): JSON.t =>
|
|
216
|
+
switch armType {
|
|
217
|
+
| ObjectRef(memberName, fields) =>
|
|
218
|
+
let base = objectRefToJsonSchema(fields)
|
|
219
|
+
switch base->JSON.Decode.object {
|
|
220
|
+
| Some(obj) =>
|
|
221
|
+
switch obj->Dict.get("properties")->Option.flatMap(JSON.Decode.object) {
|
|
222
|
+
| Some(props) =>
|
|
223
|
+
props->Dict.set("TAG", jsonObject([("type", str("string")), ("const", str(tag))]))
|
|
224
|
+
| None => ()
|
|
225
|
+
}
|
|
226
|
+
let required = switch obj->Dict.get("required")->Option.flatMap(JSON.Decode.array) {
|
|
227
|
+
| Some(existing) => Array.concat([str("TAG")], existing)
|
|
228
|
+
| None => [str("TAG")]
|
|
229
|
+
}
|
|
230
|
+
obj->Dict.set("required", JSON.Encode.array(required))
|
|
231
|
+
obj->Dict.set("x-reventless-union-member", str(memberName))
|
|
232
|
+
JSON.Encode.object(obj)
|
|
233
|
+
| None => base
|
|
234
|
+
}
|
|
235
|
+
| other => fromSchemaType(other)
|
|
236
|
+
}
|
|
237
|
+
|
|
202
238
|
// Attach a type-carried semantic to a field's JSON Schema.
|
|
203
239
|
//
|
|
204
240
|
// This is the type path's emission point, and it is deliberately *not*
|
|
@@ -246,10 +246,54 @@ function fromSchemaType(st) {
|
|
|
246
246
|
]);
|
|
247
247
|
case "Semantic" :
|
|
248
248
|
return withSemantic(fromSchemaType(st._1), st._0);
|
|
249
|
+
case "TaggedUnion" :
|
|
250
|
+
let members = st._1.map(param => armToJsonSchema(param[0], param[1]));
|
|
251
|
+
return Object.fromEntries([
|
|
252
|
+
[
|
|
253
|
+
"oneOf",
|
|
254
|
+
members
|
|
255
|
+
],
|
|
256
|
+
[
|
|
257
|
+
"x-reventless-union",
|
|
258
|
+
st._0
|
|
259
|
+
]
|
|
260
|
+
]);
|
|
249
261
|
}
|
|
250
262
|
}
|
|
251
263
|
}
|
|
252
264
|
|
|
265
|
+
function armToJsonSchema(tag, armType) {
|
|
266
|
+
if (typeof armType !== "object") {
|
|
267
|
+
return fromSchemaType(armType);
|
|
268
|
+
}
|
|
269
|
+
if (armType.TAG !== "ObjectRef") {
|
|
270
|
+
return fromSchemaType(armType);
|
|
271
|
+
}
|
|
272
|
+
let base = objectRefToJsonSchema(undefined, undefined, undefined, armType._1);
|
|
273
|
+
let obj = Stdlib_JSON.Decode.object(base);
|
|
274
|
+
if (obj === undefined) {
|
|
275
|
+
return base;
|
|
276
|
+
}
|
|
277
|
+
let props = Stdlib_Option.flatMap(obj["properties"], Stdlib_JSON.Decode.object);
|
|
278
|
+
if (props !== undefined) {
|
|
279
|
+
props["TAG"] = Object.fromEntries([
|
|
280
|
+
[
|
|
281
|
+
"type",
|
|
282
|
+
"string"
|
|
283
|
+
],
|
|
284
|
+
[
|
|
285
|
+
"const",
|
|
286
|
+
tag
|
|
287
|
+
]
|
|
288
|
+
]);
|
|
289
|
+
}
|
|
290
|
+
let existing = Stdlib_Option.flatMap(obj["required"], Stdlib_JSON.Decode.array);
|
|
291
|
+
let required = existing !== undefined ? ["TAG"].concat(existing) : ["TAG"];
|
|
292
|
+
obj["required"] = required;
|
|
293
|
+
obj["x-reventless-union-member"] = armType._0;
|
|
294
|
+
return obj;
|
|
295
|
+
}
|
|
296
|
+
|
|
253
297
|
function withSemantic(fieldSchema, sem) {
|
|
254
298
|
let obj = Stdlib_JSON.Decode.object(fieldSchema);
|
|
255
299
|
if (obj === undefined) {
|
|
@@ -368,6 +412,7 @@ export {
|
|
|
368
412
|
mergeAnnotations,
|
|
369
413
|
isNullableType,
|
|
370
414
|
fromSchemaType,
|
|
415
|
+
armToJsonSchema,
|
|
371
416
|
withSemantic,
|
|
372
417
|
objectRefToJsonSchema,
|
|
373
418
|
deriveObjectSchema,
|
|
@@ -67,12 +67,24 @@ module Make = (ReadModelSpec: Reventless.ReadModel.Spec, Ops: Ops) => {
|
|
|
67
67
|
})
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// Every union value in the row carries the GraphQL member type it is, written
|
|
71
|
+
// once here rather than by each of the fourteen read doors across three
|
|
72
|
+
// backends — and, unlike a read-time stamp, it is also on the row the live
|
|
73
|
+
// change channel hands over as raw JSON. A view with no union field is walked
|
|
74
|
+
// and left exactly as it was.
|
|
75
|
+
let stampUnionMembers = dict =>
|
|
76
|
+
Reventless.TaggedUnion.stampInto(
|
|
77
|
+
~schema=ReadModelSpec.stateSchema->S.castToUnknown,
|
|
78
|
+
JSON.Encode.object(dict),
|
|
79
|
+
)
|
|
80
|
+
|
|
70
81
|
let save = async (id, state, saveMode, ttl) =>
|
|
71
82
|
switch state->Message.encode(ReadModelSpec.stateSchema)->JSON.Decode.object {
|
|
72
83
|
| Some(dict) =>
|
|
73
84
|
dict->Dict.set("id", id->Message.encode(ReadModelSpec.Id.schema))
|
|
74
85
|
injectSubId(dict, state)
|
|
75
86
|
injectCompositeIndexAttrs(dict)
|
|
87
|
+
stampUnionMembers(dict)
|
|
76
88
|
let json = JSON.Encode.object(dict)
|
|
77
89
|
await Ops.jsonOps.save(id->ReadModelSpec.Id.toString, json, saveMode, ttl)
|
|
78
90
|
| None =>
|
|
@@ -87,6 +99,7 @@ module Make = (ReadModelSpec: Reventless.ReadModel.Spec, Ops: Ops) => {
|
|
|
87
99
|
dict->Dict.set("id", id->Message.encode(ReadModelSpec.Id.schema))
|
|
88
100
|
injectSubId(dict, state)
|
|
89
101
|
injectCompositeIndexAttrs(dict)
|
|
102
|
+
stampUnionMembers(dict)
|
|
90
103
|
let json = JSON.Encode.object(dict)
|
|
91
104
|
Ok(batch->Array.concat([(id->ReadModelSpec.Id.toString, json, ttl)]))
|
|
92
105
|
| None =>
|
|
@@ -8,6 +8,7 @@ import * as Effect from "effect/Effect";
|
|
|
8
8
|
import * as Stream from "effect/Stream";
|
|
9
9
|
import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
10
10
|
import * as Message$ReventlessCore from "../../Message.res.mjs";
|
|
11
|
+
import * as TaggedUnion$Reventless from "@reventlessdev/reventless-spec/src/components/TaggedUnion.res.mjs";
|
|
11
12
|
|
|
12
13
|
function Make(ReadModelSpec) {
|
|
13
14
|
return Ops => {
|
|
@@ -76,12 +77,14 @@ function Make(ReadModelSpec) {
|
|
|
76
77
|
dict[attrName$1] = value$1;
|
|
77
78
|
});
|
|
78
79
|
};
|
|
80
|
+
let stampUnionMembers = dict => TaggedUnion$Reventless.stampInto(ReadModelSpec.stateSchema, dict);
|
|
79
81
|
let save = async (id, state, saveMode, ttl) => {
|
|
80
82
|
let dict = Stdlib_JSON.Decode.object(Message$ReventlessCore.encode(state, ReadModelSpec.stateSchema));
|
|
81
83
|
if (dict !== undefined) {
|
|
82
84
|
dict["id"] = Message$ReventlessCore.encode(id, ReadModelSpec.Id.schema);
|
|
83
85
|
injectSubId(dict, state);
|
|
84
86
|
injectCompositeIndexAttrs(dict);
|
|
87
|
+
TaggedUnion$Reventless.stampInto(ReadModelSpec.stateSchema, dict);
|
|
85
88
|
return await Ops.jsonOps.save(ReadModelSpec.Id.toString(id), dict, saveMode, ttl);
|
|
86
89
|
} else {
|
|
87
90
|
return {
|
|
@@ -107,6 +110,7 @@ function Make(ReadModelSpec) {
|
|
|
107
110
|
dict["id"] = Message$ReventlessCore.encode(id, ReadModelSpec.Id.schema);
|
|
108
111
|
injectSubId(dict, state);
|
|
109
112
|
injectCompositeIndexAttrs(dict);
|
|
113
|
+
TaggedUnion$Reventless.stampInto(ReadModelSpec.stateSchema, dict);
|
|
110
114
|
return {
|
|
111
115
|
TAG: "Ok",
|
|
112
116
|
_0: batch.concat([[
|
|
@@ -150,6 +154,7 @@ function Make(ReadModelSpec) {
|
|
|
150
154
|
load: load,
|
|
151
155
|
injectSubId: injectSubId,
|
|
152
156
|
injectCompositeIndexAttrs: injectCompositeIndexAttrs,
|
|
157
|
+
stampUnionMembers: stampUnionMembers,
|
|
153
158
|
save: save,
|
|
154
159
|
saveBatch: saveBatch,
|
|
155
160
|
count: count,
|
|
@@ -23,6 +23,11 @@ let rec isLabelShape = (t: SchemaType.schemaType): bool =>
|
|
|
23
23
|
switch t {
|
|
24
24
|
| ScalarString => true
|
|
25
25
|
| Nullable(inner) => isLabelShape(inner)
|
|
26
|
+
// Written out rather than left to the catch-all: a union is a composite whose
|
|
27
|
+
// rendering depends on which arm a row is in, so no single string names the
|
|
28
|
+
// record. The label picker's fallback rung is a *guess*, and this is the shape
|
|
29
|
+
// it would guess most confidently and most wrongly.
|
|
30
|
+
| TaggedUnion(_, _) => false
|
|
26
31
|
| _ => false
|
|
27
32
|
}
|
|
28
33
|
|
|
@@ -33,6 +38,10 @@ let rec isLifecycleShape = (t: SchemaType.schemaType): bool =>
|
|
|
33
38
|
switch t {
|
|
34
39
|
| Enum(_, _) => true
|
|
35
40
|
| Nullable(inner) => isLifecycleShape(inner)
|
|
41
|
+
// A union is a closed set of *shapes*, not of values, and `allowedStates`
|
|
42
|
+
// compares values. The same answer the ppx gives `@lifecycle` on a union
|
|
43
|
+
// field, which is a compile error — this is the convention rung agreeing.
|
|
44
|
+
| TaggedUnion(_, _) => false
|
|
36
45
|
| _ => false
|
|
37
46
|
}
|
|
38
47
|
|
|
@@ -32,11 +32,13 @@ function isLabelShape(_t) {
|
|
|
32
32
|
if (typeof t !== "object") {
|
|
33
33
|
return t === "ScalarString";
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
switch (t.TAG) {
|
|
36
|
+
case "Nullable" :
|
|
37
|
+
_t = t._0;
|
|
38
|
+
continue;
|
|
39
|
+
default:
|
|
40
|
+
return false;
|
|
37
41
|
}
|
|
38
|
-
_t = t._0;
|
|
39
|
-
continue;
|
|
40
42
|
};
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -28,9 +28,22 @@ let rec describeSchema = (schema: S.t<unknown>): string =>
|
|
|
28
28
|
| _ => true
|
|
29
29
|
}
|
|
30
30
|
)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
|
31
|
+
let isOptional = nonNull->Array.length < anyOf->Array.length
|
|
32
|
+
switch nonNull {
|
|
33
|
+
| [inner] => "option<" ++ describeSchema(inner) ++ ">"
|
|
34
|
+
| [] => "unknown"
|
|
35
|
+
| members =>
|
|
36
|
+
// A field holding a variant. Described by every arm, sorted, rather than by
|
|
37
|
+
// the first one: the structural hash is computed from this string, and a
|
|
38
|
+
// description that mentions one arm is blind to a change in any other —
|
|
39
|
+
// which is drift detection reporting "no change" for a changed schema.
|
|
40
|
+
let inner =
|
|
41
|
+
members
|
|
42
|
+
->Array.map(describeSchema)
|
|
43
|
+
->Array.toSorted(String.compare)
|
|
44
|
+
->Array.join("|")
|
|
45
|
+
let union = "union<" ++ inner ++ ">"
|
|
46
|
+
isOptional ? "option<" ++ union ++ ">" : union
|
|
34
47
|
}
|
|
35
48
|
| Array({additionalItems}) =>
|
|
36
49
|
let itemDesc = switch additionalItems {
|
|
@@ -26,7 +26,8 @@ function describeSchema(schema) {
|
|
|
26
26
|
let inner = Object.entries(schema.properties).filter(param => param[0] !== "TAG").toSorted((param, param$1) => Primitive_string.compare(param[0], param$1[0])).map(param => param[0] + ":" + describeSchema(param[1])).join(",");
|
|
27
27
|
return "{" + inner + "}";
|
|
28
28
|
case "anyOf" :
|
|
29
|
-
let
|
|
29
|
+
let anyOf = schema.anyOf;
|
|
30
|
+
let nonNull = anyOf.filter(v => {
|
|
30
31
|
switch (v.type) {
|
|
31
32
|
case "null" :
|
|
32
33
|
case "undefined" :
|
|
@@ -35,12 +36,22 @@ function describeSchema(schema) {
|
|
|
35
36
|
return true;
|
|
36
37
|
}
|
|
37
38
|
});
|
|
38
|
-
let
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
let isOptional = nonNull.length < anyOf.length;
|
|
40
|
+
let len = nonNull.length;
|
|
41
|
+
if (len !== 1) {
|
|
42
|
+
if (len === 0) {
|
|
43
|
+
return "unknown";
|
|
44
|
+
}
|
|
45
|
+
let inner$1 = nonNull.map(describeSchema).toSorted(Primitive_string.compare).join("|");
|
|
46
|
+
let union = "union<" + inner$1 + ">";
|
|
47
|
+
if (isOptional) {
|
|
48
|
+
return "option<" + union + ">";
|
|
49
|
+
} else {
|
|
50
|
+
return union;
|
|
51
|
+
}
|
|
43
52
|
}
|
|
53
|
+
let inner$2 = nonNull[0];
|
|
54
|
+
return "option<" + describeSchema(inner$2) + ">";
|
|
44
55
|
default:
|
|
45
56
|
return "unknown";
|
|
46
57
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// this (spec-level `permission` outranks the legacy `{tableName, group}` pair),
|
|
11
11
|
// and so does the `requiredAccess` the platform publishes — so a shell can gate
|
|
12
12
|
// the Plugins view up front instead of offering it and meeting a refusal.
|
|
13
|
-
@@reventless.authorize(
|
|
13
|
+
@@reventless.authorize(AllowGroups(["Admin"]))
|
|
14
14
|
|
|
15
15
|
// Current view — one row per plugin **name**, holding the currently-active
|
|
16
16
|
// version's definition. Keyed by the aggregate id (= plugin name). The status
|
|
@@ -269,7 +269,7 @@ describe("the Plugins queryableDef is what the generic helpers produce", () => {
|
|
|
269
269
|
// nested object types whose own fields would match a naive `includes`.
|
|
270
270
|
let props =
|
|
271
271
|
rm.schema
|
|
272
|
-
->JSON.
|
|
272
|
+
->JSON.parseOrThrow
|
|
273
273
|
->JSON.Decode.object
|
|
274
274
|
->Option.flatMap(o => o->Dict.get("properties"))
|
|
275
275
|
->Option.flatMap(JSON.Decode.object)
|
|
@@ -289,7 +289,7 @@ describe("the Plugins queryableDef is what the generic helpers produce", () => {
|
|
|
289
289
|
testSync("while the internal fields are not published at all", () => {
|
|
290
290
|
let props =
|
|
291
291
|
rm.schema
|
|
292
|
-
->JSON.
|
|
292
|
+
->JSON.parseOrThrow
|
|
293
293
|
->JSON.Decode.object
|
|
294
294
|
->Option.flatMap(o => o->Dict.get("properties"))
|
|
295
295
|
->Option.flatMap(JSON.Decode.object)
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
open TaggedUnionFixtures
|
|
3
|
+
|
|
4
|
+
let fragmentFor = (~returnTypeName, ~specName, stateSchema) =>
|
|
5
|
+
GraphQL_FragmentGenerator.generate(
|
|
6
|
+
~mutationEntries=[],
|
|
7
|
+
~queryEntries=[queryEntryFor(~returnTypeName, ~specName, stateSchema)],
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
let typesOf = fragment => GraphQL_Stitcher.decode(fragment).types
|
|
11
|
+
let typeDefFor = (fragment, needle) =>
|
|
12
|
+
typesOf(fragment)->Array.find(t => t->String.includes(needle))->Option.getOr("")
|
|
13
|
+
|
|
14
|
+
// ── The IR ────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
describe("SchemaType classifies a named union", () => {
|
|
17
|
+
let shapeOfField = (schema, field) =>
|
|
18
|
+
switch SchemaType.fromSuryObject(~typeName="X", schema->S.castToUnknown) {
|
|
19
|
+
| Some(fields) => fields->Dict.get(field)
|
|
20
|
+
| None => None
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
testSync("arms are keyed by TAG and carry the member type's own name", () => {
|
|
24
|
+
switch shapeOfField(CustomersSpec.stateSchema, "geolocation") {
|
|
25
|
+
| Some(TaggedUnion(name, arms)) =>
|
|
26
|
+
expect(name)->toBe("Geolocation")
|
|
27
|
+
expect(arms->Array.map(((tag, _)) => tag))->toEqual(["Pending", "Located", "Unresolvable"])
|
|
28
|
+
switch arms->Array.find(((tag, _)) => tag === "Located") {
|
|
29
|
+
| Some((_, ObjectRef(memberName, fields))) =>
|
|
30
|
+
expect(memberName)->toBe("GeolocationLocated")
|
|
31
|
+
expect(fields->Dict.keysToArray)->toEqual(["point"])
|
|
32
|
+
| _ => expect("Located arm")->toBe("an ObjectRef")
|
|
33
|
+
}
|
|
34
|
+
| other =>
|
|
35
|
+
expect(other->Option.isSome ? "some other shape" : "nothing")->toBe("a TaggedUnion")
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
testSync("an optional union keeps its arms behind Nullable", () => {
|
|
40
|
+
switch shapeOfField(SightingsSpec.stateSchema, "lastSeen") {
|
|
41
|
+
| Some(Nullable(TaggedUnion(name, arms))) =>
|
|
42
|
+
expect((name, arms->Array.length))->toEqual(("Geolocation", 3))
|
|
43
|
+
| _ => expect("lastSeen")->toBe("Nullable(TaggedUnion(...))")
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
testSync("an array of unions keeps its arms behind ArrayOf", () => {
|
|
48
|
+
switch shapeOfField(SightingsSpec.stateSchema, "history") {
|
|
49
|
+
| Some(ArrayOf(TaggedUnion(name, _))) => expect(name)->toBe("Geolocation")
|
|
50
|
+
| _ => expect("history")->toBe("ArrayOf(TaggedUnion(...))")
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// The silent degradation this plan set out to end: an unclassifiable union
|
|
55
|
+
// rendered as `String!` with nothing said anywhere.
|
|
56
|
+
testSync("an unnamed union is reported by field and by reason", () => {
|
|
57
|
+
switch SchemaType.unclassifiedUnions(UnnamedSpec.stateSchema->S.castToUnknown) {
|
|
58
|
+
| [{path, reason}] =>
|
|
59
|
+
expect(path)->toBe("verdict")
|
|
60
|
+
expect(reason->String.includes("no name"))->toBe(true)
|
|
61
|
+
| found =>
|
|
62
|
+
expect(found->Array.length)->toBe(1)
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
testSync("a classified union and an enum are not reported", () => {
|
|
67
|
+
expect(
|
|
68
|
+
SchemaType.unclassifiedUnions(CustomersSpec.stateSchema->S.castToUnknown)->Array.length,
|
|
69
|
+
)->toBe(0)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// ── The SDL ───────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
describe("GraphQL_FragmentGenerator emits a union", () => {
|
|
76
|
+
let fragment = fragmentFor(
|
|
77
|
+
~returnTypeName="Ordering_TuCustomer",
|
|
78
|
+
~specName="TaggedUnionCustomers",
|
|
79
|
+
CustomersSpec.stateSchema,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
testSync("the field is typed as the union", () => {
|
|
83
|
+
expect(
|
|
84
|
+
typeDefFor(fragment, "type Ordering_TuCustomer ")->String.includes(
|
|
85
|
+
"geolocation: Geolocation!",
|
|
86
|
+
),
|
|
87
|
+
)->toBe(true)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
testSync("the union names one member type per arm", () => {
|
|
91
|
+
expect(typeDefFor(fragment, "union Geolocation"))->toBe(
|
|
92
|
+
"union Geolocation = GeolocationPending | GeolocationLocated | GeolocationUnresolvable",
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
testSync("each member type declares the arm's own fields, and no TAG", () => {
|
|
97
|
+
let located = typeDefFor(fragment, "type GeolocationLocated ")
|
|
98
|
+
expect(located->String.includes("point: GeoPoint!"))->toBe(true)
|
|
99
|
+
expect(located->String.includes("TAG"))->toBe(false)
|
|
100
|
+
expect(
|
|
101
|
+
typeDefFor(fragment, "type GeolocationUnresolvable ")->String.includes("reason: String!"),
|
|
102
|
+
)->toBe(true)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// The merged-API property. AppSync unions same-named types from each source
|
|
106
|
+
// API, and a union of identical definitions is that definition — so a second
|
|
107
|
+
// plugin's copy has to be byte-identical, not merely equivalent. This is the
|
|
108
|
+
// assertion that fails quietly: a name composed from the field path would pass
|
|
109
|
+
// every other test in this file.
|
|
110
|
+
testSync("a second plugin's copy of the union is byte-identical", () => {
|
|
111
|
+
let other = fragmentFor(
|
|
112
|
+
~returnTypeName="Catalog_TuSighting",
|
|
113
|
+
~specName="TaggedUnionSightings",
|
|
114
|
+
SightingsSpec.stateSchema,
|
|
115
|
+
)
|
|
116
|
+
let unionOf = f => typeDefFor(f, "union Geolocation")
|
|
117
|
+
expect(unionOf(other))->toBe(unionOf(fragment))
|
|
118
|
+
expect(typeDefFor(other, "type GeolocationLocated "))->toBe(
|
|
119
|
+
typeDefFor(fragment, "type GeolocationLocated "),
|
|
120
|
+
)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
testSync("an optional union is nullable and an array of them is a list", () => {
|
|
124
|
+
let sightings = fragmentFor(
|
|
125
|
+
~returnTypeName="Catalog_TuSighting",
|
|
126
|
+
~specName="TaggedUnionSightings",
|
|
127
|
+
SightingsSpec.stateSchema,
|
|
128
|
+
)
|
|
129
|
+
let row = typeDefFor(sightings, "type Catalog_TuSighting ")
|
|
130
|
+
expect(row->String.includes("lastSeen: Geolocation\n"))->toBe(true)
|
|
131
|
+
expect(row->String.includes("history: [Geolocation!]!"))->toBe(true)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// Unchanged behaviour, pinned: without a name there is no type to emit, and
|
|
135
|
+
// inventing one would publish a contract nothing can stamp a `__typename` for.
|
|
136
|
+
testSync("an unnamed union stays a String", () => {
|
|
137
|
+
let unnamed = fragmentFor(
|
|
138
|
+
~returnTypeName="Ordering_TuCase",
|
|
139
|
+
~specName="TaggedUnionUnnamed",
|
|
140
|
+
UnnamedSpec.stateSchema,
|
|
141
|
+
)
|
|
142
|
+
expect(
|
|
143
|
+
typeDefFor(unnamed, "type Ordering_TuCase ")->String.includes("verdict: String!"),
|
|
144
|
+
)->toBe(true)
|
|
145
|
+
expect(typesOf(unnamed)->Array.some(t => t->String.startsWith("union ")))->toBe(false)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// ── The JSON Schema ───────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
let objAt = (json, key) =>
|
|
152
|
+
json->JSON.Decode.object->Option.flatMap(o => o->Dict.get(key))
|
|
153
|
+
|
|
154
|
+
describe("SuryToJsonSchema emits the arms as oneOf", () => {
|
|
155
|
+
let json = SuryToJsonSchema.deriveObjectSchema(CustomersSpec.stateSchema->S.castToUnknown)
|
|
156
|
+
let field =
|
|
157
|
+
objAt(json, "properties")
|
|
158
|
+
->Option.flatMap(p => p->JSON.Decode.object)
|
|
159
|
+
->Option.flatMap(p => p->Dict.get("geolocation"))
|
|
160
|
+
->Option.getOr(JSON.Encode.null)
|
|
161
|
+
|
|
162
|
+
testSync("the field names the union it is", () => {
|
|
163
|
+
expect(objAt(field, "x-reventless-union")->Option.flatMap(JSON.Decode.string))->toEqual(
|
|
164
|
+
Some("Geolocation"),
|
|
165
|
+
)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
testSync("one member per arm, each discriminated by its TAG const", () => {
|
|
169
|
+
let members =
|
|
170
|
+
objAt(field, "oneOf")->Option.flatMap(JSON.Decode.array)->Option.getOr([])
|
|
171
|
+
expect(members->Array.length)->toBe(3)
|
|
172
|
+
let tags = members->Array.filterMap(m =>
|
|
173
|
+
objAt(m, "properties")
|
|
174
|
+
->Option.flatMap(JSON.Decode.object)
|
|
175
|
+
->Option.flatMap(p => p->Dict.get("TAG"))
|
|
176
|
+
->Option.flatMap(t => objAt(t, "const"))
|
|
177
|
+
->Option.flatMap(JSON.Decode.string)
|
|
178
|
+
)
|
|
179
|
+
expect(tags)->toEqual(["Pending", "Located", "Unresolvable"])
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// Without this a reader has to re-derive `<UnionName><Arm>` in a second repo to
|
|
183
|
+
// map a raw live-channel payload onto an inline fragment.
|
|
184
|
+
testSync("each member publishes the GraphQL type it is emitted as", () => {
|
|
185
|
+
let names =
|
|
186
|
+
objAt(field, "oneOf")
|
|
187
|
+
->Option.flatMap(JSON.Decode.array)
|
|
188
|
+
->Option.getOr([])
|
|
189
|
+
->Array.filterMap(m =>
|
|
190
|
+
objAt(m, "x-reventless-union-member")->Option.flatMap(JSON.Decode.string)
|
|
191
|
+
)
|
|
192
|
+
expect(names)->toEqual([
|
|
193
|
+
"GeolocationPending",
|
|
194
|
+
"GeolocationLocated",
|
|
195
|
+
"GeolocationUnresolvable",
|
|
196
|
+
])
|
|
197
|
+
})
|
|
198
|
+
})
|