@reventlessdev/reventless-core 3.0.0-alpha.223 → 3.0.0-alpha.224
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 +22 -0
- package/package.json +7 -7
- package/src/admin/Platform_Admin_Structure.res +10 -0
- package/src/admin/Platform_Admin_Structure.res.mjs +6 -1
- package/src/admin/Platform_ComponentDefinitionsApi.res +11 -1
- package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +13 -1
- package/src/components/Api/Api_Ids.res +63 -0
- package/src/components/Api/Api_Ids.res.mjs +43 -0
- package/src/components/Api/GraphQL_FragmentGenerator.res +121 -28
- package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +97 -27
- package/src/components/Api/QueryDbListQuery.res +13 -5
- package/src/components/Api/QueryDbListQuery.res.mjs +5 -3
- package/src/components/Api/SchemaType.res +36 -2
- package/src/components/Api/SchemaType.res.mjs +31 -10
- package/src/plugin/component/Plugin_Structure.res +19 -0
- package/src/plugin/component/Plugin_Structure.res.mjs +11 -2
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res +63 -0
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +117 -4
- package/tests/admin/Platform_PluginStructuresApiTest.res +10 -0
- package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +23 -2
- package/tests/api/Api_IdsTest.res +143 -0
- package/tests/api/Api_IdsTest.res.mjs +97 -0
- package/tests/api/GraphQL_FragmentGeneratorTest.res +169 -0
- package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +179 -0
- package/tests/plugin/PluginStructureTest.res +90 -0
- package/tests/plugin/PluginStructureTest.res.mjs +86 -8
- package/tests/plugin/StateViewSlice/PsCategoriesView.res +17 -0
- package/tests/plugin/StateViewSlice/PsCategoriesView.res.mjs +57 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// The id contract: which form each door accepts, in one place.
|
|
2
|
+
//
|
|
3
|
+
// A row advertises a Relay global id (`btoa("<Type>:<localId>")`) while the
|
|
4
|
+
// storage key is the local id. Before this, the typed doors took only the local
|
|
5
|
+
// id, so the obvious `X(id: row.id)` returned null with no error. These pin the
|
|
6
|
+
// rule that makes either form work — and, just as importantly, the cases where a
|
|
7
|
+
// string must NOT be treated as a global id.
|
|
8
|
+
|
|
9
|
+
open JestGlobals
|
|
10
|
+
|
|
11
|
+
@val external btoa: string => string = "btoa"
|
|
12
|
+
|
|
13
|
+
describe("Api_Ids.encode / decode", () => {
|
|
14
|
+
testSync("round-trips a type name and a local id", () => {
|
|
15
|
+
let g = Api_Ids.encode(~typeName="Ordering_Customer", ~localId="cust-42")
|
|
16
|
+
expect(g)->toEqual(btoa("Ordering_Customer:cust-42"))
|
|
17
|
+
expect(Api_Ids.decode(g))->toEqual(Some(("Ordering_Customer", "cust-42")))
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// Ids carrying a colon of their own: only the first one separates.
|
|
21
|
+
testSync("a local id containing a colon survives the round trip", () => {
|
|
22
|
+
let g = Api_Ids.encode(~typeName="Platform_Plugin", ~localId="Catalog@1.0.0:beta")
|
|
23
|
+
expect(Api_Ids.decode(g))->toEqual(Some(("Platform_Plugin", "Catalog@1.0.0:beta")))
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
testSync("a plain storage key is not a global id", () =>
|
|
27
|
+
expect(Api_Ids.decode("cust-42"))->toEqual(None)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
// `atob` accepts this and yields text with no colon — decoding must still say no.
|
|
31
|
+
testSync("valid base64 without a colon is not a global id", () =>
|
|
32
|
+
expect(Api_Ids.decode(btoa("nocolonhere")))->toEqual(None)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
// A leading colon would mean an empty type name — nothing `node` could resolve.
|
|
36
|
+
testSync("a decoded value starting with the separator is not a global id", () =>
|
|
37
|
+
expect(Api_Ids.decode(btoa(":cust-42")))->toEqual(None)
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe("Api_Ids.alternateKey — the fallback a door tries on a miss", () => {
|
|
42
|
+
testSync("a global id offers the storage key inside it", () =>
|
|
43
|
+
expect(Api_Ids.alternateKey(Api_Ids.encode(~typeName="Catalog_Product", ~localId="p-1")))
|
|
44
|
+
->toEqual(Some("p-1"))
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
testSync("a plain key offers nothing, so the lookup is never retried", () =>
|
|
48
|
+
expect(Api_Ids.alternateKey("p-1"))->toEqual(None)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
// The reason doors try the raw key FIRST and only fall back here: a storage key
|
|
52
|
+
// that happens to be valid base64 must keep resolving to its own row.
|
|
53
|
+
testSync("a key that merely looks like base64 is not silently rewritten", () => {
|
|
54
|
+
let looksBase64 = btoa("Type:inner")
|
|
55
|
+
expect(Api_Ids.alternateKey(looksBase64))->toEqual(Some("inner"))
|
|
56
|
+
// …but the door asks for `looksBase64` first, so a row stored under that exact
|
|
57
|
+
// string wins. `alternateKey` only ever supplies the second attempt.
|
|
58
|
+
expect(Api_Ids.alternateKey("cGxhaW4="))->toEqual(None)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// A resolver that stamps the Relay `id` onto a row must not stamp it onto the
|
|
63
|
+
// *stored* row: `JSON.Decode.object` hands back the very object the in-memory
|
|
64
|
+
// QueryDb holds, so a `Dict.set` writes through. The symptom was a row whose
|
|
65
|
+
// advertised id depended on query history — a list answered the storage key
|
|
66
|
+
// until someone loaded the detail page, and the global id ever after.
|
|
67
|
+
describe("stamping an id onto a row leaves the stored row alone", () => {
|
|
68
|
+
let storedRow = () => {
|
|
69
|
+
let d = Dict.make()
|
|
70
|
+
d->Dict.set("id", JSON.Encode.string("cust-42"))
|
|
71
|
+
d->Dict.set("email", JSON.Encode.string("a@b.c"))
|
|
72
|
+
JSON.Encode.object(d)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
testSync("Dict.copy before set — the pattern the resolvers use", () => {
|
|
76
|
+
let row = storedRow()
|
|
77
|
+
let stamped = {
|
|
78
|
+
let obj = row->JSON.Decode.object->Option.mapOr(Dict.make(), Dict.copy)
|
|
79
|
+
obj->Dict.set(
|
|
80
|
+
"id",
|
|
81
|
+
Api_Ids.encode(~typeName="Ordering_Customer", ~localId="cust-42")->JSON.Encode.string,
|
|
82
|
+
)
|
|
83
|
+
JSON.Encode.object(obj)
|
|
84
|
+
}
|
|
85
|
+
let idOf = j =>
|
|
86
|
+
j->JSON.Decode.object->Option.flatMap(d => d->Dict.get("id"))->Option.flatMap(JSON.Decode.string)
|
|
87
|
+
expect(idOf(stamped))->toEqual(Some(Api_Ids.encode(~typeName="Ordering_Customer", ~localId="cust-42")))
|
|
88
|
+
// The stored row still carries its storage key.
|
|
89
|
+
expect(idOf(row))->toEqual(Some("cust-42"))
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// `filter.ids` is the door that has always taken either form. Which side needs
|
|
94
|
+
// decoding flipped when the typed doors stopped stamping global ids onto rows:
|
|
95
|
+
// the row now carries its storage key and the *argument* may be the global one.
|
|
96
|
+
describe("filter.ids matches whichever form each side holds", () => {
|
|
97
|
+
let row = (id: string) => {
|
|
98
|
+
let d = Dict.make()
|
|
99
|
+
d->Dict.set("id", JSON.Encode.string(id))
|
|
100
|
+
d->Dict.set("name", JSON.Encode.string("Alpha"))
|
|
101
|
+
JSON.Encode.object(d)
|
|
102
|
+
}
|
|
103
|
+
let idsIn = (~rowId, ~filterIds) => {
|
|
104
|
+
let args = Dict.make()
|
|
105
|
+
args->Dict.set(
|
|
106
|
+
"filter",
|
|
107
|
+
JSON.Encode.object(
|
|
108
|
+
Dict.fromArray([("ids", JSON.Encode.array(filterIds->Array.map(JSON.Encode.string)))]),
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
let conn = QueryDbListQuery.run(
|
|
112
|
+
~items=[row(rowId)],
|
|
113
|
+
~argsDict=args,
|
|
114
|
+
~capability=GraphQL_FragmentGenerator.emptyCapability,
|
|
115
|
+
~labelField="name",
|
|
116
|
+
)
|
|
117
|
+
conn
|
|
118
|
+
->JSON.Decode.object
|
|
119
|
+
->Option.flatMap(d => d->Dict.get("edges"))
|
|
120
|
+
->Option.flatMap(JSON.Decode.array)
|
|
121
|
+
->Option.getOr([])
|
|
122
|
+
->Array.length
|
|
123
|
+
}
|
|
124
|
+
let globalFor = localId => Api_Ids.encode(~typeName="Catalog_Product", ~localId)
|
|
125
|
+
|
|
126
|
+
testSync("storage key row, storage key filter", () =>
|
|
127
|
+
expect(idsIn(~rowId="p-1", ~filterIds=["p-1"]))->toBe(1)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
// The case the flip fixes: a client passes back the global id it was given by
|
|
131
|
+
// `node`, while the list rows carry storage keys.
|
|
132
|
+
testSync("storage key row, global id filter", () =>
|
|
133
|
+
expect(idsIn(~rowId="p-1", ~filterIds=[globalFor("p-1")]))->toBe(1)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
testSync("global id row, storage key filter", () =>
|
|
137
|
+
expect(idsIn(~rowId=globalFor("p-1"), ~filterIds=["p-1"]))->toBe(1)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
testSync("a filter for another row still excludes it", () =>
|
|
141
|
+
expect(idsIn(~rowId="p-1", ~filterIds=[globalFor("p-2")]))->toBe(0)
|
|
142
|
+
)
|
|
143
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
4
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
|
+
import * as Api_Ids$ReventlessCore from "../../src/components/Api/Api_Ids.res.mjs";
|
|
6
|
+
import * as QueryDbListQuery$ReventlessCore from "../../src/components/Api/QueryDbListQuery.res.mjs";
|
|
7
|
+
import * as GraphQL_FragmentGenerator$ReventlessCore from "../../src/components/Api/GraphQL_FragmentGenerator.res.mjs";
|
|
8
|
+
|
|
9
|
+
globalThis.describe("Api_Ids.encode / decode", () => {
|
|
10
|
+
globalThis.test("round-trips a type name and a local id", () => {
|
|
11
|
+
let g = Api_Ids$ReventlessCore.encode("Ordering_Customer", "cust-42");
|
|
12
|
+
globalThis.expect(g).toEqual(btoa("Ordering_Customer:cust-42"));
|
|
13
|
+
globalThis.expect(Api_Ids$ReventlessCore.decode(g)).toEqual([
|
|
14
|
+
"Ordering_Customer",
|
|
15
|
+
"cust-42"
|
|
16
|
+
]);
|
|
17
|
+
});
|
|
18
|
+
globalThis.test("a local id containing a colon survives the round trip", () => {
|
|
19
|
+
let g = Api_Ids$ReventlessCore.encode("Platform_Plugin", "Catalog@1.0.0:beta");
|
|
20
|
+
globalThis.expect(Api_Ids$ReventlessCore.decode(g)).toEqual([
|
|
21
|
+
"Platform_Plugin",
|
|
22
|
+
"Catalog@1.0.0:beta"
|
|
23
|
+
]);
|
|
24
|
+
});
|
|
25
|
+
globalThis.test("a plain storage key is not a global id", () => {
|
|
26
|
+
globalThis.expect(Api_Ids$ReventlessCore.decode("cust-42")).toEqual(undefined);
|
|
27
|
+
});
|
|
28
|
+
globalThis.test("valid base64 without a colon is not a global id", () => {
|
|
29
|
+
globalThis.expect(Api_Ids$ReventlessCore.decode(btoa("nocolonhere"))).toEqual(undefined);
|
|
30
|
+
});
|
|
31
|
+
globalThis.test("a decoded value starting with the separator is not a global id", () => {
|
|
32
|
+
globalThis.expect(Api_Ids$ReventlessCore.decode(btoa(":cust-42"))).toEqual(undefined);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
globalThis.describe("Api_Ids.alternateKey — the fallback a door tries on a miss", () => {
|
|
37
|
+
globalThis.test("a global id offers the storage key inside it", () => {
|
|
38
|
+
globalThis.expect(Api_Ids$ReventlessCore.alternateKey(Api_Ids$ReventlessCore.encode("Catalog_Product", "p-1"))).toEqual("p-1");
|
|
39
|
+
});
|
|
40
|
+
globalThis.test("a plain key offers nothing, so the lookup is never retried", () => {
|
|
41
|
+
globalThis.expect(Api_Ids$ReventlessCore.alternateKey("p-1")).toEqual(undefined);
|
|
42
|
+
});
|
|
43
|
+
globalThis.test("a key that merely looks like base64 is not silently rewritten", () => {
|
|
44
|
+
let looksBase64 = btoa("Type:inner");
|
|
45
|
+
globalThis.expect(Api_Ids$ReventlessCore.alternateKey(looksBase64)).toEqual("inner");
|
|
46
|
+
globalThis.expect(Api_Ids$ReventlessCore.alternateKey("cGxhaW4=")).toEqual(undefined);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
globalThis.describe("stamping an id onto a row leaves the stored row alone", () => {
|
|
51
|
+
let storedRow = () => {
|
|
52
|
+
let d = {};
|
|
53
|
+
d["id"] = "cust-42";
|
|
54
|
+
d["email"] = "a@b.c";
|
|
55
|
+
return d;
|
|
56
|
+
};
|
|
57
|
+
globalThis.test("Dict.copy before set — the pattern the resolvers use", () => {
|
|
58
|
+
let row = storedRow();
|
|
59
|
+
let obj = Stdlib_Option.mapOr(Stdlib_JSON.Decode.object(row), {}, prim => Object.assign({}, prim));
|
|
60
|
+
obj["id"] = Api_Ids$ReventlessCore.encode("Ordering_Customer", "cust-42");
|
|
61
|
+
let idOf = j => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(j), d => d["id"]), Stdlib_JSON.Decode.string);
|
|
62
|
+
globalThis.expect(idOf(obj)).toEqual(Api_Ids$ReventlessCore.encode("Ordering_Customer", "cust-42"));
|
|
63
|
+
globalThis.expect(idOf(row)).toEqual("cust-42");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
globalThis.describe("filter.ids matches whichever form each side holds", () => {
|
|
68
|
+
let row = id => {
|
|
69
|
+
let d = {};
|
|
70
|
+
d["id"] = id;
|
|
71
|
+
d["name"] = "Alpha";
|
|
72
|
+
return d;
|
|
73
|
+
};
|
|
74
|
+
let idsIn = (rowId, filterIds) => {
|
|
75
|
+
let args = {};
|
|
76
|
+
args["filter"] = Object.fromEntries([[
|
|
77
|
+
"ids",
|
|
78
|
+
filterIds.map(prim => prim)
|
|
79
|
+
]]);
|
|
80
|
+
let conn = QueryDbListQuery$ReventlessCore.run([row(rowId)], args, GraphQL_FragmentGenerator$ReventlessCore.emptyCapability, "name", undefined);
|
|
81
|
+
return Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(conn), d => d["edges"]), Stdlib_JSON.Decode.array), []).length;
|
|
82
|
+
};
|
|
83
|
+
globalThis.test("storage key row, storage key filter", () => {
|
|
84
|
+
globalThis.expect(idsIn("p-1", ["p-1"])).toBe(1);
|
|
85
|
+
});
|
|
86
|
+
globalThis.test("storage key row, global id filter", () => {
|
|
87
|
+
globalThis.expect(idsIn("p-1", [Api_Ids$ReventlessCore.encode("Catalog_Product", "p-1")])).toBe(1);
|
|
88
|
+
});
|
|
89
|
+
globalThis.test("global id row, storage key filter", () => {
|
|
90
|
+
globalThis.expect(idsIn(Api_Ids$ReventlessCore.encode("Catalog_Product", "p-1"), ["p-1"])).toBe(1);
|
|
91
|
+
});
|
|
92
|
+
globalThis.test("a filter for another row still excludes it", () => {
|
|
93
|
+
globalThis.expect(idsIn("p-1", [Api_Ids$ReventlessCore.encode("Catalog_Product", "p-2")])).toBe(0);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/* Not a pure module */
|
|
@@ -87,3 +87,172 @@ describe("GraphQL_FragmentGenerator slice TODO rows", () => {
|
|
|
87
87
|
expect(item->String.includes("address: String!"))->toBe(true)
|
|
88
88
|
})
|
|
89
89
|
})
|
|
90
|
+
|
|
91
|
+
// A state that declares no `@id` used to produce `emptyCapability` — no per-field
|
|
92
|
+
// filter and no order-by at all, so every narrowing a client asked for happened
|
|
93
|
+
// client-side over one page. The key is knowable without the annotation in the
|
|
94
|
+
// common cases; these pin which cases those are, and which still need `@id`.
|
|
95
|
+
describe("resolveKeyField — the ladder", () => {
|
|
96
|
+
let rungFor = (~entityName, schema) =>
|
|
97
|
+
GraphQL_FragmentGenerator.resolveKeyField(~entityName, schema->S.castToUnknown)
|
|
98
|
+
|
|
99
|
+
testSync("a lone `*Id` field is the only thing the key could be", () => {
|
|
100
|
+
let schema = S.schema(s => {"productId": s.matches(S.string), "name": s.matches(S.string)})
|
|
101
|
+
expect(rungFor(~entityName="AvailableProducts", schema))->toEqual(Some(("productId", "sole")))
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
testSync("several `*Id` fields are disambiguated by the component name", () => {
|
|
105
|
+
let schema = S.schema(s =>
|
|
106
|
+
{"productId": s.matches(S.string), "categoryId": s.matches(S.string)}
|
|
107
|
+
)
|
|
108
|
+
expect(rungFor(~entityName="Products", schema))->toEqual(Some(("productId", "convention")))
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// The `-ies` plural the naive singulariser gets wrong, on the key side too.
|
|
112
|
+
testSync("the convention rung singularises the way Api_Naming does", () => {
|
|
113
|
+
let schema = S.schema(s =>
|
|
114
|
+
{"categoryId": s.matches(S.string), "ownerId": s.matches(S.string)}
|
|
115
|
+
)
|
|
116
|
+
expect(rungFor(~entityName="Categories", schema))->toEqual(Some(("categoryId", "convention")))
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// ProductDemand: two `*Id` fields, and the name yields `productDemandId`, which
|
|
120
|
+
// is not a field. Declining is the honest answer — this is what `@id` is for.
|
|
121
|
+
testSync("several `*Id` fields with no name match declines", () => {
|
|
122
|
+
let schema = S.schema(s =>
|
|
123
|
+
{"productId": s.matches(S.string), "categoryId": s.matches(S.string)}
|
|
124
|
+
)
|
|
125
|
+
expect(rungFor(~entityName="ProductDemand", schema))->toEqual(None)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
testSync("no `*Id` field at all declines", () => {
|
|
129
|
+
let schema = S.schema(s => {"email": s.matches(S.string), "address": s.matches(S.string)})
|
|
130
|
+
expect(rungFor(~entityName="Customers", schema))->toEqual(None)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// `paid` / `valid` end with a lowercase "id". SchemaType.isIdFieldName accepts
|
|
134
|
+
// them (it lowercases first, which is right for its own use); a key field must
|
|
135
|
+
// not be nominated that way.
|
|
136
|
+
testSync("a word merely ending in `id` is not a key", () => {
|
|
137
|
+
let schema = S.schema(s => {"paid": s.matches(S.string), "valid": s.matches(S.string)})
|
|
138
|
+
expect(rungFor(~entityName="Invoices", schema))->toEqual(None)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe("deriveServerCapability — inferred keys reach the SDL surface", () => {
|
|
143
|
+
let capabilityFor = (~entityName, schema) =>
|
|
144
|
+
GraphQL_FragmentGenerator.deriveServerCapability(~entityName, schema->S.castToUnknown)
|
|
145
|
+
|
|
146
|
+
testSync("an inferred key yields an eq filter and a sort field", () => {
|
|
147
|
+
let schema = S.schema(s => {"orderId": s.matches(S.string), "total": s.matches(S.float)})
|
|
148
|
+
let c = capabilityFor(~entityName="Orders", schema)
|
|
149
|
+
expect(c.filterFields->Array.map(f => f.name))->toEqual(["orderId"])
|
|
150
|
+
expect(c.sortFields)->toEqual(["orderId"])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
testSync("an unresolvable key still yields nothing — the old behaviour, now narrowed", () => {
|
|
154
|
+
let schema = S.schema(s => {"email": s.matches(S.string)})
|
|
155
|
+
let c = capabilityFor(~entityName="Customers", schema)
|
|
156
|
+
expect((c.filterFields->Array.length, c.sortFields->Array.length))->toEqual((0, 0))
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
testSync("the filter input carries the inferred key's eq field", () => {
|
|
160
|
+
let schema = S.schema(s => {"orderId": s.matches(S.string)})
|
|
161
|
+
let input = GraphQL_FragmentGenerator.deriveConnectionFilterType(
|
|
162
|
+
~filterTypeName="Ordering_OrderFilter",
|
|
163
|
+
~capability=capabilityFor(~entityName="Orders", schema),
|
|
164
|
+
)
|
|
165
|
+
// `ID`, not `String`: a `*Id`-named field is an EntityId in the IR, and the
|
|
166
|
+
// filter input mirrors the field's own GraphQL type.
|
|
167
|
+
expect(input->String.includes("orderIdEq: ID"))->toBe(true)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// No OrderBy type is emitted at all when nothing is sortable, so an inferred
|
|
171
|
+
// key is the difference between having the type and not having it.
|
|
172
|
+
testSync("the order-by pair exists because of the inferred key", () => {
|
|
173
|
+
let schema = S.schema(s => {"orderId": s.matches(S.string)})
|
|
174
|
+
let types = GraphQL_FragmentGenerator.deriveConnectionOrderByType(
|
|
175
|
+
~singularTypeName="Ordering_Order",
|
|
176
|
+
~capability=capabilityFor(~entityName="Orders", schema),
|
|
177
|
+
)
|
|
178
|
+
expect(types->Array.length)->toBe(2)
|
|
179
|
+
expect(types->Array.join("\n")->String.includes("enum Ordering_OrderOrderField"))->toBe(true)
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// A semantic composite is one type wherever it appears. Named from the field
|
|
184
|
+
// path that reached it, two commands taking a price produced two identical
|
|
185
|
+
// `{amount, currency}` inputs and, beside each, its own copy of the 165-value
|
|
186
|
+
// ISO 4217 enum — six copies of it across one example schema.
|
|
187
|
+
module AddProductCmd = {
|
|
188
|
+
@schema
|
|
189
|
+
type command =
|
|
190
|
+
| AddProduct({productId: string, price: Reventless.Money.t})
|
|
191
|
+
| ArchiveProduct({productId: string})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module ChangePriceCmd = {
|
|
195
|
+
@schema
|
|
196
|
+
type command =
|
|
197
|
+
| ChangeProductPrice({productId: string, price: Reventless.Money.t})
|
|
198
|
+
| ClearProductPrice({productId: string})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
describe("semantic composites are named once, not once per field", () => {
|
|
202
|
+
let countDefs = (types, keyword, name) =>
|
|
203
|
+
types->Array.filter(t => t->String.startsWith(`${keyword} ${name} {`))->Array.length
|
|
204
|
+
|
|
205
|
+
let fragmentTypes = GraphQL_Stitcher.decode(
|
|
206
|
+
GraphQL_FragmentGenerator.generate(
|
|
207
|
+
~mutationEntries=[
|
|
208
|
+
{
|
|
209
|
+
ReventlessInfra.Api.fieldNames: ["Catalog_AddProduct"],
|
|
210
|
+
commandSchema: AddProductCmd.commandSchema->S.castToUnknown,
|
|
211
|
+
injectIdArg: false,
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
ReventlessInfra.Api.fieldNames: ["Catalog_ChangeProductPrice"],
|
|
215
|
+
commandSchema: ChangePriceCmd.commandSchema->S.castToUnknown,
|
|
216
|
+
injectIdArg: false,
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
~queryEntries=[],
|
|
220
|
+
),
|
|
221
|
+
).types
|
|
222
|
+
|
|
223
|
+
testSync("two commands taking a price share one input definition", () =>
|
|
224
|
+
expect(fragmentTypes->countDefs("input", "MoneyInput"))->toBe(1)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
testSync("and one currency enum between them", () =>
|
|
228
|
+
expect(fragmentTypes->countDefs("enum", "MoneyCurrency"))->toBe(1)
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
// The control: these are the names the field-path naming produced, and their
|
|
232
|
+
// absence is the whole change. Without this the assertions above would pass
|
|
233
|
+
// just as well against a schema that emitted both namings.
|
|
234
|
+
testSync("nothing is named after the field path that reached it", () =>
|
|
235
|
+
expect(fragmentTypes->Array.some(t => t->String.includes("AddProductPrice")))->toBe(false)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
// GraphQL forbids one name serving as both an object and an input, so the two
|
|
239
|
+
// positions differ by suffix — while sharing the enum, which has no such
|
|
240
|
+
// restriction and would otherwise be emitted twice over.
|
|
241
|
+
testSync("the output position keeps the bare name", () => {
|
|
242
|
+
let types = GraphQL_FragmentGenerator.deriveObjectTypeWithNested(
|
|
243
|
+
~typeName="Catalog_Product",
|
|
244
|
+
~includeIdParam=false,
|
|
245
|
+
S.schema(s =>
|
|
246
|
+
{
|
|
247
|
+
"productId": s.matches(S.string),
|
|
248
|
+
"price": s.matches(Reventless.Money.schema),
|
|
249
|
+
}
|
|
250
|
+
)->S.castToUnknown,
|
|
251
|
+
)
|
|
252
|
+
expect((
|
|
253
|
+
types->countDefs("type", "Money"),
|
|
254
|
+
types->countDefs("enum", "MoneyCurrency"),
|
|
255
|
+
types->Array.some(t => t->String.includes("price: Money!")),
|
|
256
|
+
))->toEqual((1, 1, true))
|
|
257
|
+
})
|
|
258
|
+
})
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as S from "sury/src/S.res.mjs";
|
|
4
4
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
|
+
import * as Money$Reventless from "@reventlessdev/reventless-spec/src/semantic/Money.res.mjs";
|
|
5
6
|
import * as GraphQL_Stitcher$ReventlessCore from "../../src/components/Api/GraphQL_Stitcher.res.mjs";
|
|
6
7
|
import * as GraphQL_FragmentGenerator$ReventlessCore from "../../src/components/Api/GraphQL_FragmentGenerator.res.mjs";
|
|
7
8
|
import * as OutboundTranslationSlice_Callback$ReventlessCore from "../../src/components/OutboundTranslationSlice/OutboundTranslationSlice_Callback.res.mjs";
|
|
@@ -83,10 +84,188 @@ globalThis.describe("GraphQL_FragmentGenerator slice TODO rows", () => {
|
|
|
83
84
|
});
|
|
84
85
|
});
|
|
85
86
|
|
|
87
|
+
globalThis.describe("resolveKeyField — the ladder", () => {
|
|
88
|
+
globalThis.test("a lone `*Id` field is the only thing the key could be", () => {
|
|
89
|
+
let schema = S.schema(s => ({
|
|
90
|
+
productId: s.m(S.string),
|
|
91
|
+
name: s.m(S.string)
|
|
92
|
+
}));
|
|
93
|
+
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("AvailableProducts", schema)).toEqual([
|
|
94
|
+
"productId",
|
|
95
|
+
"sole"
|
|
96
|
+
]);
|
|
97
|
+
});
|
|
98
|
+
globalThis.test("several `*Id` fields are disambiguated by the component name", () => {
|
|
99
|
+
let schema = S.schema(s => ({
|
|
100
|
+
productId: s.m(S.string),
|
|
101
|
+
categoryId: s.m(S.string)
|
|
102
|
+
}));
|
|
103
|
+
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("Products", schema)).toEqual([
|
|
104
|
+
"productId",
|
|
105
|
+
"convention"
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
globalThis.test("the convention rung singularises the way Api_Naming does", () => {
|
|
109
|
+
let schema = S.schema(s => ({
|
|
110
|
+
categoryId: s.m(S.string),
|
|
111
|
+
ownerId: s.m(S.string)
|
|
112
|
+
}));
|
|
113
|
+
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("Categories", schema)).toEqual([
|
|
114
|
+
"categoryId",
|
|
115
|
+
"convention"
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
globalThis.test("several `*Id` fields with no name match declines", () => {
|
|
119
|
+
let schema = S.schema(s => ({
|
|
120
|
+
productId: s.m(S.string),
|
|
121
|
+
categoryId: s.m(S.string)
|
|
122
|
+
}));
|
|
123
|
+
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("ProductDemand", schema)).toEqual(undefined);
|
|
124
|
+
});
|
|
125
|
+
globalThis.test("no `*Id` field at all declines", () => {
|
|
126
|
+
let schema = S.schema(s => ({
|
|
127
|
+
email: s.m(S.string),
|
|
128
|
+
address: s.m(S.string)
|
|
129
|
+
}));
|
|
130
|
+
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("Customers", schema)).toEqual(undefined);
|
|
131
|
+
});
|
|
132
|
+
globalThis.test("a word merely ending in `id` is not a key", () => {
|
|
133
|
+
let schema = S.schema(s => ({
|
|
134
|
+
paid: s.m(S.string),
|
|
135
|
+
valid: s.m(S.string)
|
|
136
|
+
}));
|
|
137
|
+
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("Invoices", schema)).toEqual(undefined);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
globalThis.describe("deriveServerCapability — inferred keys reach the SDL surface", () => {
|
|
142
|
+
globalThis.test("an inferred key yields an eq filter and a sort field", () => {
|
|
143
|
+
let schema = S.schema(s => ({
|
|
144
|
+
orderId: s.m(S.string),
|
|
145
|
+
total: s.m(S.float)
|
|
146
|
+
}));
|
|
147
|
+
let c = GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability("Orders", schema);
|
|
148
|
+
globalThis.expect(c.filterFields.map(f => f.name)).toEqual(["orderId"]);
|
|
149
|
+
globalThis.expect(c.sortFields).toEqual(["orderId"]);
|
|
150
|
+
});
|
|
151
|
+
globalThis.test("an unresolvable key still yields nothing — the old behaviour, now narrowed", () => {
|
|
152
|
+
let schema = S.schema(s => ({
|
|
153
|
+
email: s.m(S.string)
|
|
154
|
+
}));
|
|
155
|
+
let c = GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability("Customers", schema);
|
|
156
|
+
globalThis.expect([
|
|
157
|
+
c.filterFields.length,
|
|
158
|
+
c.sortFields.length
|
|
159
|
+
]).toEqual([
|
|
160
|
+
0,
|
|
161
|
+
0
|
|
162
|
+
]);
|
|
163
|
+
});
|
|
164
|
+
globalThis.test("the filter input carries the inferred key's eq field", () => {
|
|
165
|
+
let schema = S.schema(s => ({
|
|
166
|
+
orderId: s.m(S.string)
|
|
167
|
+
}));
|
|
168
|
+
let input = GraphQL_FragmentGenerator$ReventlessCore.deriveConnectionFilterType("Ordering_OrderFilter", GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability("Orders", schema));
|
|
169
|
+
globalThis.expect(input.includes("orderIdEq: ID")).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
globalThis.test("the order-by pair exists because of the inferred key", () => {
|
|
172
|
+
let schema = S.schema(s => ({
|
|
173
|
+
orderId: s.m(S.string)
|
|
174
|
+
}));
|
|
175
|
+
let types = GraphQL_FragmentGenerator$ReventlessCore.deriveConnectionOrderByType("Ordering_Order", GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability("Orders", schema));
|
|
176
|
+
globalThis.expect(types.length).toBe(2);
|
|
177
|
+
globalThis.expect(types.join("\n").includes("enum Ordering_OrderOrderField")).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
let commandSchema$1 = S.union([
|
|
182
|
+
S.schema(s => ({
|
|
183
|
+
TAG: "AddProduct",
|
|
184
|
+
productId: s.m(S.string),
|
|
185
|
+
price: s.m(Money$Reventless.schema)
|
|
186
|
+
})),
|
|
187
|
+
S.schema(s => ({
|
|
188
|
+
TAG: "ArchiveProduct",
|
|
189
|
+
productId: s.m(S.string)
|
|
190
|
+
}))
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
function commandAuthorization$1(param) {
|
|
194
|
+
return "AllowAuthenticated";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let AddProductCmd = {
|
|
198
|
+
commandSchema: commandSchema$1,
|
|
199
|
+
commandAuthorization: commandAuthorization$1
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
let commandSchema$2 = S.union([
|
|
203
|
+
S.schema(s => ({
|
|
204
|
+
TAG: "ChangeProductPrice",
|
|
205
|
+
productId: s.m(S.string),
|
|
206
|
+
price: s.m(Money$Reventless.schema)
|
|
207
|
+
})),
|
|
208
|
+
S.schema(s => ({
|
|
209
|
+
TAG: "ClearProductPrice",
|
|
210
|
+
productId: s.m(S.string)
|
|
211
|
+
}))
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
function commandAuthorization$2(param) {
|
|
215
|
+
return "AllowAuthenticated";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let ChangePriceCmd = {
|
|
219
|
+
commandSchema: commandSchema$2,
|
|
220
|
+
commandAuthorization: commandAuthorization$2
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
globalThis.describe("semantic composites are named once, not once per field", () => {
|
|
224
|
+
let countDefs = (types, keyword, name) => types.filter(t => t.startsWith(keyword + ` ` + name + ` {`)).length;
|
|
225
|
+
let fragmentTypes = GraphQL_Stitcher$ReventlessCore.decode(GraphQL_FragmentGenerator$ReventlessCore.generate([
|
|
226
|
+
{
|
|
227
|
+
fieldNames: ["Catalog_AddProduct"],
|
|
228
|
+
commandSchema: commandSchema$1,
|
|
229
|
+
injectIdArg: false
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
fieldNames: ["Catalog_ChangeProductPrice"],
|
|
233
|
+
commandSchema: commandSchema$2,
|
|
234
|
+
injectIdArg: false
|
|
235
|
+
}
|
|
236
|
+
], [])).types;
|
|
237
|
+
globalThis.test("two commands taking a price share one input definition", () => {
|
|
238
|
+
globalThis.expect(countDefs(fragmentTypes, "input", "MoneyInput")).toBe(1);
|
|
239
|
+
});
|
|
240
|
+
globalThis.test("and one currency enum between them", () => {
|
|
241
|
+
globalThis.expect(countDefs(fragmentTypes, "enum", "MoneyCurrency")).toBe(1);
|
|
242
|
+
});
|
|
243
|
+
globalThis.test("nothing is named after the field path that reached it", () => {
|
|
244
|
+
globalThis.expect(fragmentTypes.some(t => t.includes("AddProductPrice"))).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
globalThis.test("the output position keeps the bare name", () => {
|
|
247
|
+
let types = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested("Catalog_Product", undefined, false, S.schema(s => ({
|
|
248
|
+
productId: s.m(S.string),
|
|
249
|
+
price: s.m(Money$Reventless.schema)
|
|
250
|
+
})));
|
|
251
|
+
globalThis.expect([
|
|
252
|
+
countDefs(types, "type", "Money"),
|
|
253
|
+
countDefs(types, "enum", "MoneyCurrency"),
|
|
254
|
+
types.some(t => t.includes("price: Money!"))
|
|
255
|
+
]).toEqual([
|
|
256
|
+
1,
|
|
257
|
+
1,
|
|
258
|
+
true
|
|
259
|
+
]);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
86
263
|
export {
|
|
87
264
|
OrderSliceCmd,
|
|
88
265
|
mutationFor,
|
|
89
266
|
GeocodeItem,
|
|
90
267
|
typeDefFor,
|
|
268
|
+
AddProductCmd,
|
|
269
|
+
ChangePriceCmd,
|
|
91
270
|
}
|
|
92
271
|
/* commandSchema Not a pure module */
|