@reventlessdev/reventless-core 3.0.0-alpha.265 → 3.0.0-alpha.266
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 +11 -0
- package/package.json +6 -6
- package/src/components/Api/GraphQL_FragmentGenerator.res +47 -16
- package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +20 -7
- package/src/components/Api/SchemaType.res +13 -3
- package/src/components/Api/SchemaType.res.mjs +13 -2
- package/src/components/Api/SuryToJsonSchema.res +9 -5
- package/src/components/Api/SuryToJsonSchema.res.mjs +14 -2
- package/src/plugin/component/Plugin_Structure.res +17 -10
- package/src/plugin/component/Plugin_Structure.res.mjs +6 -6
- package/tests/api/GraphQL_FragmentGeneratorTest.res +76 -1
- package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +78 -17
- package/tests/api/IdentitySemanticTest.res +77 -0
- package/tests/api/IdentitySemanticTest.res.mjs +71 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,17 @@
|
|
|
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.266 (2026-09-21)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **core:** an aggregate read model is not keyed by a foreign id ([0dfed74](https://github.com/ReventlessDev/reventless-core/commit/0dfed747c4182b1fc58675b42f5d3cb3eb6309c7))
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **spec:** one Id type per identity ([203be38](https://github.com/ReventlessDev/reventless-core/commit/203be389b9047db0a7a0294fe3c1c04ba5119965))
|
|
14
|
+
* **spec:** the runtime reads identities ([417c5f6](https://github.com/ReventlessDev/reventless-core/commit/417c5f613b031a08c7849a81c75de6a8bbeb841b))
|
|
15
|
+
|
|
16
|
+
|
|
6
17
|
# 3.0.0-alpha.265 (2026-09-20)
|
|
7
18
|
|
|
8
19
|
**Note:** Version bump only for package @reventlessdev/reventless-core
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-core",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.266",
|
|
4
4
|
"description": "Core package for Reventless framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -29,16 +29,16 @@
|
|
|
29
29
|
"sury": "11.0.0-rc.2",
|
|
30
30
|
"uuid": "^13.0.0",
|
|
31
31
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.33",
|
|
32
|
-
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.12",
|
|
33
32
|
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
|
|
34
|
-
"@reventlessdev/rescript-
|
|
33
|
+
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.12",
|
|
35
34
|
"@reventlessdev/rescript-node": "2.0.0-alpha.12",
|
|
35
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
36
36
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.21",
|
|
37
37
|
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.12",
|
|
38
38
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
39
|
-
"@reventlessdev/reventless-
|
|
40
|
-
"@reventlessdev/reventless-
|
|
41
|
-
"@reventlessdev/reventless-
|
|
39
|
+
"@reventlessdev/reventless-interop": "3.0.0-alpha.37",
|
|
40
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.167",
|
|
41
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.139"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"rescript": "12.3.0",
|
|
@@ -316,19 +316,32 @@ type keyFieldResolution =
|
|
|
316
316
|
| Ambiguous({candidates: array<string>, conventional: string})
|
|
317
317
|
| NoCandidate
|
|
318
318
|
|
|
319
|
+
/** Where a view's row key lives. A read model's row is keyed by the envelope id of
|
|
320
|
+
the events it folds, so a `*Id` field in its state is somebody else's id unless
|
|
321
|
+
it is declared or named as the view's own. A StateViewSlice keys its row by a
|
|
322
|
+
state field. */
|
|
323
|
+
type rowKeying = RowKeyedByEnvelope | RowKeyedByStateField
|
|
324
|
+
|
|
319
325
|
/**
|
|
320
326
|
The field that identifies a row, and which rung answered:
|
|
321
327
|
|
|
322
328
|
- `"annotation"` — the state declares `@id`. Nothing outranks it.
|
|
323
|
-
- `"convention"` — a field
|
|
324
|
-
(`Products` → `productId`).
|
|
325
|
-
- `"sole"` — the state has exactly one `*Id` field (`AvailableProducts`).
|
|
329
|
+
- `"convention"` — a field keyed `<singular entity name>Id` exists
|
|
330
|
+
(`Products` → `productId`): named so, or typed as that identity.
|
|
331
|
+
- `"sole"` — the state has exactly one `*Id` field (`AvailableProducts`). Only for
|
|
332
|
+
`RowKeyedByStateField`: a read model's lone `*Id` is a foreign id (`Orders` →
|
|
333
|
+
`customerId`), and having none of its own is its ordinary shape, so neither
|
|
334
|
+
that nor several `*Id` fields is a gap.
|
|
326
335
|
|
|
327
336
|
Convention outranks sole so a view carrying one foreign key and no key of its own
|
|
328
337
|
is not keyed by the foreign key. `resolveKeyField` is this, with both gaps
|
|
329
338
|
flattened to `None`.
|
|
330
339
|
*/
|
|
331
|
-
let classifyKeyField = (
|
|
340
|
+
let classifyKeyField = (
|
|
341
|
+
~rowKeying: rowKeying,
|
|
342
|
+
~entityName: string,
|
|
343
|
+
schema: S.t<unknown>,
|
|
344
|
+
): keyFieldResolution => {
|
|
332
345
|
let declared = switch Reventless.StateAnnotations.getSpec(schema) {
|
|
333
346
|
| Some({ids}) => ids->Array.get(0)
|
|
334
347
|
| None => None
|
|
@@ -336,29 +349,45 @@ let classifyKeyField = (~entityName: string, schema: S.t<unknown>): keyFieldReso
|
|
|
336
349
|
switch declared {
|
|
337
350
|
| Some(field) => Resolved({field, rung: "annotation"})
|
|
338
351
|
| None =>
|
|
352
|
+
// A field is a candidate by its name or by its type, and its key is its
|
|
353
|
+
// identity when it has one: `orderId: CustomerId.t` never keys `Orders`, and
|
|
354
|
+
// `order: OrderId.t` does.
|
|
355
|
+
let properties = switch schema {
|
|
356
|
+
| Object({properties}) => properties
|
|
357
|
+
| _ => Dict.make()
|
|
358
|
+
}
|
|
359
|
+
let keyOf = field =>
|
|
360
|
+
properties->Dict.get(field)->Option.flatMap(Reventless.Semantic.identityKey)
|
|
339
361
|
let candidates =
|
|
340
362
|
SchemaType.fromSuryObject(~typeName="", schema)
|
|
341
363
|
->Option.getOr(Dict.make())
|
|
342
364
|
->Dict.keysToArray
|
|
343
|
-
->Array.filter(isKeyFieldName)
|
|
365
|
+
->Array.filter(field => isKeyFieldName(field) || keyOf(field)->Option.isSome)
|
|
344
366
|
let singular = entityName->Api_Naming.stripViewSuffix->Api_Naming.singularize
|
|
345
367
|
let conventional =
|
|
346
368
|
singular->String.slice(~start=0, ~end=1)->String.toLowerCase ++
|
|
347
369
|
singular->String.slice(~start=1, ~end=singular->String.length) ++ "Id"
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
370
|
+
switch candidates->Array.find(field => keyOf(field)->Option.getOr(field) == conventional) {
|
|
371
|
+
| Some(field) => Resolved({field, rung: "convention"})
|
|
372
|
+
| None =>
|
|
373
|
+
if rowKeying == RowKeyedByEnvelope {
|
|
374
|
+
NoCandidate
|
|
375
|
+
} else if candidates->Array.length == 1 {
|
|
376
|
+
Resolved({field: candidates->Array.getUnsafe(0), rung: "sole"})
|
|
377
|
+
} else if Array.length(candidates) == 0 {
|
|
378
|
+
NoCandidate
|
|
379
|
+
} else {
|
|
380
|
+
Ambiguous({candidates, conventional})
|
|
381
|
+
}
|
|
356
382
|
}
|
|
357
383
|
}
|
|
358
384
|
}
|
|
359
385
|
|
|
360
|
-
let resolveKeyField = (~entityName: string, schema: S.t<unknown>): option<(
|
|
361
|
-
|
|
386
|
+
let resolveKeyField = (~rowKeying: rowKeying, ~entityName: string, schema: S.t<unknown>): option<(
|
|
387
|
+
string,
|
|
388
|
+
string,
|
|
389
|
+
)> =>
|
|
390
|
+
switch classifyKeyField(~rowKeying, ~entityName, schema) {
|
|
362
391
|
| Resolved({field, rung}) => Some((field, rung))
|
|
363
392
|
| Ambiguous(_) | NoCandidate => None
|
|
364
393
|
}
|
|
@@ -457,7 +486,9 @@ let deriveServerCapability = (~entityName: string, schema: S.t<unknown>): server
|
|
|
457
486
|
// a client asked for happened client-side over one page. Its key is knowable
|
|
458
487
|
// without the annotation in the common cases; take it. Pushed last, and both
|
|
459
488
|
// pushes dedupe, so a declared `@id` keeps its position and this is a no-op.
|
|
460
|
-
|
|
489
|
+
// Read as a state-field key for every view: a read model's lone foreign id is
|
|
490
|
+
// not its key, but it stays filterable, since dropping it would break the SDL.
|
|
491
|
+
switch resolveKeyField(~rowKeying=RowKeyedByStateField, ~entityName, schema) {
|
|
461
492
|
| Some((field, _rung)) =>
|
|
462
493
|
pushFilter(field, ~range=false)
|
|
463
494
|
pushSort(field)
|
|
@@ -5,6 +5,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
|
5
5
|
import * as Stdlib_String from "@rescript/runtime/lib/es6/Stdlib_String.js";
|
|
6
6
|
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
7
7
|
import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
|
|
8
|
+
import * as Semantic$Reventless from "@reventlessdev/reventless-spec/src/semantic/Semantic.res.mjs";
|
|
8
9
|
import * as ReadModel$Reventless from "@reventlessdev/reventless-spec/src/components/ReadModel.res.mjs";
|
|
9
10
|
import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
|
|
10
11
|
import * as Api_Naming$ReventlessCore from "./Api_Naming.res.mjs";
|
|
@@ -256,7 +257,7 @@ function isKeyFieldName(name) {
|
|
|
256
257
|
}
|
|
257
258
|
}
|
|
258
259
|
|
|
259
|
-
function classifyKeyField(entityName, schema) {
|
|
260
|
+
function classifyKeyField(rowKeying, entityName, schema) {
|
|
260
261
|
let match = StateAnnotations$Reventless.getSpec(schema);
|
|
261
262
|
let declared = match !== undefined ? match.ids[0] : undefined;
|
|
262
263
|
if (declared !== undefined) {
|
|
@@ -266,15 +267,27 @@ function classifyKeyField(entityName, schema) {
|
|
|
266
267
|
rung: "annotation"
|
|
267
268
|
};
|
|
268
269
|
}
|
|
269
|
-
let
|
|
270
|
+
let properties;
|
|
271
|
+
properties = schema.type === "object" ? schema.properties : ({});
|
|
272
|
+
let keyOf = field => Stdlib_Option.flatMap(properties[field], Semantic$Reventless.identityKey);
|
|
273
|
+
let candidates = Object.keys(Stdlib_Option.getOr(SchemaType$ReventlessCore.fromSuryObject("", schema), {})).filter(field => {
|
|
274
|
+
if (isKeyFieldName(field)) {
|
|
275
|
+
return true;
|
|
276
|
+
} else {
|
|
277
|
+
return Stdlib_Option.isSome(keyOf(field));
|
|
278
|
+
}
|
|
279
|
+
});
|
|
270
280
|
let singular = Api_Naming$ReventlessCore.singularize(Api_Naming$ReventlessCore.stripViewSuffix(entityName));
|
|
271
281
|
let conventional = singular.slice(0, 1).toLowerCase() + singular.slice(1, singular.length) + "Id";
|
|
272
|
-
|
|
282
|
+
let field = candidates.find(field => Stdlib_Option.getOr(keyOf(field), field) === conventional);
|
|
283
|
+
if (field !== undefined) {
|
|
273
284
|
return {
|
|
274
285
|
TAG: "Resolved",
|
|
275
|
-
field:
|
|
286
|
+
field: field,
|
|
276
287
|
rung: "convention"
|
|
277
288
|
};
|
|
289
|
+
} else if (rowKeying === "RowKeyedByEnvelope") {
|
|
290
|
+
return "NoCandidate";
|
|
278
291
|
} else if (candidates.length === 1) {
|
|
279
292
|
return {
|
|
280
293
|
TAG: "Resolved",
|
|
@@ -292,8 +305,8 @@ function classifyKeyField(entityName, schema) {
|
|
|
292
305
|
}
|
|
293
306
|
}
|
|
294
307
|
|
|
295
|
-
function resolveKeyField(entityName, schema) {
|
|
296
|
-
let match = classifyKeyField(entityName, schema);
|
|
308
|
+
function resolveKeyField(rowKeying, entityName, schema) {
|
|
309
|
+
let match = classifyKeyField(rowKeying, entityName, schema);
|
|
297
310
|
if (typeof match !== "object" || match.TAG !== "Resolved") {
|
|
298
311
|
return;
|
|
299
312
|
} else {
|
|
@@ -373,7 +386,7 @@ function deriveServerCapability(entityName, schema) {
|
|
|
373
386
|
spec.scan.forEach(name => pushFilter(name, false));
|
|
374
387
|
spec.scanSort.forEach(pushSort);
|
|
375
388
|
}
|
|
376
|
-
let match = resolveKeyField(entityName, schema);
|
|
389
|
+
let match = resolveKeyField("RowKeyedByStateField", entityName, schema);
|
|
377
390
|
if (match !== undefined) {
|
|
378
391
|
let field = match[0];
|
|
379
392
|
pushFilter(field, false);
|
|
@@ -45,6 +45,14 @@ let isTagged = Reventless.DcbTag.isTagged
|
|
|
45
45
|
let isDateTime = Reventless.DateTime.isDateTime
|
|
46
46
|
let isCalendarDate = Reventless.CalendarDate.isCalendarDate
|
|
47
47
|
|
|
48
|
+
// On the schema itself, not through `option<…>` as `Semantic.get` reads: the
|
|
49
|
+
// optional wrapper then reaches the `AnyOf` branch and stays `Nullable`.
|
|
50
|
+
let isIdentity = (schema: S.t<unknown>): bool =>
|
|
51
|
+
switch schema->S.Metadata.get(~id=Reventless.Semantic.semanticId) {
|
|
52
|
+
| Some({id}) => id === Reventless.Semantic.Id.identity
|
|
53
|
+
| None => false
|
|
54
|
+
}
|
|
55
|
+
|
|
48
56
|
let isIdFieldName = (name: string): bool => {
|
|
49
57
|
let lower = String.toLowerCase(name)
|
|
50
58
|
let len = String.length(lower)
|
|
@@ -123,12 +131,14 @@ and withoutDefault = (
|
|
|
123
131
|
}
|
|
124
132
|
|
|
125
133
|
and shapeOf = (~parentName: string, ~fieldName: string, schema: S.t<unknown>): schemaType => {
|
|
126
|
-
//
|
|
134
|
+
// Three independent facts, checked independently. A DCB-tagged field is an
|
|
127
135
|
// entity id because it routes; a reference field is one because it points at
|
|
128
|
-
// an entity. Neither implies the other — a `@partitionTag` field carries no
|
|
136
|
+
// an entity; an identity-typed field is one whatever it is called. Neither implies the other — a `@partitionTag` field carries no
|
|
129
137
|
// reference, and `Reference.toWithoutDcbTag` carries no tag — so collapsing
|
|
130
138
|
// these into one test would silently reclassify whichever case it dropped.
|
|
131
|
-
if
|
|
139
|
+
if (
|
|
140
|
+
isTagged(schema) || Reventless.Reference.getTarget(schema)->Option.isSome || isIdentity(schema)
|
|
141
|
+
) {
|
|
132
142
|
EntityId
|
|
133
143
|
} else {
|
|
134
144
|
switch schema {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
+
import * as Sury from "sury";
|
|
3
4
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
4
5
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
6
|
import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
|
|
@@ -11,6 +12,15 @@ import * as TaggedUnion$Reventless from "@reventlessdev/reventless-spec/src/comp
|
|
|
11
12
|
import * as CalendarDate$Reventless from "@reventlessdev/reventless-spec/src/semantic/CalendarDate.res.mjs";
|
|
12
13
|
import * as FieldDefault$Reventless from "@reventlessdev/reventless-spec/src/components/FieldDefault.res.mjs";
|
|
13
14
|
|
|
15
|
+
function isIdentity(schema) {
|
|
16
|
+
let match = Sury.$Metadata_get(schema, Semantic$Reventless.semanticId);
|
|
17
|
+
if (match !== undefined) {
|
|
18
|
+
return match.id === Semantic$Reventless.Id.identity;
|
|
19
|
+
} else {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
14
24
|
function isIdFieldName(name) {
|
|
15
25
|
let lower = name.toLowerCase();
|
|
16
26
|
let len = lower.length;
|
|
@@ -87,7 +97,7 @@ function withoutDefault(parentName, fieldName, schema) {
|
|
|
87
97
|
}
|
|
88
98
|
|
|
89
99
|
function shapeOf(parentName, fieldName, schema) {
|
|
90
|
-
if (DcbTag$Reventless.isTagged(schema) || Stdlib_Option.isSome(Reference$Reventless.getTarget(schema))) {
|
|
100
|
+
if (DcbTag$Reventless.isTagged(schema) || Stdlib_Option.isSome(Reference$Reventless.getTarget(schema)) || isIdentity(schema)) {
|
|
91
101
|
return "EntityId";
|
|
92
102
|
}
|
|
93
103
|
switch (schema.type) {
|
|
@@ -425,6 +435,7 @@ export {
|
|
|
425
435
|
isTagged,
|
|
426
436
|
isDateTime,
|
|
427
437
|
isCalendarDate,
|
|
438
|
+
isIdentity,
|
|
428
439
|
isIdFieldName,
|
|
429
440
|
isIdsFieldName,
|
|
430
441
|
semanticCompositeNames,
|
|
@@ -439,4 +450,4 @@ export {
|
|
|
439
450
|
trailShape,
|
|
440
451
|
fromSuryObject,
|
|
441
452
|
}
|
|
442
|
-
/*
|
|
453
|
+
/* sury Not a pure module */
|
|
@@ -310,11 +310,15 @@ and withSemantic = (fieldSchema: JSON.t, sem: Reventless.Semantic.t): JSON.t =>
|
|
|
310
310
|
obj->Dict.set("x-reventless-semantic-source", str("type"))
|
|
311
311
|
switch sem.payload {
|
|
312
312
|
| Plain => ()
|
|
313
|
-
| ReferenceTo({entity, plugin}) =>
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
313
|
+
| ReferenceTo({entity, plugin, ?identity}) =>
|
|
314
|
+
let pairs = withOptionalPlugin([("entity", str(entity))], plugin)
|
|
315
|
+
let pairs = switch identity {
|
|
316
|
+
| Some(key) => Array.concat(pairs, [("identity", str(key))])
|
|
317
|
+
| None => pairs
|
|
318
|
+
}
|
|
319
|
+
obj->Dict.set("x-reventless-semantic-target", jsonObject(pairs))
|
|
320
|
+
| IdentityOf({key}) =>
|
|
321
|
+
obj->Dict.set("x-reventless-semantic-target", jsonObject([("key", str(key))]))
|
|
318
322
|
| StoredIn({plugin, store}) =>
|
|
319
323
|
// An absent plugin means "this plugin's own store" — omit the key rather
|
|
320
324
|
// than writing an empty string, so a reader never has to tell those apart.
|
|
@@ -355,10 +355,16 @@ function withSemantic(fieldSchema, sem) {
|
|
|
355
355
|
switch (match.TAG) {
|
|
356
356
|
case "ReferenceTo" :
|
|
357
357
|
let match$1 = match._0;
|
|
358
|
-
|
|
358
|
+
let identity = match$1.identity;
|
|
359
|
+
let pairs = withOptionalPlugin([[
|
|
359
360
|
"entity",
|
|
360
361
|
match$1.entity
|
|
361
|
-
]], match$1.plugin)
|
|
362
|
+
]], match$1.plugin);
|
|
363
|
+
let pairs$1 = identity !== undefined ? pairs.concat([[
|
|
364
|
+
"identity",
|
|
365
|
+
identity
|
|
366
|
+
]]) : pairs;
|
|
367
|
+
obj["x-reventless-semantic-target"] = Object.fromEntries(pairs$1);
|
|
362
368
|
break;
|
|
363
369
|
case "StoredIn" :
|
|
364
370
|
let match$2 = match._0;
|
|
@@ -384,6 +390,12 @@ function withSemantic(fieldSchema, sem) {
|
|
|
384
390
|
match$3.field
|
|
385
391
|
]], match$3.plugin), "view", match$3.view), "content", match$3.content));
|
|
386
392
|
break;
|
|
393
|
+
case "IdentityOf" :
|
|
394
|
+
obj["x-reventless-semantic-target"] = Object.fromEntries([[
|
|
395
|
+
"key",
|
|
396
|
+
match.key
|
|
397
|
+
]]);
|
|
398
|
+
break;
|
|
387
399
|
}
|
|
388
400
|
}
|
|
389
401
|
return obj;
|
|
@@ -986,9 +986,12 @@ let queryableDefFromSpec = (
|
|
|
986
986
|
): Reventless.Plugin.queryableDef => {
|
|
987
987
|
let qf = Api_Naming.queryFieldNamesForReadModel(~plugin, ~name)
|
|
988
988
|
let label = labelFieldsFromStateSchema(~entityName=name, stateSchema)
|
|
989
|
-
// The
|
|
990
|
-
|
|
991
|
-
|
|
989
|
+
// The published key is always filterable; not every filterable field is the key.
|
|
990
|
+
let keyField = GraphQL_FragmentGenerator.resolveKeyField(
|
|
991
|
+
~rowKeying=RowKeyedByEnvelope,
|
|
992
|
+
~entityName=name,
|
|
993
|
+
stateSchema,
|
|
994
|
+
)
|
|
992
995
|
{
|
|
993
996
|
Reventless.Plugin.name,
|
|
994
997
|
queryField: qf.listFieldName,
|
|
@@ -1414,8 +1417,8 @@ let make = (
|
|
|
1414
1417
|
|
|
1415
1418
|
// Warned rather than refused: an unkeyed view still answers, just without a
|
|
1416
1419
|
// filter or an ordering, and a lint that stops a deploy gets silenced.
|
|
1417
|
-
let recordKeyField = (~entityName, stateSchema) =>
|
|
1418
|
-
GraphQL_FragmentGenerator.classifyKeyField(~entityName, stateSchema)
|
|
1420
|
+
let recordKeyField = (~rowKeying, ~entityName, stateSchema) =>
|
|
1421
|
+
GraphQL_FragmentGenerator.classifyKeyField(~rowKeying, ~entityName, stateSchema)
|
|
1419
1422
|
->GraphQL_FragmentGenerator.keyFieldGapMessage
|
|
1420
1423
|
->Option.forEach(why => log.warn(~comp="Plugin_Structure", `${name}/${entityName} ${why}`))
|
|
1421
1424
|
|
|
@@ -1425,14 +1428,17 @@ let make = (
|
|
|
1425
1428
|
let qf = Api_Naming.queryFieldNamesForReadModel(~plugin=name, ~name=R.Spec.name)
|
|
1426
1429
|
let stateSchema = R.Spec.stateSchema->S.castToUnknown
|
|
1427
1430
|
let label = labelFieldsFromStateSchema(~entityName=R.Spec.name, stateSchema)
|
|
1428
|
-
// The
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
+
// The published key is always filterable; not every filterable field is the key.
|
|
1432
|
+
let keyField = GraphQL_FragmentGenerator.resolveKeyField(
|
|
1433
|
+
~rowKeying=RowKeyedByEnvelope,
|
|
1434
|
+
~entityName=R.Spec.name,
|
|
1435
|
+
stateSchema,
|
|
1436
|
+
)
|
|
1431
1437
|
// Qualified to the plugin's event ids so they match the producers' nodes.
|
|
1432
1438
|
let consumed = qualify(~prefix=name, R.consumedEventNames)
|
|
1433
1439
|
recordRetired(~entityName=R.Spec.name, stateSchema)
|
|
1434
1440
|
recordLifecycle(~entityName=R.Spec.name, stateSchema)
|
|
1435
|
-
recordKeyField(~entityName=R.Spec.name, stateSchema)
|
|
1441
|
+
recordKeyField(~rowKeying=RowKeyedByEnvelope, ~entityName=R.Spec.name, stateSchema)
|
|
1436
1442
|
|
|
1437
1443
|
(
|
|
1438
1444
|
{
|
|
@@ -1471,12 +1477,13 @@ let make = (
|
|
|
1471
1477
|
let stateSchema = SVS.Spec.stateSchema->S.castToUnknown
|
|
1472
1478
|
let label = labelFieldsFromStateSchema(~entityName=SVS.Spec.name, stateSchema)
|
|
1473
1479
|
let keyField = GraphQL_FragmentGenerator.resolveKeyField(
|
|
1480
|
+
~rowKeying=RowKeyedByStateField,
|
|
1474
1481
|
~entityName=SVS.Spec.name,
|
|
1475
1482
|
stateSchema,
|
|
1476
1483
|
)
|
|
1477
1484
|
recordRetired(~entityName=SVS.Spec.name, stateSchema)
|
|
1478
1485
|
recordLifecycle(~entityName=SVS.Spec.name, stateSchema)
|
|
1479
|
-
recordKeyField(~entityName=SVS.Spec.name, stateSchema)
|
|
1486
|
+
recordKeyField(~rowKeying=RowKeyedByStateField, ~entityName=SVS.Spec.name, stateSchema)
|
|
1480
1487
|
|
|
1481
1488
|
(
|
|
1482
1489
|
{
|
|
@@ -706,7 +706,7 @@ function queryableDefFromSpec(plugin, name, stateSchema, authorization, visibili
|
|
|
706
706
|
let linkedWriteSide = linkedWriteSideOpt !== undefined ? linkedWriteSideOpt : [];
|
|
707
707
|
let qf = Api_Naming$ReventlessCore.queryFieldNamesForReadModel(plugin, name, undefined);
|
|
708
708
|
let label = labelFieldsFromStateSchema(name, stateSchema);
|
|
709
|
-
let keyField = GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField(name, stateSchema);
|
|
709
|
+
let keyField = GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("RowKeyedByEnvelope", name, stateSchema);
|
|
710
710
|
return {
|
|
711
711
|
name: name,
|
|
712
712
|
queryField: qf.listFieldName,
|
|
@@ -1006,16 +1006,16 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
|
|
|
1006
1006
|
retiredFailures.push(f);
|
|
1007
1007
|
});
|
|
1008
1008
|
};
|
|
1009
|
-
let recordKeyField = (entityName, stateSchema) => Stdlib_Option.forEach(GraphQL_FragmentGenerator$ReventlessCore.keyFieldGapMessage(GraphQL_FragmentGenerator$ReventlessCore.classifyKeyField(entityName, stateSchema)), why => log.warn("Plugin_Structure", undefined, name + `/` + entityName + ` ` + why));
|
|
1009
|
+
let recordKeyField = (rowKeying, entityName, stateSchema) => Stdlib_Option.forEach(GraphQL_FragmentGenerator$ReventlessCore.keyFieldGapMessage(GraphQL_FragmentGenerator$ReventlessCore.classifyKeyField(rowKeying, entityName, stateSchema)), why => log.warn("Plugin_Structure", undefined, name + `/` + entityName + ` ` + why));
|
|
1010
1010
|
let readModelDefs = readModels.map(R => {
|
|
1011
1011
|
let qf = Api_Naming$ReventlessCore.queryFieldNamesForReadModel(name, R.Spec.name, undefined);
|
|
1012
1012
|
let stateSchema = R.Spec.stateSchema;
|
|
1013
1013
|
let label = labelFieldsFromStateSchema(R.Spec.name, stateSchema);
|
|
1014
|
-
let keyField = GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField(R.Spec.name, stateSchema);
|
|
1014
|
+
let keyField = GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("RowKeyedByEnvelope", R.Spec.name, stateSchema);
|
|
1015
1015
|
let consumed = qualify(name, R.consumedEventNames);
|
|
1016
1016
|
recordRetired(R.Spec.name, stateSchema);
|
|
1017
1017
|
recordLifecycle(R.Spec.name, stateSchema);
|
|
1018
|
-
recordKeyField(R.Spec.name, stateSchema);
|
|
1018
|
+
recordKeyField("RowKeyedByEnvelope", R.Spec.name, stateSchema);
|
|
1019
1019
|
return {
|
|
1020
1020
|
name: R.Spec.name,
|
|
1021
1021
|
queryField: qf.listFieldName,
|
|
@@ -1044,10 +1044,10 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
|
|
|
1044
1044
|
let consumed = match[1];
|
|
1045
1045
|
let stateSchema = SVS.Spec.stateSchema;
|
|
1046
1046
|
let label = labelFieldsFromStateSchema(SVS.Spec.name, stateSchema);
|
|
1047
|
-
let keyField = GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField(SVS.Spec.name, stateSchema);
|
|
1047
|
+
let keyField = GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("RowKeyedByStateField", SVS.Spec.name, stateSchema);
|
|
1048
1048
|
recordRetired(SVS.Spec.name, stateSchema);
|
|
1049
1049
|
recordLifecycle(SVS.Spec.name, stateSchema);
|
|
1050
|
-
recordKeyField(SVS.Spec.name, stateSchema);
|
|
1050
|
+
recordKeyField("RowKeyedByStateField", SVS.Spec.name, stateSchema);
|
|
1051
1051
|
return {
|
|
1052
1052
|
name: SVS.Spec.name,
|
|
1053
1053
|
queryField: qf.listFieldName,
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
open JestGlobals
|
|
2
2
|
|
|
3
|
+
module OrderId = Reventless.Id.Make({
|
|
4
|
+
let key = "orderId"
|
|
5
|
+
})
|
|
6
|
+
module CustomerId = Reventless.Id.Make({
|
|
7
|
+
let key = "customerId"
|
|
8
|
+
})
|
|
9
|
+
|
|
3
10
|
// A multi-variant command union serializes as a sury `Union`, the same shape an
|
|
4
11
|
// aggregate command produces. The generator must decide whether to inject the
|
|
5
12
|
// aggregate-style `id: ID!` argument from the entry's `injectIdArg` flag, not
|
|
@@ -94,7 +101,11 @@ describe("GraphQL_FragmentGenerator slice TODO rows", () => {
|
|
|
94
101
|
// common cases; these pin which cases those are, and which still need `@id`.
|
|
95
102
|
describe("resolveKeyField — the ladder", () => {
|
|
96
103
|
let rungFor = (~entityName, schema) =>
|
|
97
|
-
GraphQL_FragmentGenerator.resolveKeyField(
|
|
104
|
+
GraphQL_FragmentGenerator.resolveKeyField(
|
|
105
|
+
~rowKeying=RowKeyedByStateField,
|
|
106
|
+
~entityName,
|
|
107
|
+
schema->S.castToUnknown,
|
|
108
|
+
)
|
|
98
109
|
|
|
99
110
|
testSync("a lone `*Id` field is the only thing the key could be", () => {
|
|
100
111
|
let schema = S.schema(s => {"productId": s.matches(S.string), "name": s.matches(S.string)})
|
|
@@ -137,12 +148,65 @@ describe("resolveKeyField — the ladder", () => {
|
|
|
137
148
|
})
|
|
138
149
|
})
|
|
139
150
|
|
|
151
|
+
// A read model's row is keyed by the envelope id of the events it folds, so its
|
|
152
|
+
// lone `*Id` field is a foreign id: `Orders = {customerId, …}` is not keyed by the
|
|
153
|
+
// customer.
|
|
154
|
+
describe("resolveKeyField — a read model is keyed by its envelope", () => {
|
|
155
|
+
let keyFor = (~entityName, schema) =>
|
|
156
|
+
GraphQL_FragmentGenerator.resolveKeyField(
|
|
157
|
+
~rowKeying=RowKeyedByEnvelope,
|
|
158
|
+
~entityName,
|
|
159
|
+
schema->S.castToUnknown,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
testSync("a lone foreign `*Id` field does not name the key", () => {
|
|
163
|
+
let schema = S.schema(
|
|
164
|
+
s => {"customerId": s.matches(S.string), "lifecycle": s.matches(S.string)},
|
|
165
|
+
)
|
|
166
|
+
expect(keyFor(~entityName="Orders", schema))->toEqual(None)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
testSync("the convention rung still names it", () => {
|
|
170
|
+
let schema = S.schema(s => {"orderId": s.matches(S.string), "customerId": s.matches(S.string)})
|
|
171
|
+
expect(keyFor(~entityName="Orders", schema))->toEqual(Some(("orderId", "convention")))
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
// The key is read off the type where there is one: a field typed as the view's
|
|
175
|
+
// own identity keys it whatever it is called, and one typed as another
|
|
176
|
+
// identity never does, whatever it is called.
|
|
177
|
+
testSync("a field typed as the view's identity is its key", () => {
|
|
178
|
+
let schema = S.schema(
|
|
179
|
+
s => {"order": s.matches(OrderId.schema), "customerId": s.matches(CustomerId.schema)},
|
|
180
|
+
)
|
|
181
|
+
expect(keyFor(~entityName="Orders", schema))->toEqual(Some(("order", "convention")))
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
testSync("a field named for the view but typed as another identity is not", () => {
|
|
185
|
+
let schema = S.schema(s => {"orderId": s.matches(CustomerId.schema)})
|
|
186
|
+
expect(keyFor(~entityName="Orders", schema))->toEqual(None)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
testSync("several foreign `*Id` fields are not a gap worth a warning", () => {
|
|
190
|
+
let schema = S.schema(
|
|
191
|
+
s => {"productId": s.matches(S.string), "categoryId": s.matches(S.string)},
|
|
192
|
+
)
|
|
193
|
+
expect(
|
|
194
|
+
GraphQL_FragmentGenerator.classifyKeyField(
|
|
195
|
+
~rowKeying=RowKeyedByEnvelope,
|
|
196
|
+
~entityName="ProductDemand",
|
|
197
|
+
schema->S.castToUnknown,
|
|
198
|
+
)->GraphQL_FragmentGenerator.keyFieldGapMessage,
|
|
199
|
+
)->toEqual(None)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
140
203
|
// Losing the key is invisible — the view keeps every field and every row, and
|
|
141
204
|
// drops its `<field>Eq` filter and its whole `orderBy` from the SDL. These pin
|
|
142
205
|
// which of the two ways to lose it is worth saying out loud.
|
|
143
206
|
describe("keyFieldGapMessage — which gap is worth a warning", () => {
|
|
144
207
|
let gapFor = (~entityName, schema) =>
|
|
145
208
|
GraphQL_FragmentGenerator.classifyKeyField(
|
|
209
|
+
~rowKeying=RowKeyedByStateField,
|
|
146
210
|
~entityName,
|
|
147
211
|
schema->S.castToUnknown,
|
|
148
212
|
)->GraphQL_FragmentGenerator.keyFieldGapMessage
|
|
@@ -197,6 +261,17 @@ describe("deriveServerCapability — inferred keys reach the SDL surface", () =>
|
|
|
197
261
|
expect(c.sortFields)->toEqual(["orderId"])
|
|
198
262
|
})
|
|
199
263
|
|
|
264
|
+
// Not the key of a read model, but filterable all the same: removing a
|
|
265
|
+
// generated filter is an SDL break.
|
|
266
|
+
testSync("a lone foreign `*Id` field stays filterable and sortable", () => {
|
|
267
|
+
let schema = S.schema(
|
|
268
|
+
s => {"customerId": s.matches(S.string), "lifecycle": s.matches(S.string)},
|
|
269
|
+
)
|
|
270
|
+
let c = capabilityFor(~entityName="Orders", schema)
|
|
271
|
+
expect(c.filterFields->Array.map(f => f.name))->toEqual(["customerId"])
|
|
272
|
+
expect(c.sortFields)->toEqual(["customerId"])
|
|
273
|
+
})
|
|
274
|
+
|
|
200
275
|
testSync("an unresolvable key still yields nothing — the old behaviour, now narrowed", () => {
|
|
201
276
|
let schema = S.schema(s => {"email": s.matches(S.string)})
|
|
202
277
|
let c = capabilityFor(~entityName="Customers", schema)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
3
|
import * as Sury from "sury";
|
|
4
|
+
import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
|
|
4
5
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
6
|
import * as Money$Reventless from "@reventlessdev/reventless-spec/src/semantic/Money.res.mjs";
|
|
6
7
|
import * as GraphQL_Stitcher$ReventlessCore from "../../src/components/Api/GraphQL_Stitcher.res.mjs";
|
|
@@ -9,6 +10,14 @@ import * as GraphQL_FragmentGenerator$ReventlessCore from "../../src/components/
|
|
|
9
10
|
import * as PsInternalFieldsReadModel$ReventlessCore from "./PsInternalFieldsReadModel.res.mjs";
|
|
10
11
|
import * as OutboundTranslationSlice_Callback$ReventlessCore from "../../src/components/OutboundTranslationSlice/OutboundTranslationSlice_Callback.res.mjs";
|
|
11
12
|
|
|
13
|
+
let OrderId = Id$Reventless.Make({
|
|
14
|
+
key: "orderId"
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
let CustomerId = Id$Reventless.Make({
|
|
18
|
+
key: "customerId"
|
|
19
|
+
});
|
|
20
|
+
|
|
12
21
|
let commandSchema = Sury.union([
|
|
13
22
|
Sury.$schema(s => ({
|
|
14
23
|
TAG: "CancelOrder",
|
|
@@ -100,7 +109,7 @@ globalThis.describe("resolveKeyField — the ladder", () => {
|
|
|
100
109
|
productId: s.m(Sury.string),
|
|
101
110
|
name: s.m(Sury.string)
|
|
102
111
|
}));
|
|
103
|
-
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("AvailableProducts", schema)).toEqual([
|
|
112
|
+
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("RowKeyedByStateField", "AvailableProducts", schema)).toEqual([
|
|
104
113
|
"productId",
|
|
105
114
|
"sole"
|
|
106
115
|
]);
|
|
@@ -110,7 +119,7 @@ globalThis.describe("resolveKeyField — the ladder", () => {
|
|
|
110
119
|
productId: s.m(Sury.string),
|
|
111
120
|
categoryId: s.m(Sury.string)
|
|
112
121
|
}));
|
|
113
|
-
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("Products", schema)).toEqual([
|
|
122
|
+
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("RowKeyedByStateField", "Products", schema)).toEqual([
|
|
114
123
|
"productId",
|
|
115
124
|
"convention"
|
|
116
125
|
]);
|
|
@@ -120,7 +129,7 @@ globalThis.describe("resolveKeyField — the ladder", () => {
|
|
|
120
129
|
categoryId: s.m(Sury.string),
|
|
121
130
|
ownerId: s.m(Sury.string)
|
|
122
131
|
}));
|
|
123
|
-
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("Categories", schema)).toEqual([
|
|
132
|
+
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("RowKeyedByStateField", "Categories", schema)).toEqual([
|
|
124
133
|
"categoryId",
|
|
125
134
|
"convention"
|
|
126
135
|
]);
|
|
@@ -130,53 +139,94 @@ globalThis.describe("resolveKeyField — the ladder", () => {
|
|
|
130
139
|
productId: s.m(Sury.string),
|
|
131
140
|
categoryId: s.m(Sury.string)
|
|
132
141
|
}));
|
|
133
|
-
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("ProductDemand", schema)).toEqual(undefined);
|
|
142
|
+
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("RowKeyedByStateField", "ProductDemand", schema)).toEqual(undefined);
|
|
134
143
|
});
|
|
135
144
|
globalThis.test("no `*Id` field at all declines", () => {
|
|
136
145
|
let schema = Sury.$schema(s => ({
|
|
137
146
|
email: s.m(Sury.string),
|
|
138
147
|
address: s.m(Sury.string)
|
|
139
148
|
}));
|
|
140
|
-
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("Customers", schema)).toEqual(undefined);
|
|
149
|
+
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("RowKeyedByStateField", "Customers", schema)).toEqual(undefined);
|
|
141
150
|
});
|
|
142
151
|
globalThis.test("a word merely ending in `id` is not a key", () => {
|
|
143
152
|
let schema = Sury.$schema(s => ({
|
|
144
153
|
paid: s.m(Sury.string),
|
|
145
154
|
valid: s.m(Sury.string)
|
|
146
155
|
}));
|
|
147
|
-
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("Invoices", schema)).toEqual(undefined);
|
|
156
|
+
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("RowKeyedByStateField", "Invoices", schema)).toEqual(undefined);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
globalThis.describe("resolveKeyField — a read model is keyed by its envelope", () => {
|
|
161
|
+
globalThis.test("a lone foreign `*Id` field does not name the key", () => {
|
|
162
|
+
let schema = Sury.$schema(s => ({
|
|
163
|
+
customerId: s.m(Sury.string),
|
|
164
|
+
lifecycle: s.m(Sury.string)
|
|
165
|
+
}));
|
|
166
|
+
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("RowKeyedByEnvelope", "Orders", schema)).toEqual(undefined);
|
|
167
|
+
});
|
|
168
|
+
globalThis.test("the convention rung still names it", () => {
|
|
169
|
+
let schema = Sury.$schema(s => ({
|
|
170
|
+
orderId: s.m(Sury.string),
|
|
171
|
+
customerId: s.m(Sury.string)
|
|
172
|
+
}));
|
|
173
|
+
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("RowKeyedByEnvelope", "Orders", schema)).toEqual([
|
|
174
|
+
"orderId",
|
|
175
|
+
"convention"
|
|
176
|
+
]);
|
|
177
|
+
});
|
|
178
|
+
globalThis.test("a field typed as the view's identity is its key", () => {
|
|
179
|
+
let schema = Sury.$schema(s => ({
|
|
180
|
+
order: s.m(OrderId.schema),
|
|
181
|
+
customerId: s.m(CustomerId.schema)
|
|
182
|
+
}));
|
|
183
|
+
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("RowKeyedByEnvelope", "Orders", schema)).toEqual([
|
|
184
|
+
"order",
|
|
185
|
+
"convention"
|
|
186
|
+
]);
|
|
187
|
+
});
|
|
188
|
+
globalThis.test("a field named for the view but typed as another identity is not", () => {
|
|
189
|
+
let schema = Sury.$schema(s => ({
|
|
190
|
+
orderId: s.m(CustomerId.schema)
|
|
191
|
+
}));
|
|
192
|
+
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("RowKeyedByEnvelope", "Orders", schema)).toEqual(undefined);
|
|
193
|
+
});
|
|
194
|
+
globalThis.test("several foreign `*Id` fields are not a gap worth a warning", () => {
|
|
195
|
+
let schema = Sury.$schema(s => ({
|
|
196
|
+
productId: s.m(Sury.string),
|
|
197
|
+
categoryId: s.m(Sury.string)
|
|
198
|
+
}));
|
|
199
|
+
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.keyFieldGapMessage(GraphQL_FragmentGenerator$ReventlessCore.classifyKeyField("RowKeyedByEnvelope", "ProductDemand", schema))).toEqual(undefined);
|
|
148
200
|
});
|
|
149
201
|
});
|
|
150
202
|
|
|
151
203
|
globalThis.describe("keyFieldGapMessage — which gap is worth a warning", () => {
|
|
204
|
+
let gapFor = (entityName, schema) => GraphQL_FragmentGenerator$ReventlessCore.keyFieldGapMessage(GraphQL_FragmentGenerator$ReventlessCore.classifyKeyField("RowKeyedByStateField", entityName, schema));
|
|
152
205
|
globalThis.test("a second `*Id` field taking the key away is named, with both fields", () => {
|
|
153
206
|
let schema = Sury.$schema(s => ({
|
|
154
207
|
productId: s.m(Sury.string),
|
|
155
208
|
categoryId: s.m(Sury.string)
|
|
156
209
|
}));
|
|
157
|
-
let message = Stdlib_Option.getOr(
|
|
210
|
+
let message = Stdlib_Option.getOr(gapFor("ProductDemand", schema), "");
|
|
158
211
|
globalThis.expect(message.includes("productId, categoryId")).toBe(true);
|
|
159
212
|
globalThis.expect(message.includes("productDemandId")).toBe(true);
|
|
160
213
|
});
|
|
161
214
|
globalThis.test("a state with no `*Id` field at all is silent, not warned about", () => {
|
|
162
|
-
|
|
215
|
+
globalThis.expect(gapFor("Customers", Sury.$schema(s => ({
|
|
163
216
|
email: s.m(Sury.string),
|
|
164
217
|
address: s.m(Sury.string)
|
|
165
|
-
}));
|
|
166
|
-
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.keyFieldGapMessage(GraphQL_FragmentGenerator$ReventlessCore.classifyKeyField("Customers", schema))).toEqual(undefined);
|
|
218
|
+
})))).toEqual(undefined);
|
|
167
219
|
});
|
|
168
220
|
globalThis.test("a resolved key says nothing", () => {
|
|
169
|
-
|
|
221
|
+
globalThis.expect(gapFor("Orders", Sury.$schema(s => ({
|
|
170
222
|
orderId: s.m(Sury.string)
|
|
171
|
-
}));
|
|
172
|
-
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.keyFieldGapMessage(GraphQL_FragmentGenerator$ReventlessCore.classifyKeyField("Orders", schema))).toEqual(undefined);
|
|
223
|
+
})))).toEqual(undefined);
|
|
173
224
|
});
|
|
174
225
|
globalThis.test("an @id-less view whose name picks one of its `*Id` fields is fine", () => {
|
|
175
|
-
|
|
226
|
+
globalThis.expect(gapFor("Products", Sury.$schema(s => ({
|
|
176
227
|
productId: s.m(Sury.string),
|
|
177
228
|
categoryId: s.m(Sury.string)
|
|
178
|
-
}));
|
|
179
|
-
globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.keyFieldGapMessage(GraphQL_FragmentGenerator$ReventlessCore.classifyKeyField("Products", schema))).toEqual(undefined);
|
|
229
|
+
})))).toEqual(undefined);
|
|
180
230
|
});
|
|
181
231
|
});
|
|
182
232
|
|
|
@@ -190,6 +240,15 @@ globalThis.describe("deriveServerCapability — inferred keys reach the SDL surf
|
|
|
190
240
|
globalThis.expect(c.filterFields.map(f => f.name)).toEqual(["orderId"]);
|
|
191
241
|
globalThis.expect(c.sortFields).toEqual(["orderId"]);
|
|
192
242
|
});
|
|
243
|
+
globalThis.test("a lone foreign `*Id` field stays filterable and sortable", () => {
|
|
244
|
+
let schema = Sury.$schema(s => ({
|
|
245
|
+
customerId: s.m(Sury.string),
|
|
246
|
+
lifecycle: s.m(Sury.string)
|
|
247
|
+
}));
|
|
248
|
+
let c = GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability("Orders", schema);
|
|
249
|
+
globalThis.expect(c.filterFields.map(f => f.name)).toEqual(["customerId"]);
|
|
250
|
+
globalThis.expect(c.sortFields).toEqual(["customerId"]);
|
|
251
|
+
});
|
|
193
252
|
globalThis.test("an unresolvable key still yields nothing — the old behaviour, now narrowed", () => {
|
|
194
253
|
let schema = Sury.$schema(s => ({
|
|
195
254
|
email: s.m(Sury.string)
|
|
@@ -668,6 +727,8 @@ globalThis.describe("@resolves / @resolvesMany reach the SDL", () => {
|
|
|
668
727
|
});
|
|
669
728
|
|
|
670
729
|
export {
|
|
730
|
+
OrderId,
|
|
731
|
+
CustomerId,
|
|
671
732
|
OrderSliceCmd,
|
|
672
733
|
mutationFor,
|
|
673
734
|
GeocodeItem,
|
|
@@ -680,4 +741,4 @@ export {
|
|
|
680
741
|
refDoorFragment,
|
|
681
742
|
ResolvedFields,
|
|
682
743
|
}
|
|
683
|
-
/*
|
|
744
|
+
/* OrderId Not a pure module */
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// An identity-typed field is an id whatever it is called: `buyer: CustomerId.t`
|
|
2
|
+
// must reach the SDL as `ID!` and carry its key on the JSON Schema, or a field
|
|
3
|
+
// that is neither tagged nor referenced loses its id-ness on the wire.
|
|
4
|
+
|
|
5
|
+
open JestGlobals
|
|
6
|
+
|
|
7
|
+
module OrderId = Reventless.Id.Make({
|
|
8
|
+
let key = "orderId"
|
|
9
|
+
})
|
|
10
|
+
module CustomerId = Reventless.Id.Make({
|
|
11
|
+
let key = "customerId"
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
@schema
|
|
15
|
+
type order = {
|
|
16
|
+
buyer: CustomerId.t,
|
|
17
|
+
previous: option<OrderId.t>,
|
|
18
|
+
related: array<OrderId.t>,
|
|
19
|
+
note: string,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let property = (json: JSON.t, name: string): option<JSON.t> =>
|
|
23
|
+
json
|
|
24
|
+
->JSON.Decode.object
|
|
25
|
+
->Option.flatMap(o => o->Dict.get("properties"))
|
|
26
|
+
->Option.flatMap(JSON.Decode.object)
|
|
27
|
+
->Option.flatMap(p => p->Dict.get(name))
|
|
28
|
+
|
|
29
|
+
let key = (json: option<JSON.t>, name: string): option<JSON.t> =>
|
|
30
|
+
json->Option.flatMap(JSON.Decode.object)->Option.flatMap(o => o->Dict.get(name))
|
|
31
|
+
|
|
32
|
+
describe("the identity semantic on the JSON Schema", () => {
|
|
33
|
+
let json = SuryToJsonSchema.deriveObjectSchema(orderSchema->S.castToUnknown)
|
|
34
|
+
|
|
35
|
+
testSync("names the semantic and its key", () => {
|
|
36
|
+
let buyer = json->property("buyer")
|
|
37
|
+
expect(buyer->key("x-reventless-semantic"))->toEqual(Some(JSON.Encode.string("identity")))
|
|
38
|
+
expect(buyer->key("x-reventless-semantic-target"))->toEqual(
|
|
39
|
+
Some(JSON.Encode.object(Dict.fromArray([("key", JSON.Encode.string("customerId"))]))),
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
testSync("reaches an array's elements", () =>
|
|
44
|
+
expect(
|
|
45
|
+
json
|
|
46
|
+
->property("related")
|
|
47
|
+
->key("items")
|
|
48
|
+
->key("x-reventless-semantic-target")
|
|
49
|
+
->key("key"),
|
|
50
|
+
)->toEqual(Some(JSON.Encode.string("orderId")))
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
testSync("leaves a plain string alone", () =>
|
|
54
|
+
expect(json->property("note")->key("x-reventless-semantic"))->toEqual(None)
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe("the identity semantic in the SDL", () => {
|
|
59
|
+
let sdl =
|
|
60
|
+
GraphQL_FragmentGenerator.deriveObjectTypeWithNested(
|
|
61
|
+
~typeName="Ordering_Order",
|
|
62
|
+
~includeIdParam=false,
|
|
63
|
+
orderSchema->S.castToUnknown,
|
|
64
|
+
)->Array.join("\n")
|
|
65
|
+
|
|
66
|
+
testSync("an identity field not named `*Id` is an ID", () =>
|
|
67
|
+
expect(sdl->String.includes("buyer: ID!"))->toBe(true)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
testSync("an optional identity keeps its optionality", () =>
|
|
71
|
+
expect(sdl->String.includes("previous: ID\n"))->toBe(true)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
testSync("an array of identities is a list of IDs", () =>
|
|
75
|
+
expect(sdl->String.includes("related: [ID!]!"))->toBe(true)
|
|
76
|
+
)
|
|
77
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
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 Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
|
|
6
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
7
|
+
import * as SuryToJsonSchema$ReventlessCore from "../../src/components/Api/SuryToJsonSchema.res.mjs";
|
|
8
|
+
import * as GraphQL_FragmentGenerator$ReventlessCore from "../../src/components/Api/GraphQL_FragmentGenerator.res.mjs";
|
|
9
|
+
|
|
10
|
+
let OrderId = Id$Reventless.Make({
|
|
11
|
+
key: "orderId"
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
let CustomerId = Id$Reventless.Make({
|
|
15
|
+
key: "customerId"
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
let orderSchema = Sury.$schema(s => ({
|
|
19
|
+
buyer: s.m(CustomerId.schema),
|
|
20
|
+
previous: s.m(Sury.$option(OrderId.schema)),
|
|
21
|
+
related: s.m(Sury.array(OrderId.schema)),
|
|
22
|
+
note: s.m(Sury.string)
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
function property(json, name) {
|
|
26
|
+
return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), o => o["properties"]), Stdlib_JSON.Decode.object), p => p[name]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function key(json, name) {
|
|
30
|
+
return Stdlib_Option.flatMap(Stdlib_Option.flatMap(json, Stdlib_JSON.Decode.object), o => o[name]);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
globalThis.describe("the identity semantic on the JSON Schema", () => {
|
|
34
|
+
let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(undefined, undefined, orderSchema);
|
|
35
|
+
globalThis.test("names the semantic and its key", () => {
|
|
36
|
+
let buyer = property(json, "buyer");
|
|
37
|
+
globalThis.expect(key(buyer, "x-reventless-semantic")).toEqual("identity");
|
|
38
|
+
globalThis.expect(key(buyer, "x-reventless-semantic-target")).toEqual(Object.fromEntries([[
|
|
39
|
+
"key",
|
|
40
|
+
"customerId"
|
|
41
|
+
]]));
|
|
42
|
+
});
|
|
43
|
+
globalThis.test("reaches an array's elements", () => {
|
|
44
|
+
globalThis.expect(key(key(key(property(json, "related"), "items"), "x-reventless-semantic-target"), "key")).toEqual("orderId");
|
|
45
|
+
});
|
|
46
|
+
globalThis.test("leaves a plain string alone", () => {
|
|
47
|
+
globalThis.expect(key(property(json, "note"), "x-reventless-semantic")).toEqual(undefined);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
globalThis.describe("the identity semantic in the SDL", () => {
|
|
52
|
+
let sdl = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested("Ordering_Order", undefined, false, undefined, orderSchema).join("\n");
|
|
53
|
+
globalThis.test("an identity field not named `*Id` is an ID", () => {
|
|
54
|
+
globalThis.expect(sdl.includes("buyer: ID!")).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
globalThis.test("an optional identity keeps its optionality", () => {
|
|
57
|
+
globalThis.expect(sdl.includes("previous: ID\n")).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
globalThis.test("an array of identities is a list of IDs", () => {
|
|
60
|
+
globalThis.expect(sdl.includes("related: [ID!]!")).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export {
|
|
65
|
+
OrderId,
|
|
66
|
+
CustomerId,
|
|
67
|
+
orderSchema,
|
|
68
|
+
property,
|
|
69
|
+
key,
|
|
70
|
+
}
|
|
71
|
+
/* OrderId Not a pure module */
|