@reventlessdev/reventless-core 3.0.0-alpha.241 → 3.0.0-alpha.243
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 +5 -5
- 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/GeolocationSemanticTest.res +108 -0
- package/tests/api/GeolocationSemanticTest.res.mjs +122 -0
- 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
|
@@ -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,108 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
open TaggedUnionFixtures
|
|
3
|
+
|
|
4
|
+
// Two markers ride on one schema; a lost one emits the field as `String` with
|
|
5
|
+
// nothing reporting it. Also pins a negative: the name does NOT come from
|
|
6
|
+
// `semanticCompositeNames`, so an entry there would be inert.
|
|
7
|
+
|
|
8
|
+
module GeoCustomersSpec = {
|
|
9
|
+
module Id = Reventless.Id.StringPure
|
|
10
|
+
let name = "SemanticGeolocationCustomers"
|
|
11
|
+
let moduleUrl: string = %raw(`import.meta.url`)
|
|
12
|
+
|
|
13
|
+
@schema
|
|
14
|
+
type state = {customerId: string, geolocation: Reventless.Geolocation.t}
|
|
15
|
+
|
|
16
|
+
let config = Reventless.ReadModel.config()
|
|
17
|
+
let subIdConfig = None
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let fragment = GraphQL_FragmentGenerator.generate(
|
|
21
|
+
~mutationEntries=[],
|
|
22
|
+
~queryEntries=[
|
|
23
|
+
queryEntryFor(
|
|
24
|
+
~returnTypeName="SemanticGeoCustomer",
|
|
25
|
+
~specName=GeoCustomersSpec.name,
|
|
26
|
+
GeoCustomersSpec.stateSchema,
|
|
27
|
+
),
|
|
28
|
+
],
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
let types = GraphQL_Stitcher.decode(fragment).types
|
|
32
|
+
let typeDefFor = needle => types->Array.find(t => t->String.includes(needle))->Option.getOr("")
|
|
33
|
+
|
|
34
|
+
describe("the Geolocation semantic reaches the IR as a named union", () => {
|
|
35
|
+
let field = switch SchemaType.fromSuryObject(
|
|
36
|
+
~typeName="SemanticGeoCustomer",
|
|
37
|
+
GeoCustomersSpec.stateSchema->S.castToUnknown,
|
|
38
|
+
) {
|
|
39
|
+
| Some(fields) => fields->Dict.get("geolocation")
|
|
40
|
+
| None => None
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// If the walk stopped at the marker, the field would classify as `Unknown`.
|
|
44
|
+
testSync("the semantic marker does not swallow the union", () => {
|
|
45
|
+
switch field {
|
|
46
|
+
| Some(Semantic({id}, TaggedUnion(name, arms))) =>
|
|
47
|
+
expect((id, name, arms->Array.length))->toEqual((
|
|
48
|
+
Reventless.Semantic.Id.geolocation,
|
|
49
|
+
"Geolocation",
|
|
50
|
+
3,
|
|
51
|
+
))
|
|
52
|
+
| _ => expect("geolocation")->toBe("Semantic(geolocation, TaggedUnion(Geolocation, 3 arms))")
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// A path-composed name would carry the spec's name; this one must not.
|
|
57
|
+
testSync("the union is named from the schema, not from where it was found", () => {
|
|
58
|
+
switch field {
|
|
59
|
+
| Some(Semantic(_, TaggedUnion(name, _))) =>
|
|
60
|
+
expect(name->String.includes("Customer"))->toBe(false)
|
|
61
|
+
expect(name)->toBe("Geolocation")
|
|
62
|
+
| _ => expect("geolocation")->toBe("a named TaggedUnion")
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// An entry there would leave this passing — it decides nothing.
|
|
67
|
+
testSync("semanticCompositeNames does not name this union", () => {
|
|
68
|
+
expect(SchemaType.canonicalName(Reventless.Semantic.Id.geolocation))->toEqual(None)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// The breaking half, asserted rather than assumed.
|
|
73
|
+
describe("a row in the pre-union shape does not decode", () => {
|
|
74
|
+
let oldShapedRow = JSON.parseOrThrow(`{
|
|
75
|
+
"customerId": "c1",
|
|
76
|
+
"location": {"lat": 48.2082, "lng": 16.3738},
|
|
77
|
+
"locationStatus": "Located",
|
|
78
|
+
"locationNote": null
|
|
79
|
+
}`)
|
|
80
|
+
|
|
81
|
+
testSync("the three-field shape is refused against the union schema", () => {
|
|
82
|
+
let decodes = switch oldShapedRow->S.parseOrThrow(~to=GeoCustomersSpec.stateSchema) {
|
|
83
|
+
| _ => true
|
|
84
|
+
| exception _ => false
|
|
85
|
+
}
|
|
86
|
+
expect(decodes)->toBe(false)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe("the Geolocation semantic emits its three member types", () => {
|
|
91
|
+
testSync("the union declaration lists all three members", () => {
|
|
92
|
+
let union = typeDefFor("union Geolocation")
|
|
93
|
+
expect(union->String.includes("GeolocationPending"))->toBe(true)
|
|
94
|
+
expect(union->String.includes("GeolocationLocated"))->toBe(true)
|
|
95
|
+
expect(union->String.includes("GeolocationUnresolvable"))->toBe(true)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
// Also says a composite nested in an arm keeps its own canonical name.
|
|
99
|
+
testSync("the located member carries a GeoPoint", () => {
|
|
100
|
+
expect(typeDefFor("type GeolocationLocated")->String.includes("GeoPoint"))->toBe(true)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
testSync("the field is typed as the union", () => {
|
|
104
|
+
expect(typeDefFor("type SemanticGeoCustomer")->String.includes("geolocation: Geolocation"))->toBe(
|
|
105
|
+
true,
|
|
106
|
+
)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Sury from "sury";
|
|
5
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
|
+
import * as Semantic$Reventless from "@reventlessdev/reventless-spec/src/semantic/Semantic.res.mjs";
|
|
7
|
+
import * as ReadModel$Reventless from "@reventlessdev/reventless-spec/src/components/ReadModel.res.mjs";
|
|
8
|
+
import * as Geolocation$Reventless from "@reventlessdev/reventless-spec/src/semantic/Geolocation.res.mjs";
|
|
9
|
+
import * as SchemaType$ReventlessCore from "../../src/components/Api/SchemaType.res.mjs";
|
|
10
|
+
import * as GraphQL_Stitcher$ReventlessCore from "../../src/components/Api/GraphQL_Stitcher.res.mjs";
|
|
11
|
+
import * as TaggedUnionFixtures$ReventlessCore from "../fixtures/TaggedUnionFixtures.res.mjs";
|
|
12
|
+
import * as GraphQL_FragmentGenerator$ReventlessCore from "../../src/components/Api/GraphQL_FragmentGenerator.res.mjs";
|
|
13
|
+
|
|
14
|
+
let name = "SemanticGeolocationCustomers";
|
|
15
|
+
|
|
16
|
+
let moduleUrl = import.meta.url;
|
|
17
|
+
|
|
18
|
+
let stateSchema = Sury.$schema(s => ({
|
|
19
|
+
customerId: s.m(Sury.string),
|
|
20
|
+
geolocation: s.m(Geolocation$Reventless.schema)
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
let config = ReadModel$Reventless.config(undefined, undefined, undefined);
|
|
24
|
+
|
|
25
|
+
let GeoCustomersSpec = {
|
|
26
|
+
Id: undefined,
|
|
27
|
+
name: name,
|
|
28
|
+
moduleUrl: moduleUrl,
|
|
29
|
+
stateSchema: stateSchema,
|
|
30
|
+
config: config,
|
|
31
|
+
subIdConfig: undefined,
|
|
32
|
+
authorization: "AllowAuthenticated",
|
|
33
|
+
visibility: "Public"
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let fragment = GraphQL_FragmentGenerator$ReventlessCore.generate([], [TaggedUnionFixtures$ReventlessCore.queryEntryFor("SemanticGeoCustomer", name, stateSchema)]);
|
|
37
|
+
|
|
38
|
+
let types = GraphQL_Stitcher$ReventlessCore.decode(fragment).types;
|
|
39
|
+
|
|
40
|
+
function typeDefFor(needle) {
|
|
41
|
+
return Stdlib_Option.getOr(types.find(t => t.includes(needle)), "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
globalThis.describe("the Geolocation semantic reaches the IR as a named union", () => {
|
|
45
|
+
let fields = SchemaType$ReventlessCore.fromSuryObject("SemanticGeoCustomer", stateSchema);
|
|
46
|
+
let field = fields !== undefined ? fields["geolocation"] : undefined;
|
|
47
|
+
globalThis.test("the semantic marker does not swallow the union", () => {
|
|
48
|
+
if (typeof field === "object" && field.TAG === "Semantic") {
|
|
49
|
+
let match = field._1;
|
|
50
|
+
if (typeof match === "object" && match.TAG === "TaggedUnion") {
|
|
51
|
+
globalThis.expect([
|
|
52
|
+
field._0.id,
|
|
53
|
+
match._0,
|
|
54
|
+
match._1.length
|
|
55
|
+
]).toEqual([
|
|
56
|
+
Semantic$Reventless.Id.geolocation,
|
|
57
|
+
"Geolocation",
|
|
58
|
+
3
|
|
59
|
+
]);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
globalThis.expect("geolocation").toBe("Semantic(geolocation, TaggedUnion(Geolocation, 3 arms))");
|
|
64
|
+
});
|
|
65
|
+
globalThis.test("the union is named from the schema, not from where it was found", () => {
|
|
66
|
+
if (typeof field === "object" && field.TAG === "Semantic") {
|
|
67
|
+
let match = field._1;
|
|
68
|
+
if (typeof match === "object" && match.TAG === "TaggedUnion") {
|
|
69
|
+
let name = match._0;
|
|
70
|
+
globalThis.expect(name.includes("Customer")).toBe(false);
|
|
71
|
+
globalThis.expect(name).toBe("Geolocation");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
globalThis.expect("geolocation").toBe("a named TaggedUnion");
|
|
76
|
+
});
|
|
77
|
+
globalThis.test("semanticCompositeNames does not name this union", () => {
|
|
78
|
+
globalThis.expect(SchemaType$ReventlessCore.canonicalName(Semantic$Reventless.Id.geolocation)).toEqual(undefined);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
globalThis.describe("a row in the pre-union shape does not decode", () => {
|
|
83
|
+
let oldShapedRow = JSON.parse(`{
|
|
84
|
+
"customerId": "c1",
|
|
85
|
+
"location": {"lat": 48.2082, "lng": 16.3738},
|
|
86
|
+
"locationStatus": "Located",
|
|
87
|
+
"locationNote": null
|
|
88
|
+
}`);
|
|
89
|
+
globalThis.test("the three-field shape is refused against the union schema", () => {
|
|
90
|
+
let decodes;
|
|
91
|
+
try {
|
|
92
|
+
S.parseOrThrow(oldShapedRow, stateSchema);
|
|
93
|
+
decodes = true;
|
|
94
|
+
} catch (exn) {
|
|
95
|
+
decodes = false;
|
|
96
|
+
}
|
|
97
|
+
globalThis.expect(decodes).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
globalThis.describe("the Geolocation semantic emits its three member types", () => {
|
|
102
|
+
globalThis.test("the union declaration lists all three members", () => {
|
|
103
|
+
let union = typeDefFor("union Geolocation");
|
|
104
|
+
globalThis.expect(union.includes("GeolocationPending")).toBe(true);
|
|
105
|
+
globalThis.expect(union.includes("GeolocationLocated")).toBe(true);
|
|
106
|
+
globalThis.expect(union.includes("GeolocationUnresolvable")).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
globalThis.test("the located member carries a GeoPoint", () => {
|
|
109
|
+
globalThis.expect(typeDefFor("type GeolocationLocated").includes("GeoPoint")).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
globalThis.test("the field is typed as the union", () => {
|
|
112
|
+
globalThis.expect(typeDefFor("type SemanticGeoCustomer").includes("geolocation: Geolocation")).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
export {
|
|
117
|
+
GeoCustomersSpec,
|
|
118
|
+
fragment,
|
|
119
|
+
types,
|
|
120
|
+
typeDefFor,
|
|
121
|
+
}
|
|
122
|
+
/* moduleUrl Not a pure module */
|
|
@@ -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
|
+
})
|