@jskit-ai/crud-core 0.1.26 → 0.1.28
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/package.descriptor.mjs +2 -2
- package/package.json +17 -7
- package/src/client/composables/crudClientSupportHelpers.js +5 -17
- package/src/server/createCrudRepositoryFromResource.js +57 -0
- package/src/server/createCrudServiceFromResource.js +101 -0
- package/src/server/crudModuleConfig.js +13 -21
- package/src/server/fieldAccess.js +316 -0
- package/src/server/listQueryValidators.js +87 -0
- package/src/server/lookupHydration.js +546 -0
- package/src/server/lookupPathSupport.js +45 -0
- package/src/server/lookupProviders.js +43 -0
- package/src/server/repositoryMethods.js +381 -0
- package/src/server/repositorySupport.js +205 -0
- package/src/server/serviceEvents.js +53 -0
- package/src/shared/crudFieldMetaSupport.js +54 -0
- package/src/shared/crudNamespaceSupport.js +31 -0
- package/test/createCrudRepositoryFromResource.test.js +731 -0
- package/test/createCrudServiceFromResource.test.js +263 -0
- package/test/crudFieldMetaSupport.test.js +47 -0
- package/test/fieldAccess.test.js +86 -0
- package/test/listQueryValidators.test.js +162 -0
- package/test/lookupProviders.test.js +103 -0
- package/test/repositorySupport.test.js +282 -1
- package/test/serviceEvents.test.js +28 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { normalizeObjectInput } from "@jskit-ai/kernel/shared/validators";
|
|
3
|
+
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
4
|
+
import { resolveCrudLookupFieldKeys } from "@jskit-ai/kernel/shared/support/crudLookup";
|
|
5
|
+
|
|
6
|
+
const listSearchQueryValidator = Object.freeze({
|
|
7
|
+
schema: Type.Object(
|
|
8
|
+
{
|
|
9
|
+
q: Type.Optional(Type.String({ minLength: 0 }))
|
|
10
|
+
},
|
|
11
|
+
{ additionalProperties: false }
|
|
12
|
+
),
|
|
13
|
+
normalize(payload = {}) {
|
|
14
|
+
const source = normalizeObjectInput(payload);
|
|
15
|
+
if (!Object.hasOwn(source, "q")) {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
q: normalizeText(source.q)
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const lookupIncludeQueryValidator = Object.freeze({
|
|
26
|
+
schema: Type.Object(
|
|
27
|
+
{
|
|
28
|
+
include: Type.Optional(Type.String({ minLength: 0 }))
|
|
29
|
+
},
|
|
30
|
+
{ additionalProperties: false }
|
|
31
|
+
),
|
|
32
|
+
normalize(payload = {}) {
|
|
33
|
+
const source = normalizeObjectInput(payload);
|
|
34
|
+
if (!Object.hasOwn(source, "include")) {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
include: normalizeText(source.include)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function resolveCrudParentFilterKeys(resource = {}) {
|
|
45
|
+
const createSchemaProperties = resource?.operations?.create?.bodyValidator?.schema?.properties;
|
|
46
|
+
const allowedKeys = createSchemaProperties && typeof createSchemaProperties === "object" && !Array.isArray(createSchemaProperties)
|
|
47
|
+
? Object.keys(createSchemaProperties)
|
|
48
|
+
: [];
|
|
49
|
+
return resolveCrudLookupFieldKeys(resource, {
|
|
50
|
+
allowKeys: allowedKeys
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function createCrudParentFilterQueryValidator(resource = {}) {
|
|
55
|
+
const keys = resolveCrudParentFilterKeys(resource);
|
|
56
|
+
const schemaProperties = {};
|
|
57
|
+
for (const key of keys) {
|
|
58
|
+
schemaProperties[key] = Type.Optional(Type.String({ minLength: 1 }));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return Object.freeze({
|
|
62
|
+
schema: Type.Object(schemaProperties, { additionalProperties: false }),
|
|
63
|
+
normalize(payload = {}) {
|
|
64
|
+
const source = normalizeObjectInput(payload);
|
|
65
|
+
const normalized = {};
|
|
66
|
+
for (const key of keys) {
|
|
67
|
+
if (!Object.hasOwn(source, key)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const value = normalizeText(source[key]);
|
|
72
|
+
if (value) {
|
|
73
|
+
normalized[key] = value;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return normalized;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export {
|
|
83
|
+
listSearchQueryValidator,
|
|
84
|
+
lookupIncludeQueryValidator,
|
|
85
|
+
resolveCrudParentFilterKeys,
|
|
86
|
+
createCrudParentFilterQueryValidator
|
|
87
|
+
};
|
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
import { normalizeOpaqueId, normalizeText, normalizeUniqueTextList } from "@jskit-ai/kernel/shared/support/normalize";
|
|
2
|
+
import {
|
|
3
|
+
normalizeCrudLookupNamespace,
|
|
4
|
+
resolveCrudLookupApiPathFromNamespace,
|
|
5
|
+
normalizeCrudLookupContainerKey,
|
|
6
|
+
resolveCrudLookupContainerKey
|
|
7
|
+
} from "@jskit-ai/kernel/shared/support/crudLookup";
|
|
8
|
+
import { normalizeCrudLookupApiPath } from "./lookupPathSupport.js";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_LOOKUP_INCLUDE = "*";
|
|
11
|
+
const DEFAULT_LOOKUP_MAX_DEPTH = 3;
|
|
12
|
+
const MAX_LOOKUP_MAX_DEPTH = 10;
|
|
13
|
+
|
|
14
|
+
function normalizeLookupRelationEntry(entry = {}, outputKeys = new Set()) {
|
|
15
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const key = normalizeText(entry.key);
|
|
20
|
+
if (!key || (outputKeys instanceof Set && !outputKeys.has(key))) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const relation = entry.relation;
|
|
25
|
+
if (!relation || typeof relation !== "object" || Array.isArray(relation)) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const relationKind = normalizeText(relation.kind).toLowerCase();
|
|
30
|
+
if (relationKind !== "lookup") {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const namespace =
|
|
35
|
+
normalizeCrudLookupNamespace(relation.namespace) ||
|
|
36
|
+
normalizeCrudLookupNamespace(relation.apiPath);
|
|
37
|
+
if (!namespace) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const explicitApiPath = normalizeCrudLookupApiPath(relation.apiPath);
|
|
41
|
+
const apiPath = explicitApiPath || resolveCrudLookupApiPathFromNamespace(namespace);
|
|
42
|
+
|
|
43
|
+
const valueKey = normalizeText(relation.valueKey) || "id";
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
key,
|
|
47
|
+
relation: {
|
|
48
|
+
kind: "lookup",
|
|
49
|
+
namespace,
|
|
50
|
+
apiPath,
|
|
51
|
+
valueKey,
|
|
52
|
+
hydrateOnList: relation.hydrateOnList !== false,
|
|
53
|
+
hydrateOnView: relation.hydrateOnView !== false
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeLookupDefaultInclude(value) {
|
|
59
|
+
const normalized = normalizeText(value);
|
|
60
|
+
if (!normalized) {
|
|
61
|
+
return DEFAULT_LOOKUP_INCLUDE;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (normalized.toLowerCase() === "none") {
|
|
65
|
+
return "none";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return normalized;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeLookupMaxDepth(value) {
|
|
72
|
+
const parsed = Number(value);
|
|
73
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
74
|
+
return DEFAULT_LOOKUP_MAX_DEPTH;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return Math.min(parsed, MAX_LOOKUP_MAX_DEPTH);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function resolveLookupRuntimeDefaults(resource = {}) {
|
|
81
|
+
const lookupContract = resource?.contract?.lookup;
|
|
82
|
+
if (
|
|
83
|
+
lookupContract !== undefined &&
|
|
84
|
+
lookupContract !== null &&
|
|
85
|
+
(typeof lookupContract !== "object" || Array.isArray(lookupContract))
|
|
86
|
+
) {
|
|
87
|
+
throw new TypeError("crud lookup runtime requires resource.contract.lookup to be an object when provided.");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
defaultInclude: normalizeLookupDefaultInclude(lookupContract?.defaultInclude),
|
|
92
|
+
maxDepth: normalizeLookupMaxDepth(lookupContract?.maxDepth)
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function createCrudLookupRuntime(resource = {}, { outputKeys = [] } = {}) {
|
|
97
|
+
const outputKeySet = new Set(
|
|
98
|
+
(Array.isArray(outputKeys) ? outputKeys : [])
|
|
99
|
+
.map((key) => normalizeText(key))
|
|
100
|
+
.filter(Boolean)
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const sourceEntries = Array.isArray(resource?.fieldMeta) ? resource.fieldMeta : [];
|
|
104
|
+
const lookupEntries = [];
|
|
105
|
+
const seenKeys = new Set();
|
|
106
|
+
|
|
107
|
+
for (const entry of sourceEntries) {
|
|
108
|
+
const normalizedEntry = normalizeLookupRelationEntry(entry, outputKeySet);
|
|
109
|
+
if (!normalizedEntry) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (seenKeys.has(normalizedEntry.key)) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
seenKeys.add(normalizedEntry.key);
|
|
117
|
+
lookupEntries.push(normalizedEntry);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const lookupEntryByKey = lookupEntries.reduce((accumulator, entry) => {
|
|
121
|
+
accumulator[entry.key] = entry;
|
|
122
|
+
return accumulator;
|
|
123
|
+
}, {});
|
|
124
|
+
|
|
125
|
+
const containerKey = resolveCrudLookupContainerKey(resource, {
|
|
126
|
+
context: "crud lookup runtime container key"
|
|
127
|
+
});
|
|
128
|
+
const defaults = resolveLookupRuntimeDefaults(resource);
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
containerKey,
|
|
132
|
+
defaultInclude: defaults.defaultInclude,
|
|
133
|
+
maxDepth: defaults.maxDepth,
|
|
134
|
+
entries: lookupEntries,
|
|
135
|
+
byKey: lookupEntryByKey
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function normalizeLookupIdentifier(value) {
|
|
140
|
+
const normalized = normalizeOpaqueId(value);
|
|
141
|
+
if (normalized == null) {
|
|
142
|
+
return "";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return normalizeText(normalized);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function normalizeIncludePaths(include, { defaultInclude = DEFAULT_LOOKUP_INCLUDE } = {}) {
|
|
149
|
+
const sourceInclude = normalizeText(include);
|
|
150
|
+
const normalizedInclude = sourceInclude || normalizeLookupDefaultInclude(defaultInclude);
|
|
151
|
+
if (!normalizedInclude || normalizedInclude.toLowerCase() === "none") {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const tokens = normalizeUniqueTextList(normalizedInclude.split(","));
|
|
156
|
+
const paths = [];
|
|
157
|
+
|
|
158
|
+
for (const token of tokens) {
|
|
159
|
+
const normalizedToken = normalizeText(token);
|
|
160
|
+
if (!normalizedToken) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (normalizedToken.toLowerCase() === "none") {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const segments = normalizedToken
|
|
169
|
+
.split(".")
|
|
170
|
+
.map((entry) => normalizeText(entry))
|
|
171
|
+
.filter(Boolean);
|
|
172
|
+
|
|
173
|
+
if (segments.length > 0) {
|
|
174
|
+
paths.push(segments);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return paths;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function resolveChildIncludeFromPaths(paths = []) {
|
|
182
|
+
const entries = Array.isArray(paths) ? paths : [];
|
|
183
|
+
const pathKeys = new Set();
|
|
184
|
+
let includesAll = false;
|
|
185
|
+
|
|
186
|
+
for (const path of entries) {
|
|
187
|
+
if (!Array.isArray(path) || path.length < 1) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const normalizedPath = path
|
|
192
|
+
.map((entry) => normalizeText(entry))
|
|
193
|
+
.filter(Boolean);
|
|
194
|
+
if (normalizedPath.length < 1) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (normalizedPath[0] === "*") {
|
|
198
|
+
includesAll = true;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
pathKeys.add(normalizedPath.join("."));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (includesAll) {
|
|
205
|
+
return "*";
|
|
206
|
+
}
|
|
207
|
+
if (pathKeys.size < 1) {
|
|
208
|
+
return "none";
|
|
209
|
+
}
|
|
210
|
+
return [...pathKeys].join(",");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function buildLookupHydrationPlan(
|
|
214
|
+
runtime = {},
|
|
215
|
+
include,
|
|
216
|
+
{
|
|
217
|
+
mode = "list",
|
|
218
|
+
context = "crudRepository",
|
|
219
|
+
includeWasExplicit = false
|
|
220
|
+
} = {}
|
|
221
|
+
) {
|
|
222
|
+
const entries = Array.isArray(runtime?.entries) ? runtime.entries : [];
|
|
223
|
+
if (entries.length < 1) {
|
|
224
|
+
return {
|
|
225
|
+
entries: [],
|
|
226
|
+
childIncludeByKey: {}
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const includePaths = normalizeIncludePaths(include, {
|
|
231
|
+
defaultInclude: runtime?.defaultInclude
|
|
232
|
+
});
|
|
233
|
+
if (includePaths.length < 1) {
|
|
234
|
+
return {
|
|
235
|
+
entries: [],
|
|
236
|
+
childIncludeByKey: {}
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const shouldHydrateByMode = mode === "view"
|
|
241
|
+
? (entry) => entry?.relation?.hydrateOnView !== false
|
|
242
|
+
: (entry) => entry?.relation?.hydrateOnList !== false;
|
|
243
|
+
|
|
244
|
+
const selectedByKey = new Map();
|
|
245
|
+
function ensureSelection(entry = null) {
|
|
246
|
+
if (!entry) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!includeWasExplicit && !shouldHydrateByMode(entry)) {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!selectedByKey.has(entry.key)) {
|
|
255
|
+
selectedByKey.set(entry.key, {
|
|
256
|
+
entry,
|
|
257
|
+
childPaths: [],
|
|
258
|
+
childPathSet: new Set()
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return selectedByKey.get(entry.key);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function appendChildPath(selection, segments = []) {
|
|
266
|
+
const sourceSegments = Array.isArray(segments) ? segments : [];
|
|
267
|
+
if (sourceSegments.length < 1) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const normalizedSegments = sourceSegments
|
|
272
|
+
.map((entry) => normalizeText(entry))
|
|
273
|
+
.filter(Boolean);
|
|
274
|
+
if (normalizedSegments.length < 1) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const pathKey = normalizedSegments.join(".");
|
|
279
|
+
if (selection.childPathSet.has(pathKey)) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
selection.childPathSet.add(pathKey);
|
|
284
|
+
selection.childPaths.push(normalizedSegments);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
for (const pathSegments of includePaths) {
|
|
288
|
+
const [head, ...tail] = pathSegments;
|
|
289
|
+
if (!head) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (head === "*") {
|
|
294
|
+
const wildcardTail = tail.length > 0 ? tail : ["*"];
|
|
295
|
+
for (const entry of entries) {
|
|
296
|
+
const selection = ensureSelection(entry);
|
|
297
|
+
if (!selection) {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
appendChildPath(selection, wildcardTail);
|
|
301
|
+
}
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const entry = runtime?.byKey?.[head] || null;
|
|
306
|
+
if (!entry) {
|
|
307
|
+
throw new Error(`${context} include references unknown lookup key "${head}".`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const selection = ensureSelection(entry);
|
|
311
|
+
if (!selection) {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (tail.length > 0) {
|
|
316
|
+
appendChildPath(selection, tail);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const selectedEntries = [];
|
|
321
|
+
const childIncludeByKey = {};
|
|
322
|
+
for (const selection of selectedByKey.values()) {
|
|
323
|
+
selectedEntries.push(selection.entry);
|
|
324
|
+
childIncludeByKey[selection.entry.key] = resolveChildIncludeFromPaths(selection.childPaths);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
entries: selectedEntries,
|
|
329
|
+
childIncludeByKey
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function resolveLookupProviderResolver(repositoryOptions = {}, callOptions = {}, { context = "crudRepository" } = {}) {
|
|
334
|
+
const resolver = callOptions?.resolveLookupProvider || repositoryOptions?.resolveLookupProvider;
|
|
335
|
+
if (typeof resolver !== "function") {
|
|
336
|
+
throw new TypeError(`${context} requires resolveLookupProvider(relation) to hydrate lookups.`);
|
|
337
|
+
}
|
|
338
|
+
return resolver;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function resolveLookupDepthRuntime(runtime = {}, repositoryOptions = {}, callOptions = {}) {
|
|
342
|
+
const maxDepth = normalizeLookupMaxDepth(
|
|
343
|
+
callOptions?.lookupMaxDepth ?? repositoryOptions?.lookupMaxDepth ?? runtime?.maxDepth
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const parsedDepth = Number(callOptions?.lookupDepth);
|
|
347
|
+
const depth = Number.isInteger(parsedDepth) && parsedDepth >= 0 ? parsedDepth : 0;
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
depth,
|
|
351
|
+
maxDepth
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function buildLookupGroupKey(relation = {}) {
|
|
356
|
+
return `${relation.namespace}::${relation.valueKey}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function normalizeLookupRelationValues(records = [], entries = [], childIncludeByKey = {}) {
|
|
360
|
+
const byGroup = new Map();
|
|
361
|
+
for (const entry of entries) {
|
|
362
|
+
const relation = entry.relation;
|
|
363
|
+
const groupKey = buildLookupGroupKey(relation);
|
|
364
|
+
if (!byGroup.has(groupKey)) {
|
|
365
|
+
byGroup.set(groupKey, {
|
|
366
|
+
relation,
|
|
367
|
+
entries: [],
|
|
368
|
+
values: [],
|
|
369
|
+
childIncludes: new Set()
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const group = byGroup.get(groupKey);
|
|
374
|
+
group.entries.push(entry);
|
|
375
|
+
group.childIncludes.add(normalizeText(childIncludeByKey?.[entry.key]) || "none");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
for (const group of byGroup.values()) {
|
|
379
|
+
const seen = new Set();
|
|
380
|
+
for (const record of records) {
|
|
381
|
+
for (const entry of group.entries) {
|
|
382
|
+
const rawValue = record?.[entry.key];
|
|
383
|
+
const normalized = normalizeLookupIdentifier(rawValue);
|
|
384
|
+
if (!normalized || seen.has(normalized)) {
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
seen.add(normalized);
|
|
388
|
+
group.values.push(rawValue);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return byGroup;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function resolveGroupChildInclude(group = {}) {
|
|
397
|
+
const sourceIncludes = group?.childIncludes instanceof Set ? [...group.childIncludes] : [];
|
|
398
|
+
const includeSet = new Set();
|
|
399
|
+
let includeAll = false;
|
|
400
|
+
|
|
401
|
+
for (const includeValue of sourceIncludes) {
|
|
402
|
+
const normalized = normalizeText(includeValue);
|
|
403
|
+
if (!normalized || normalized.toLowerCase() === "none") {
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (normalized === "*") {
|
|
408
|
+
includeAll = true;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
for (const token of normalizeUniqueTextList(normalized.split(","))) {
|
|
413
|
+
if (token === "*") {
|
|
414
|
+
includeAll = true;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
includeSet.add(token);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (includeAll) {
|
|
422
|
+
return "*";
|
|
423
|
+
}
|
|
424
|
+
if (includeSet.size < 1) {
|
|
425
|
+
return "none";
|
|
426
|
+
}
|
|
427
|
+
return [...includeSet].join(",");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function normalizeLookupProvider(provider, relation = {}, { context = "crudRepository" } = {}) {
|
|
431
|
+
if (!provider || typeof provider !== "object" || Array.isArray(provider)) {
|
|
432
|
+
throw new Error(`${context} could not resolve lookup provider for namespace "${relation.namespace}".`);
|
|
433
|
+
}
|
|
434
|
+
if (typeof provider.listByIds !== "function") {
|
|
435
|
+
throw new Error(`${context} lookup provider for namespace "${relation.namespace}" must expose listByIds(ids, options).`);
|
|
436
|
+
}
|
|
437
|
+
return provider;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function buildLookupRecordMap(records = [], valueKey = "") {
|
|
441
|
+
const lookupMap = new Map();
|
|
442
|
+
for (const record of Array.isArray(records) ? records : []) {
|
|
443
|
+
const lookupId = normalizeLookupIdentifier(record?.[valueKey]);
|
|
444
|
+
if (!lookupId || lookupMap.has(lookupId)) {
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
lookupMap.set(lookupId, record);
|
|
448
|
+
}
|
|
449
|
+
return lookupMap;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function hydrateCrudLookupRecords(
|
|
453
|
+
records = [],
|
|
454
|
+
runtime = {},
|
|
455
|
+
{
|
|
456
|
+
include,
|
|
457
|
+
mode = "list",
|
|
458
|
+
repositoryOptions = {},
|
|
459
|
+
callOptions = {}
|
|
460
|
+
} = {}
|
|
461
|
+
) {
|
|
462
|
+
const sourceRecords = Array.isArray(records) ? records : [];
|
|
463
|
+
if (sourceRecords.length < 1) {
|
|
464
|
+
return sourceRecords;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const depthRuntime = resolveLookupDepthRuntime(runtime, repositoryOptions, callOptions);
|
|
468
|
+
if (depthRuntime.depth >= depthRuntime.maxDepth) {
|
|
469
|
+
return sourceRecords;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const lookupContainerKey = normalizeCrudLookupContainerKey(runtime?.containerKey, {
|
|
473
|
+
context: `${runtime?.context || "crudRepository"} lookup runtime container key`
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
const lookupPlan = buildLookupHydrationPlan(runtime, include, {
|
|
477
|
+
mode,
|
|
478
|
+
context: runtime?.context || "crudRepository",
|
|
479
|
+
includeWasExplicit: normalizeText(include).length > 0
|
|
480
|
+
});
|
|
481
|
+
const selectedEntries = lookupPlan.entries;
|
|
482
|
+
if (selectedEntries.length < 1) {
|
|
483
|
+
return sourceRecords;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const resolveLookupProvider = resolveLookupProviderResolver(repositoryOptions, callOptions, {
|
|
487
|
+
context: runtime?.context || "crudRepository"
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const relationGroups = normalizeLookupRelationValues(sourceRecords, selectedEntries, lookupPlan.childIncludeByKey);
|
|
491
|
+
const groupRecordMaps = new Map();
|
|
492
|
+
for (const group of relationGroups.values()) {
|
|
493
|
+
if (group.values.length < 1) {
|
|
494
|
+
groupRecordMaps.set(buildLookupGroupKey(group.relation), new Map());
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const provider = normalizeLookupProvider(resolveLookupProvider(group.relation), group.relation, {
|
|
499
|
+
context: runtime?.context || "crudRepository"
|
|
500
|
+
});
|
|
501
|
+
const childInclude = resolveGroupChildInclude(group);
|
|
502
|
+
const groupRecords = await provider.listByIds(group.values, {
|
|
503
|
+
...callOptions,
|
|
504
|
+
include: childInclude,
|
|
505
|
+
lookupDepth: depthRuntime.depth + 1,
|
|
506
|
+
lookupMaxDepth: depthRuntime.maxDepth,
|
|
507
|
+
valueKey: group.relation.valueKey
|
|
508
|
+
});
|
|
509
|
+
groupRecordMaps.set(
|
|
510
|
+
buildLookupGroupKey(group.relation),
|
|
511
|
+
buildLookupRecordMap(groupRecords, group.relation.valueKey)
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return sourceRecords.map((record) => {
|
|
516
|
+
const baseRecord = record && typeof record === "object" && !Array.isArray(record) ? record : {};
|
|
517
|
+
const existingLookups =
|
|
518
|
+
baseRecord[lookupContainerKey] &&
|
|
519
|
+
typeof baseRecord[lookupContainerKey] === "object" &&
|
|
520
|
+
!Array.isArray(baseRecord[lookupContainerKey])
|
|
521
|
+
? baseRecord[lookupContainerKey]
|
|
522
|
+
: {};
|
|
523
|
+
const nextLookups = { ...existingLookups };
|
|
524
|
+
|
|
525
|
+
for (const entry of selectedEntries) {
|
|
526
|
+
const relation = entry.relation;
|
|
527
|
+
const lookupMap = groupRecordMaps.get(buildLookupGroupKey(relation)) || new Map();
|
|
528
|
+
const lookupId = normalizeLookupIdentifier(baseRecord?.[entry.key]);
|
|
529
|
+
if (!lookupId) {
|
|
530
|
+
nextLookups[entry.key] = null;
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
nextLookups[entry.key] = lookupMap.get(lookupId) || null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
...baseRecord,
|
|
538
|
+
[lookupContainerKey]: nextLookups
|
|
539
|
+
};
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export {
|
|
544
|
+
createCrudLookupRuntime,
|
|
545
|
+
hydrateCrudLookupRecords
|
|
546
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeCrudLookupApiPath,
|
|
3
|
+
normalizeCrudLookupNamespace
|
|
4
|
+
} from "@jskit-ai/kernel/shared/support/crudLookup";
|
|
5
|
+
import { toSnakeCase } from "@jskit-ai/kernel/shared/support/stringCase";
|
|
6
|
+
|
|
7
|
+
function requireCrudLookupNamespace(value = "", { context = "crudLookupProvider" } = {}) {
|
|
8
|
+
const normalizedNamespace = normalizeCrudLookupNamespace(value);
|
|
9
|
+
if (!normalizedNamespace) {
|
|
10
|
+
throw new Error(`${context} requires relation.namespace.`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return normalizedNamespace;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function resolveCrudLookupProviderToken(namespace = "", { context = "crudLookupProvider" } = {}) {
|
|
17
|
+
const normalizedNamespace = requireCrudLookupNamespace(namespace, {
|
|
18
|
+
context
|
|
19
|
+
});
|
|
20
|
+
const tokenPart = normalizedNamespace
|
|
21
|
+
.split("/")
|
|
22
|
+
.map((segment) => toSnakeCase(segment))
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
.join(".");
|
|
25
|
+
return `crud.lookup.${tokenPart}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resolveCrudLookupNamespaceFromRelation(relation = {}, { context = "crudLookupProvider" } = {}) {
|
|
29
|
+
const normalizedNamespace =
|
|
30
|
+
normalizeCrudLookupNamespace(relation?.namespace) ||
|
|
31
|
+
normalizeCrudLookupNamespace(relation?.apiPath);
|
|
32
|
+
if (!normalizedNamespace) {
|
|
33
|
+
throw new Error(`${context} requires relation.namespace.`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return normalizedNamespace;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export {
|
|
40
|
+
normalizeCrudLookupApiPath,
|
|
41
|
+
normalizeCrudLookupNamespace,
|
|
42
|
+
requireCrudLookupNamespace,
|
|
43
|
+
resolveCrudLookupNamespaceFromRelation,
|
|
44
|
+
resolveCrudLookupProviderToken
|
|
45
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveCrudLookupProviderToken,
|
|
3
|
+
resolveCrudLookupNamespaceFromRelation
|
|
4
|
+
} from "./lookupPathSupport.js";
|
|
5
|
+
|
|
6
|
+
function createCrudLookupProviderResolver(scope, { context = "crudLookupProvider" } = {}) {
|
|
7
|
+
if (!scope || typeof scope.make !== "function") {
|
|
8
|
+
throw new Error(`${context} requires scope.make().`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return function resolveLookupProvider(relation = {}) {
|
|
12
|
+
const namespace = resolveCrudLookupNamespaceFromRelation(relation, {
|
|
13
|
+
context
|
|
14
|
+
});
|
|
15
|
+
return scope.make(
|
|
16
|
+
resolveCrudLookupProviderToken(namespace, {
|
|
17
|
+
context
|
|
18
|
+
})
|
|
19
|
+
);
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createCrudLookupProvider(repository, { context = "crudLookupProvider" } = {}) {
|
|
24
|
+
if (!repository || typeof repository.listByIds !== "function") {
|
|
25
|
+
throw new Error(`${context} requires repository.listByIds(ids, options).`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return Object.freeze({
|
|
29
|
+
async listByIds(ids = [], options = {}) {
|
|
30
|
+
const include = options?.include === undefined ? "none" : options.include;
|
|
31
|
+
return repository.listByIds(ids, {
|
|
32
|
+
...options,
|
|
33
|
+
include
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export {
|
|
40
|
+
resolveCrudLookupProviderToken,
|
|
41
|
+
createCrudLookupProviderResolver,
|
|
42
|
+
createCrudLookupProvider
|
|
43
|
+
};
|