@appweaver/core 1.4.0 → 1.4.1
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/export/export-service.js +6 -0
- package/factory/create-model.js +18 -1
- package/package.json +1 -1
- package/resource/utils/relation-util.js +51 -15
- package/server/swagger.js +49 -1
package/export/export-service.js
CHANGED
|
@@ -99,6 +99,12 @@ class ExportService {
|
|
|
99
99
|
// Skip mapping for non-exposed fields.
|
|
100
100
|
continue;
|
|
101
101
|
}
|
|
102
|
+
// The records are read from the database rather than through a response
|
|
103
|
+
// schema, so the fields no response carries are dropped here instead.
|
|
104
|
+
if (modelSchema?.['hidden'] === true ||
|
|
105
|
+
resourceModel.config.virtual?.[key]?.output?.type === 'none') {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
102
108
|
let header = parentKey ? `${parentKey}.${key}` : key;
|
|
103
109
|
const isArrayValue = (0, common_1.isArray)(value);
|
|
104
110
|
const exportField = exportConfig?.[key];
|
package/factory/create-model.js
CHANGED
|
@@ -50,6 +50,7 @@ function buildModel(config) {
|
|
|
50
50
|
const createModel = omitOrPickScalars(resolveDefaultScalars(typebox_1.Type.Composite([scalarsSchema, virtualSchema]), config), config.create);
|
|
51
51
|
const updateModel = omitOrPickScalars(resolveDefaultScalars(typebox_1.Type.Partial(typebox_1.Type.Composite([scalarsSchema, virtualSchema])), config), config.update);
|
|
52
52
|
const { readOneModel, readManyModel } = buildOutputModels(baseReadModel, relationsModel, filesModel, config);
|
|
53
|
+
const readOneNullableModel = buildNullableOutputModel(name);
|
|
53
54
|
const { createOneModel, updateOneModel, relationCreateModel, relationUpdateModel, relationInputModel } = buildInputModels(createModel, updateModel, relationsModel, idSchema, config);
|
|
54
55
|
const { fileUploadModel, fileDeleteModel } = buildFileInputModels(config);
|
|
55
56
|
const resourceModel = {
|
|
@@ -64,6 +65,7 @@ function buildModel(config) {
|
|
|
64
65
|
virtualModel,
|
|
65
66
|
readOneModel,
|
|
66
67
|
readManyModel,
|
|
68
|
+
readOneNullableModel,
|
|
67
69
|
createOneModel,
|
|
68
70
|
updateOneModel,
|
|
69
71
|
relationCreateModel,
|
|
@@ -223,6 +225,17 @@ function buildOutputModels(readModel, relationsModel, filesModel, config) {
|
|
|
223
225
|
], { $id: `${config.name}Multiple` });
|
|
224
226
|
return { readOneModel, readManyModel };
|
|
225
227
|
}
|
|
228
|
+
function buildNullableOutputModel(name) {
|
|
229
|
+
// Builds the nullable variant of the single read model, registered under its own
|
|
230
|
+
// name so a nullable relation can reference it instead of inlining the union.
|
|
231
|
+
// The response serializer cannot compile an inline `anyOf` union that cycles
|
|
232
|
+
// back to a model it is already writing, since it expands every union branch
|
|
233
|
+
// into a fresh schema. A union behind a name is expanded once and reused, which
|
|
234
|
+
// keeps a self-referencing relation (a category and its parent) serializable.
|
|
235
|
+
return typebox_1.Type.Union([typebox_1.Type.Ref(`${name}Single`), typebox_1.Type.Null()], {
|
|
236
|
+
$id: `${name}SingleNullable`
|
|
237
|
+
});
|
|
238
|
+
}
|
|
226
239
|
function buildInputModels(createModel, updateModel, relationsModel, idSchema, config) {
|
|
227
240
|
const virtualConfig = config.virtual;
|
|
228
241
|
const relationsConfig = config.relations;
|
|
@@ -349,7 +362,7 @@ function relationOutputProperties(object, relationConfig, outputType) {
|
|
|
349
362
|
return undefined;
|
|
350
363
|
}
|
|
351
364
|
if (config.required === false && schema.type !== 'array') {
|
|
352
|
-
schema = (
|
|
365
|
+
schema = nullableOutputSchema(schema);
|
|
353
366
|
}
|
|
354
367
|
}
|
|
355
368
|
return schema;
|
|
@@ -378,6 +391,10 @@ function relationOutputProperties(object, relationConfig, outputType) {
|
|
|
378
391
|
}, {})
|
|
379
392
|
});
|
|
380
393
|
}
|
|
394
|
+
function nullableOutputSchema(schema) {
|
|
395
|
+
const modelRef = schema.$ref;
|
|
396
|
+
return modelRef ? typebox_1.Type.Ref(`${modelRef}Nullable`) : (0, common_1.Nullable)(schema);
|
|
397
|
+
}
|
|
381
398
|
function removeHiddenFields(schema) {
|
|
382
399
|
const properties = {};
|
|
383
400
|
for (const [name, field] of Object.entries(schema.properties)) {
|
package/package.json
CHANGED
|
@@ -9,6 +9,9 @@ const common_1 = require("@appweaver/common");
|
|
|
9
9
|
const context_1 = require("../../context");
|
|
10
10
|
const security_1 = require("../../security");
|
|
11
11
|
const errors_1 = require("../../errors");
|
|
12
|
+
/** Levels of a self referencing relation read when the relation configures no
|
|
13
|
+
* `maxDepth`, i.e. the relation itself and nothing below it. */
|
|
14
|
+
const DEFAULT_RELATION_MAX_DEPTH = 1;
|
|
12
15
|
/**
|
|
13
16
|
* Builds the Prisma `include` clause for the relation and file fields of a resource model. A field is included when
|
|
14
17
|
* its configured output type allows it for the given action, or always when no action is specified, and the relations
|
|
@@ -41,7 +44,7 @@ function mapRelationInclusions(resourceName, action) {
|
|
|
41
44
|
}
|
|
42
45
|
// Check if the relation should be included based on the output type
|
|
43
46
|
if (shouldIncludeRelation(relationField?.output?.type, action)) {
|
|
44
|
-
inclusion[key] = buildNestedInclusion(relationField, action);
|
|
47
|
+
inclusion[key] = buildNestedInclusion(relationField, key, action);
|
|
45
48
|
}
|
|
46
49
|
}
|
|
47
50
|
return inclusion;
|
|
@@ -294,34 +297,67 @@ function createdByConnect(resourceName) {
|
|
|
294
297
|
: undefined;
|
|
295
298
|
}
|
|
296
299
|
/**
|
|
297
|
-
* Resolves the inclusion value of a single relation field
|
|
298
|
-
*
|
|
299
|
-
* `include` clause otherwise.
|
|
300
|
+
* Resolves the inclusion value of a single relation field, walking both the levels a self referencing relation
|
|
301
|
+
* repeats itself for and the nested includes it configures. Returns `true` when the relation reads no further than
|
|
302
|
+
* itself for the given action, or a nested `include` clause otherwise.
|
|
303
|
+
*
|
|
304
|
+
* A relation whose related model holds the same relation again points back at its own model, so it repeats itself
|
|
305
|
+
* down the tree up to its configured `output.maxDepth`, letting a category carry its ancestors without every level
|
|
306
|
+
* being spelled out. An `output.include` entry naming that same relation configures the level itself and replaces
|
|
307
|
+
* the repetition.
|
|
300
308
|
*
|
|
301
309
|
* @param {RelationField} relationField - The configuration of the relation whose inclusion value is resolved, read
|
|
302
|
-
* from its `output.include`
|
|
310
|
+
* from its `output.include` and `output.maxDepth` properties.
|
|
311
|
+
* @param {string} key - The field name the relation is declared under, matched against the related model to detect a
|
|
312
|
+
* relation pointing back at its own model.
|
|
303
313
|
* @param {ActionType} [action] - The action the inclusions are built for, matched against the configured output type
|
|
304
314
|
* of every nested relation.
|
|
305
|
-
* @
|
|
306
|
-
* nested `include` clause
|
|
315
|
+
* @param {number} [depth=1] - The level of the relation being resolved, counting the relation itself as the first.
|
|
316
|
+
* @return {boolean|Object} True if the relation reads no further than itself, or the nested `include` clause
|
|
317
|
+
* otherwise.
|
|
307
318
|
*/
|
|
308
|
-
function buildNestedInclusion(relationField, action) {
|
|
319
|
+
function buildNestedInclusion(relationField, key, action, depth = 1) {
|
|
309
320
|
const nestedIncludeConfig = relationField?.output?.include;
|
|
310
|
-
if (!nestedIncludeConfig || Object.keys(nestedIncludeConfig).length === 0) {
|
|
311
|
-
return true;
|
|
312
|
-
}
|
|
313
321
|
const nestedInclusion = {};
|
|
314
|
-
|
|
322
|
+
// A file field carries no related model, so it reads no further than itself
|
|
323
|
+
const relatedModel = relationField?.model
|
|
324
|
+
? (0, context_1.injectModel)((0, common_1.capitalize)(relationField.model), false)
|
|
325
|
+
: undefined;
|
|
326
|
+
const selfRelation = selfReferencingRelation(relatedModel, key);
|
|
327
|
+
const maxDepth = relationField?.output?.maxDepth ?? DEFAULT_RELATION_MAX_DEPTH;
|
|
328
|
+
if (selfRelation && depth < maxDepth && !nestedIncludeConfig?.[key]) {
|
|
329
|
+
nestedInclusion[key] = buildNestedInclusion({ ...selfRelation, output: relationField.output }, key, action, depth + 1);
|
|
330
|
+
}
|
|
331
|
+
for (const [nestedKey, nestedOutput] of Object.entries(nestedIncludeConfig ?? {})) {
|
|
315
332
|
// Check if the nested relation should be included
|
|
316
|
-
if (shouldIncludeRelation(nestedOutput?.type, action)) {
|
|
317
|
-
|
|
318
|
-
nestedInclusion[nestedKey] = buildNestedInclusion({ output: nestedOutput }, action);
|
|
333
|
+
if (!shouldIncludeRelation(nestedOutput?.type, action)) {
|
|
334
|
+
continue;
|
|
319
335
|
}
|
|
336
|
+
// The related model carries the field the nested include names, so each
|
|
337
|
+
// level is resolved against the model it actually belongs to
|
|
338
|
+
const nestedRelation = relatedModel?.config?.relations?.[nestedKey] ??
|
|
339
|
+
relatedModel?.config?.files?.[nestedKey];
|
|
340
|
+
nestedInclusion[nestedKey] = buildNestedInclusion({ ...nestedRelation, output: nestedOutput }, nestedKey, action);
|
|
320
341
|
}
|
|
321
342
|
return Object.keys(nestedInclusion).length > 0
|
|
322
343
|
? { include: nestedInclusion }
|
|
323
344
|
: true;
|
|
324
345
|
}
|
|
346
|
+
/**
|
|
347
|
+
* Reads the relation a related model holds under the given field name, when it points back at that same model. It is
|
|
348
|
+
* the relation a self referencing field repeats itself through, i.e. the `parent` of the parent of a category.
|
|
349
|
+
*
|
|
350
|
+
* @param {ResourceModel} [model] - The related model the field is looked up on.
|
|
351
|
+
* @param {string} key - The field name of the relation.
|
|
352
|
+
* @return {RelationField | undefined} The relation of the model under that name when it references the model itself,
|
|
353
|
+
* undefined otherwise.
|
|
354
|
+
*/
|
|
355
|
+
function selfReferencingRelation(model, key) {
|
|
356
|
+
const relation = key ? model?.config?.relations?.[key] : undefined;
|
|
357
|
+
return relation && (0, common_1.capitalize)(relation.model) === model?.name
|
|
358
|
+
? relation
|
|
359
|
+
: undefined;
|
|
360
|
+
}
|
|
325
361
|
/**
|
|
326
362
|
* Decides whether a relation with the given configured output type is included for the given action. The `none` type
|
|
327
363
|
* is never included, the `single` type is excluded from the query action, and the `multiple` type is only included on
|
package/server/swagger.js
CHANGED
|
@@ -11,7 +11,7 @@ const context_1 = require("../context");
|
|
|
11
11
|
exports.default = (0, fastify_plugin_1.default)((server) => {
|
|
12
12
|
server.register(swagger_1.default, {
|
|
13
13
|
hideUntagged: common_1.config.SWAGGER_HIDE_UNTAGGED,
|
|
14
|
-
transformObject: (document) => addConfig(normalizeUnionTypes(pruneUnusedSchemas(document))),
|
|
14
|
+
transformObject: (document) => addConfig(normalizeUnionTypes(pruneUnusedSchemas(inlineNullableRefs(document)))),
|
|
15
15
|
openapi: {
|
|
16
16
|
info: {
|
|
17
17
|
title: common_1.config.APP_NAME,
|
|
@@ -64,6 +64,54 @@ exports.default = (0, fastify_plugin_1.default)((server) => {
|
|
|
64
64
|
});
|
|
65
65
|
}
|
|
66
66
|
});
|
|
67
|
+
/**
|
|
68
|
+
* Replaces every reference to a nullable model variant with the union it stands
|
|
69
|
+
* for. Each model registers a `<Model>SingleNullable` schema the response
|
|
70
|
+
* serializer needs as a name of its own, since it cannot compile an inline
|
|
71
|
+
* union that cycles back to a model it is already writing. The document says
|
|
72
|
+
* the same thing without that indirection, leaving the variants unreferenced
|
|
73
|
+
* for {@link pruneUnusedSchemas} to drop.
|
|
74
|
+
*
|
|
75
|
+
* @param {Object} document The transform argument of the Swagger plugin,
|
|
76
|
+
* wrapping the OpenAPI document in its `openapiObject` property.
|
|
77
|
+
* @returns {Object} The same argument, with every reference to a nullable model
|
|
78
|
+
* variant replaced in place.
|
|
79
|
+
*/
|
|
80
|
+
function inlineNullableRefs(document) {
|
|
81
|
+
const schemas = document.openapiObject?.components?.schemas ?? {};
|
|
82
|
+
// The schemas are registered under generated names, so the variants are
|
|
83
|
+
// recognized by the title carrying the name they were declared with
|
|
84
|
+
const variants = new Map();
|
|
85
|
+
for (const [name, schema] of Object.entries(schemas)) {
|
|
86
|
+
if (/SingleNullable$/.test(schema?.title ?? '') && (0, common_1.isArray)(schema?.anyOf)) {
|
|
87
|
+
variants.set(`#/components/schemas/${name}`, schema.anyOf);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (variants.size === 0) {
|
|
91
|
+
return document;
|
|
92
|
+
}
|
|
93
|
+
const visit = (node) => {
|
|
94
|
+
if (!node || typeof node !== 'object') {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if ((0, common_1.isArray)(node)) {
|
|
98
|
+
for (const item of node)
|
|
99
|
+
visit(item);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
for (const value of Object.values(node)) {
|
|
103
|
+
visit(value);
|
|
104
|
+
}
|
|
105
|
+
const union = variants.get(node.$ref);
|
|
106
|
+
if (union) {
|
|
107
|
+
delete node.$ref;
|
|
108
|
+
node.anyOf = structuredClone(union);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
visit(document.openapiObject?.paths);
|
|
112
|
+
visit(schemas);
|
|
113
|
+
return document;
|
|
114
|
+
}
|
|
67
115
|
/**
|
|
68
116
|
* Rewrites the JSON Schema type lists of the document into the equivalent
|
|
69
117
|
* `anyOf` unions. The query filter schemas declare their plain values as a
|