@appweaver/data 0.0.0-stage → 2.0.0

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.
Files changed (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -2
  3. package/config.d.ts +6 -0
  4. package/config.js +22 -0
  5. package/database/create-client.d.ts +11 -0
  6. package/database/create-client.js +145 -0
  7. package/database/database-config.d.ts +14 -0
  8. package/database/database-config.js +40 -0
  9. package/database/database-error.d.ts +49 -0
  10. package/database/database-error.js +216 -0
  11. package/database/index.d.ts +3 -0
  12. package/database/index.js +19 -0
  13. package/database/prisma-database.d.ts +33 -0
  14. package/database/prisma-database.js +178 -0
  15. package/database/run-transaction.d.ts +21 -0
  16. package/database/run-transaction.js +15 -0
  17. package/export/export-config.d.ts +7 -0
  18. package/export/export-config.js +17 -0
  19. package/export/export-service.d.ts +22 -0
  20. package/export/export-service.js +188 -0
  21. package/export/index.d.ts +1 -0
  22. package/export/index.js +17 -0
  23. package/factory/create-model.d.ts +11 -0
  24. package/factory/create-model.js +479 -0
  25. package/factory/create-policy.d.ts +12 -0
  26. package/factory/create-policy.js +20 -0
  27. package/factory/create-service.d.ts +14 -0
  28. package/factory/create-service.js +116 -0
  29. package/index.d.ts +22 -0
  30. package/index.js +38 -0
  31. package/internal.d.ts +2 -0
  32. package/internal.js +6 -0
  33. package/module.d.ts +3 -0
  34. package/module.js +81 -0
  35. package/package.json +67 -3
  36. package/prisma/client/browser.d.ts +39 -0
  37. package/prisma/client/browser.js +56 -0
  38. package/prisma/client/client.d.ts +58 -0
  39. package/prisma/client/client.js +70 -0
  40. package/prisma/client/commonInputTypes.d.ts +345 -0
  41. package/prisma/client/commonInputTypes.js +11 -0
  42. package/prisma/client/enums.d.ts +1 -0
  43. package/prisma/client/enums.js +11 -0
  44. package/prisma/client/internal/class.d.ts +198 -0
  45. package/prisma/client/internal/class.js +85 -0
  46. package/prisma/client/internal/prismaNamespace.d.ts +1125 -0
  47. package/prisma/client/internal/prismaNamespace.js +204 -0
  48. package/prisma/client/internal/prismaNamespaceBrowser.d.ts +137 -0
  49. package/prisma/client/internal/prismaNamespaceBrowser.js +175 -0
  50. package/prisma/client/models/ApiKey.d.ts +1167 -0
  51. package/prisma/client/models/ApiKey.js +2 -0
  52. package/prisma/client/models/ConnectedAccount.d.ts +1094 -0
  53. package/prisma/client/models/ConnectedAccount.js +2 -0
  54. package/prisma/client/models/File.d.ts +1291 -0
  55. package/prisma/client/models/File.js +2 -0
  56. package/prisma/client/models/OneTimeToken.d.ts +1055 -0
  57. package/prisma/client/models/OneTimeToken.js +2 -0
  58. package/prisma/client/models/Permission.d.ts +1209 -0
  59. package/prisma/client/models/Permission.js +2 -0
  60. package/prisma/client/models/Role.d.ts +1209 -0
  61. package/prisma/client/models/Role.js +2 -0
  62. package/prisma/client/models/Seeder.d.ts +1023 -0
  63. package/prisma/client/models/Seeder.js +2 -0
  64. package/prisma/client/models.d.ts +8 -0
  65. package/prisma/client/models.js +2 -0
  66. package/resource/load-data-resources.d.ts +13 -0
  67. package/resource/load-data-resources.js +37 -0
  68. package/resource/model-schema.d.ts +34 -0
  69. package/resource/model-schema.js +54 -0
  70. package/resource/resource-config.d.ts +16 -0
  71. package/resource/resource-config.js +62 -0
  72. package/resource/resource-loader.d.ts +25 -0
  73. package/resource/resource-loader.js +138 -0
  74. package/resource/resource-service.d.ts +462 -0
  75. package/resource/resource-service.js +1042 -0
  76. package/resource/schemas/index.d.ts +3 -0
  77. package/resource/schemas/index.js +19 -0
  78. package/resource/schemas/resource-aggregate-schema.d.ts +53 -0
  79. package/resource/schemas/resource-aggregate-schema.js +130 -0
  80. package/resource/schemas/resource-filter-schema.d.ts +39 -0
  81. package/resource/schemas/resource-filter-schema.js +153 -0
  82. package/resource/schemas/resource-sort-schema.d.ts +38 -0
  83. package/resource/schemas/resource-sort-schema.js +113 -0
  84. package/resource/utils/aggregate-util.d.ts +113 -0
  85. package/resource/utils/aggregate-util.js +322 -0
  86. package/resource/utils/cursor-util.d.ts +60 -0
  87. package/resource/utils/cursor-util.js +116 -0
  88. package/resource/utils/date-util.d.ts +22 -0
  89. package/resource/utils/date-util.js +112 -0
  90. package/resource/utils/delete-util.d.ts +131 -0
  91. package/resource/utils/delete-util.js +385 -0
  92. package/resource/utils/filter-util.d.ts +24 -0
  93. package/resource/utils/filter-util.js +289 -0
  94. package/resource/utils/prune-util.d.ts +11 -0
  95. package/resource/utils/prune-util.js +56 -0
  96. package/resource/utils/relation-util.d.ts +75 -0
  97. package/resource/utils/relation-util.js +400 -0
  98. package/resource/utils/sort-util.d.ts +31 -0
  99. package/resource/utils/sort-util.js +235 -0
  100. package/resource/utils/text-search-util.d.ts +14 -0
  101. package/resource/utils/text-search-util.js +39 -0
  102. package/resources.d.ts +10 -0
  103. package/resources.js +26 -0
  104. package/security/auth-config.d.ts +23 -0
  105. package/security/auth-config.js +43 -0
  106. package/security/create-auth-resources.d.ts +28 -0
  107. package/security/create-auth-resources.js +163 -0
  108. package/security/helper.d.ts +109 -0
  109. package/security/helper.js +298 -0
  110. package/security/resources/api-key/model.d.ts +2 -0
  111. package/security/resources/api-key/model.js +67 -0
  112. package/security/resources/api-key/policy.d.ts +2 -0
  113. package/security/resources/api-key/policy.js +27 -0
  114. package/security/resources/api-key/service.d.ts +3 -0
  115. package/security/resources/api-key/service.js +34 -0
  116. package/security/resources/connected-account/model.d.ts +2 -0
  117. package/security/resources/connected-account/model.js +57 -0
  118. package/security/resources/connected-account/service.d.ts +2 -0
  119. package/security/resources/connected-account/service.js +7 -0
  120. package/security/resources/one-time-token/model.d.ts +2 -0
  121. package/security/resources/one-time-token/model.js +36 -0
  122. package/security/resources/permission/model.d.ts +2 -0
  123. package/security/resources/permission/model.js +16 -0
  124. package/security/resources/role/model.d.ts +2 -0
  125. package/security/resources/role/model.js +22 -0
  126. package/security/store/database-security-store.d.ts +5 -0
  127. package/security/store/database-security-store.js +54 -0
  128. package/seeder/create-seeder.d.ts +29 -0
  129. package/seeder/create-seeder.js +28 -0
  130. package/seeder/index.d.ts +2 -0
  131. package/seeder/index.js +18 -0
  132. package/seeder/resources/seeder/model.d.ts +2 -0
  133. package/seeder/resources/seeder/model.js +38 -0
  134. package/seeder/seeder.d.ts +29 -0
  135. package/seeder/seeder.js +197 -0
  136. package/storage/file-service.d.ts +133 -0
  137. package/storage/file-service.js +586 -0
  138. package/storage/image-config.d.ts +3 -0
  139. package/storage/image-config.js +11 -0
  140. package/storage/resources/file/model.d.ts +2 -0
  141. package/storage/resources/file/model.js +80 -0
  142. package/types/auth.d.ts +23 -0
  143. package/types/auth.js +2 -0
  144. package/types/generated.d.ts +366 -0
  145. package/types/generated.js +3 -0
  146. package/types/storage.d.ts +35 -0
  147. package/types/storage.js +2 -0
  148. package/utils/export-util.d.ts +8 -0
  149. package/utils/export-util.js +26 -0
  150. package/utils/file-util.d.ts +106 -0
  151. package/utils/file-util.js +254 -0
  152. package/utils/model-util.d.ts +36 -0
  153. package/utils/model-util.js +154 -0
  154. package/utils/policy-util.d.ts +18 -0
  155. package/utils/policy-util.js +28 -0
  156. package/utils/virtual-util.d.ts +11 -0
  157. package/utils/virtual-util.js +53 -0
@@ -0,0 +1,400 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.mapRelationInclusions = mapRelationInclusions;
4
+ exports.mapRelationActions = mapRelationActions;
5
+ exports.unknownRelationFields = unknownRelationFields;
6
+ exports.missingRelationFields = missingRelationFields;
7
+ exports.createdByConnect = createdByConnect;
8
+ const common_1 = require("@appweaver/common");
9
+ const core_1 = require("@appweaver/core");
10
+ const delete_util_1 = require("./delete-util");
11
+ /** Levels of a self referencing relation read when the relation configures no
12
+ * `maxDepth`, i.e. the relation itself and nothing below it. */
13
+ const DEFAULT_RELATION_MAX_DEPTH = 1;
14
+ /**
15
+ * Builds the Prisma `include` clause for the relation and file fields of a resource model. A field is included when
16
+ * its configured output type allows it for the given action, or always when no action is specified, and the relations
17
+ * configured with an output count contribute to the `_count` selection instead.
18
+ *
19
+ * @param {string} resourceName - The name of the model whose relations are included.
20
+ * @param {ActionType} [action] - The action the inclusions are built for, matched against the configured output type
21
+ * of every field. When omitted, all fields whose output type is not `none` are included.
22
+ * @return {Object} The `include` clause mapping each included field to `true` or to its own nested `include` clause,
23
+ * extended with a `_count` selection for the relations configured with an output count.
24
+ */
25
+ function mapRelationInclusions(resourceName, action) {
26
+ const inclusion = {};
27
+ const resourceModel = (0, core_1.injectModel)(resourceName);
28
+ const relationConfig = resourceModel.config.relations;
29
+ const fileConfig = resourceModel.config.files;
30
+ const relationModelProps = (0, common_1.extractSchemaProperties)(resourceModel.relationsModel);
31
+ const fileModelProps = (0, common_1.extractSchemaProperties)(resourceModel.filesModel);
32
+ // Add relation and file fields to the inclusion map if the include type is
33
+ // satisfied or the requested action is not specified. Also, add the count
34
+ // aggregation actions for relations if configured.
35
+ for (const key of Object.keys({
36
+ ...relationModelProps,
37
+ ...fileModelProps
38
+ })) {
39
+ const relationField = relationConfig?.[key] || fileConfig?.[key];
40
+ if (relationField?.output?.count) {
41
+ inclusion._count = inclusion._count ?? { select: {} };
42
+ // Soft deleted related records are not counted
43
+ inclusion._count.select[key] =
44
+ (0, delete_util_1.liveInclusionFilter)(relationField.model) ?? true;
45
+ }
46
+ // Check if the relation should be included based on the output type
47
+ if (shouldIncludeRelation(relationField?.output?.type, action)) {
48
+ inclusion[key] = buildNestedInclusion(relationField, key, action);
49
+ }
50
+ }
51
+ return inclusion;
52
+ }
53
+ /**
54
+ * Maps the relation and file fields of a write payload to the Prisma nested write actions. Bare key values and arrays
55
+ * of them are normalized to objects first, then every item is classified: items with an id and additional data become
56
+ * inline updates when the parent action is an update and inline updates are enabled for the relation, items with only
57
+ * an id are connected, and items without an id are created inline or matched through a connect-or-create when
58
+ * `input.uniqueKey` is configured. On an update action, the items of the current record that are absent from the new
59
+ * value, as well as the relations set to null, are disconnected, or deleted when `orphanRemoval` is configured.
60
+ * Relations that were not loaded on the current record are left untouched.
61
+ *
62
+ * @param {string} resourceName - The name of the model the write payload belongs to.
63
+ * @param {'create'|'update'} action - The write action the relation actions are mapped for. Inline updates and the
64
+ * removal of the relations absent from the new value are only applied on an update action.
65
+ * @param {Object} data - The sanitized write payload whose relation and file fields are mapped. The non-relation
66
+ * fields are skipped, except for the null values of the array scalar fields, which are mapped to an empty array on an
67
+ * update action and to undefined on a create action.
68
+ * @param {Object} [currentData] - The currently stored record with its relations loaded, required on an update action
69
+ * to determine which relations to disconnect or delete.
70
+ * @return {RelationActions} The nested write clause per relation field, mapping each one to its `connect`, `create`,
71
+ * `update`, `connectOrCreate`, `disconnect` and `delete` actions, or to undefined for the relations no action can be
72
+ * applied to.
73
+ * @throws {ResourceError} `RESOURCE_INVALID_RELATION` if an inline payload holds fields the related model does not
74
+ * accept for its action, an inline create payload is missing required fields, a record with an id carries data the
75
+ * relation cannot update inline, or the relation accepts no new records and an id was not provided.
76
+ */
77
+ function mapRelationActions(resourceName, action, data, currentData) {
78
+ const relations = {};
79
+ const resourceModel = (0, core_1.injectModel)(resourceName);
80
+ const readModel = resourceModel.readModel;
81
+ const relationsModel = resourceModel.relationsModel;
82
+ const relationsConfig = resourceModel.config.relations;
83
+ for (const key in data) {
84
+ let value = data[key];
85
+ const relationSchema = (0, common_1.extractSchemaProperties)(relationsModel, key);
86
+ if (!relationSchema) {
87
+ // Set empty array or undefined value for null array type fields
88
+ if (value === null) {
89
+ if ((0, common_1.extractSchemaProperties)(readModel, key)?.type === 'array') {
90
+ relations[key] = action === 'update' ? [] : undefined;
91
+ }
92
+ else if (action === 'create') {
93
+ relations[key] = undefined;
94
+ }
95
+ }
96
+ // Skip mapping for non-relation fields
97
+ continue;
98
+ }
99
+ const config = relationsConfig?.[key];
100
+ // The unique key is only used to match existing records for the
101
+ // connect-or-create action; connect and inline update always use the id
102
+ const uniqueKey = config?.input?.allowCreate
103
+ ? config?.input?.uniqueKey || 'id'
104
+ : 'id';
105
+ const isArrayType = relationSchema.type === 'array';
106
+ // Normalize array values to single values if a value type is not an array
107
+ if (!isArrayType && (0, common_1.isArray)(value)) {
108
+ value = value[0];
109
+ }
110
+ // Set null values to undefined value for create actions. On update action
111
+ // they will be returned as disconnected relations.
112
+ if (action === 'create' && value === null) {
113
+ relations[key] = undefined;
114
+ continue;
115
+ }
116
+ // Normalize plain key values or arrays to object values
117
+ if (isArrayType) {
118
+ if ((0, common_1.isArray)(value) && !(0, common_1.isPlainObject)(value[0])) {
119
+ value = value.map((v) => ({
120
+ [uniqueKey]: v
121
+ }));
122
+ }
123
+ }
124
+ else if (!(0, common_1.isObject)(value)) {
125
+ // Only bare key values are wrapped. The loose object check keeps
126
+ // null values untouched, so they still map to a disconnect action.
127
+ value = { [uniqueKey]: value };
128
+ }
129
+ // Classify every relation input item into its relation write action:
130
+ // items with an id and additional data become inline updates (on parent
131
+ // update requests with inline updates enabled), items with only an id are
132
+ // connected, and items without an id are created inline or matched with
133
+ // connect-or-create, both of which require `allowCreate`
134
+ if (value) {
135
+ const items = isArrayType && (0, common_1.isArray)(value) ? value : [value];
136
+ const createdBy = createdByConnect(config?.model ?? resourceName);
137
+ const actions = {
138
+ connect: [],
139
+ update: [],
140
+ create: [],
141
+ connectOrCreate: []
142
+ };
143
+ for (const item of items) {
144
+ if (!(0, common_1.isPlainObject)(item)) {
145
+ continue;
146
+ }
147
+ if (item.id !== undefined && item.id !== null) {
148
+ const { id, ...updateData } = item;
149
+ const fields = Object.keys(updateData);
150
+ if (fields.length === 0) {
151
+ actions.connect.push({ id });
152
+ continue;
153
+ }
154
+ // The database updates relations only within an update action
155
+ if (action !== 'update' || !config?.input?.allowUpdate) {
156
+ throw new common_1.ResourceError(common_1.ErrorCode.ResourceInvalidRelation, `${resourceName} relation '${key}' does not accept inline updates, an existing record is connected by its id only`, { model: resourceName, relation: key, unknownFields: fields });
157
+ }
158
+ assertKnownFields(resourceName, key, unknownRelationFields(config?.model, 'update', updateData));
159
+ actions.update.push({ where: { id }, data: updateData });
160
+ }
161
+ else if (!config?.input?.allowCreate) {
162
+ // Without inline creation the related record must already exist,
163
+ // so it is created through its own endpoint first
164
+ throw new common_1.ResourceError(common_1.ErrorCode.ResourceInvalidRelation, `${resourceName} relation '${key}' does not accept new records, an id is required`, { model: resourceName, relation: key });
165
+ }
166
+ else {
167
+ // A null id marks no existing record, it is not part of the data
168
+ const { id: _id, ...createData } = item;
169
+ assertKnownFields(resourceName, key, unknownRelationFields(config?.model, 'create', createData));
170
+ const missingFields = missingRelationFields(config?.model, createData);
171
+ if (missingFields.length > 0) {
172
+ throw new common_1.ResourceError(common_1.ErrorCode.ResourceInvalidRelation, `${resourceName} relation '${key}' is missing required fields: ${missingFields.join(', ')}`, { model: resourceName, relation: key, missingFields });
173
+ }
174
+ // A unique key matches an existing record before a new one is created
175
+ if (config?.input?.uniqueKey) {
176
+ actions.connectOrCreate.push({
177
+ where: { [uniqueKey]: item[uniqueKey] },
178
+ create: { ...createData, createdBy }
179
+ });
180
+ }
181
+ else {
182
+ actions.create.push({ ...createData, createdBy });
183
+ }
184
+ }
185
+ }
186
+ const relationActions = {};
187
+ for (const [actionName, actionItems] of Object.entries(actions)) {
188
+ if (actionItems.length > 0) {
189
+ relationActions[actionName] = isArrayType
190
+ ? actionItems
191
+ : actionItems[0];
192
+ }
193
+ }
194
+ // An empty list maps to no action, rather than leaving the raw list in
195
+ // the write data, where the database rejects it
196
+ relations[key] =
197
+ Object.keys(relationActions).length > 0 ? relationActions : undefined;
198
+ }
199
+ // Map relation disconnects if keys are no longer present or the new
200
+ // value is null. Delete relations if orphanRemoval is set to true.
201
+ // Relations that are not set on the current resource are left untouched,
202
+ // so no relation action is applied for them.
203
+ if (action === 'update') {
204
+ const currentValue = currentData[key];
205
+ const removalMethod = config?.orphanRemoval ? 'delete' : 'disconnect';
206
+ if (currentValue && isArrayType) {
207
+ const newValueKeys = value?.map((v) => v[uniqueKey]) ?? [];
208
+ const currentValueKeys = currentValue
209
+ .filter((v) => newValueKeys.indexOf(v[uniqueKey]) === -1)
210
+ .map((v) => ({
211
+ [uniqueKey]: v[uniqueKey]
212
+ }));
213
+ if (currentValueKeys.length > 0) {
214
+ relations[key] = {
215
+ [removalMethod]: currentValueKeys,
216
+ ...(relations[key] ?? {})
217
+ };
218
+ }
219
+ }
220
+ else if (currentValue && value === null) {
221
+ relations[key] = {
222
+ [removalMethod]: { [uniqueKey]: currentValue[uniqueKey] },
223
+ ...(relations[key] ?? {})
224
+ };
225
+ }
226
+ else if (currentValue === undefined) {
227
+ // The relation was not loaded on the current record, so no relation
228
+ // action can be applied safely
229
+ relations[key] = undefined;
230
+ }
231
+ }
232
+ }
233
+ return relations;
234
+ }
235
+ /**
236
+ * Lists the fields of an inline relation payload the related model does not accept for the given action, so a payload
237
+ * written through a service call is rejected the same way the request validation rejects it.
238
+ *
239
+ * @param {string} [resourceName] - The name of the related model whose field restrictions are applied. When omitted,
240
+ * no fields are reported.
241
+ * @param {'create'|'update'} action - The write action the payload is checked for, selecting either the relation
242
+ * create or the relation update model of the related resource.
243
+ * @param {Object} data - The inline relation payload to check, without its `id` field.
244
+ * @return {string[]} The names of the payload fields the related model does not accept for the action, or an empty
245
+ * list if the related model declares no fields for it.
246
+ */
247
+ function unknownRelationFields(resourceName, action, data) {
248
+ const resourceModel = resourceName
249
+ ? (0, core_1.injectModel)(resourceName, false)
250
+ : undefined;
251
+ const writeModel = action === 'create'
252
+ ? resourceModel?.relationCreateModel
253
+ : resourceModel?.relationUpdateModel;
254
+ const properties = (0, common_1.extractSchemaProperties)(writeModel);
255
+ if (!properties) {
256
+ return [];
257
+ }
258
+ return Object.keys(data).filter((name) => !(name in properties));
259
+ }
260
+ /**
261
+ * Lists the fields the related model requires on creation that the given inline relation payload does not provide.
262
+ *
263
+ * @param {string} [resourceName] - The name of the related model whose required fields are checked. When omitted, no
264
+ * fields are reported as missing.
265
+ * @param {Object} data - The inline relation payload to check.
266
+ * @return {string[]} The names of the fields the related model requires on creation that the payload leaves
267
+ * undefined, or an empty list if the related model declares no create schema.
268
+ */
269
+ function missingRelationFields(resourceName, data) {
270
+ const resourceModel = resourceName
271
+ ? (0, core_1.injectModel)(resourceName, false)
272
+ : undefined;
273
+ const createModel = resourceModel?.relationCreateModel;
274
+ if (!createModel) {
275
+ return [];
276
+ }
277
+ const schema = createModel['$ref']
278
+ ? createModel['$defs']?.[createModel['$ref']]
279
+ : createModel;
280
+ return (schema?.required ?? []).filter((name) => data[name] === undefined);
281
+ }
282
+ /**
283
+ * Builds the connect action for the `createdBy` audit relation of a resource, pointing at the currently authenticated
284
+ * user. Returns undefined when the model does not audit the `createdById` field or when no user is authenticated.
285
+ *
286
+ * @param {string} resourceName - The name of the model the audit relation is built for.
287
+ * @return {{connect: {id: ResourceId}}|undefined} The connect action pointing at the id of the currently authenticated
288
+ * user, or undefined if the model does not audit the `createdById` field or no user is authenticated.
289
+ */
290
+ function createdByConnect(resourceName) {
291
+ const resourceModel = (0, core_1.injectModel)(resourceName, false);
292
+ if (resourceModel?.config.audit?.createdById === false) {
293
+ return undefined;
294
+ }
295
+ const currentUser = (0, core_1.currentAuthUser)();
296
+ return currentUser
297
+ ? {
298
+ connect: {
299
+ id: currentUser.id
300
+ }
301
+ }
302
+ : undefined;
303
+ }
304
+ /**
305
+ * Resolves the inclusion value of a single relation field, walking both the levels a self referencing relation
306
+ * repeats itself for and the nested includes it configures. Returns `true` when the relation reads no further than
307
+ * itself for the given action, or a nested `include` clause otherwise.
308
+ *
309
+ * A relation whose related model holds the same relation again points back at its own model, so it repeats itself
310
+ * down the tree up to its configured `output.maxDepth`, letting a category carry its ancestors without every level
311
+ * being spelled out. An `output.include` entry naming that same relation configures the level itself and replaces
312
+ * the repetition.
313
+ *
314
+ * @param {RelationField} relationField - The configuration of the relation whose inclusion value is resolved, read
315
+ * from its `output.include` and `output.maxDepth` properties.
316
+ * @param {string} key - The field name the relation is declared under, matched against the related model to detect a
317
+ * relation pointing back at its own model.
318
+ * @param {ActionType} [action] - The action the inclusions are built for, matched against the configured output type
319
+ * of every nested relation.
320
+ * @param {number} [depth=1] - The level of the relation being resolved, counting the relation itself as the first.
321
+ * @return {boolean|Object} True if the relation reads no further than itself, or its nested `include` clause, and
322
+ * for a list relation of a soft deleted model, the `where` clause skipping the deleted records.
323
+ */
324
+ function buildNestedInclusion(relationField, key, action, depth = 1) {
325
+ const nestedIncludeConfig = relationField?.output?.include;
326
+ const nestedInclusion = {};
327
+ // A file field carries no related model, so it reads no further than itself
328
+ const relatedModel = relationField?.model
329
+ ? (0, core_1.injectModel)((0, common_1.capitalize)(relationField.model), false)
330
+ : undefined;
331
+ const selfRelation = selfReferencingRelation(relatedModel, key);
332
+ const maxDepth = relationField?.output?.maxDepth ?? DEFAULT_RELATION_MAX_DEPTH;
333
+ if (selfRelation && depth < maxDepth && !nestedIncludeConfig?.[key]) {
334
+ nestedInclusion[key] = buildNestedInclusion({ ...selfRelation, output: relationField.output }, key, action, depth + 1);
335
+ }
336
+ for (const [nestedKey, nestedOutput] of Object.entries(nestedIncludeConfig ?? {})) {
337
+ // Check if the nested relation should be included
338
+ if (!shouldIncludeRelation(nestedOutput?.type, action)) {
339
+ continue;
340
+ }
341
+ // The related model carries the field the nested include names, so each
342
+ // level is resolved against the model it actually belongs to
343
+ const nestedRelation = relatedModel?.config?.relations?.[nestedKey] ??
344
+ relatedModel?.config?.files?.[nestedKey];
345
+ nestedInclusion[nestedKey] = buildNestedInclusion({ ...nestedRelation, output: nestedOutput }, nestedKey, action);
346
+ }
347
+ // A list relation reads only the related records that are not soft deleted,
348
+ // while a single relation is cleared of them after it is read
349
+ const liveFilter = relatedModel && (0, common_1.isRelationArray)(relationField)
350
+ ? (0, delete_util_1.liveInclusionFilter)(relatedModel.name)
351
+ : undefined;
352
+ const hasNested = Object.keys(nestedInclusion).length > 0;
353
+ if (!hasNested && !liveFilter) {
354
+ return true;
355
+ }
356
+ return {
357
+ ...(liveFilter ?? {}),
358
+ ...(hasNested ? { include: nestedInclusion } : {})
359
+ };
360
+ }
361
+ /**
362
+ * Reads the relation a related model holds under the given field name, when it points back at that same model. It is
363
+ * the relation a self referencing field repeats itself through, i.e. the `parent` of the parent of a category.
364
+ *
365
+ * @param {ResourceModel} [model] - The related model the field is looked up on.
366
+ * @param {string} key - The field name of the relation.
367
+ * @return {RelationField | undefined} The relation of the model under that name when it references the model itself,
368
+ * undefined otherwise.
369
+ */
370
+ function selfReferencingRelation(model, key) {
371
+ const relation = key ? model?.config?.relations?.[key] : undefined;
372
+ return relation && (0, common_1.capitalize)(relation.model) === model?.name
373
+ ? relation
374
+ : undefined;
375
+ }
376
+ /**
377
+ * Decides whether a relation with the given configured output type is included for the given action. The `none` type
378
+ * is never included, the `single` type is excluded from the query action, and the `multiple` type is only included on
379
+ * the query action or when no action is specified.
380
+ *
381
+ * @param {OutputType} [outputType] - The configured output type of the relation. When omitted, the relation is
382
+ * included for every action.
383
+ * @param {ActionType} [action] - The action the relation would be included for. When omitted, every output type
384
+ * except `none` is included.
385
+ * @return {boolean} True if the relation should be included, false otherwise.
386
+ */
387
+ function shouldIncludeRelation(outputType, action) {
388
+ if (outputType === 'none') {
389
+ return false;
390
+ }
391
+ if (outputType === 'single' && action === 'query') {
392
+ return false;
393
+ }
394
+ return !(outputType === 'multiple' && action && action !== 'query');
395
+ }
396
+ function assertKnownFields(resourceName, relation, unknownFields) {
397
+ if (unknownFields.length > 0) {
398
+ throw new common_1.ResourceError(common_1.ErrorCode.ResourceInvalidRelation, `${resourceName} relation '${relation}' does not accept the fields: ${unknownFields.join(', ')}`, { model: resourceName, relation, unknownFields });
399
+ }
400
+ }
@@ -0,0 +1,31 @@
1
+ import { ActionType, QuerySort } from '@appweaver/common';
2
+ /**
3
+ * Maps a sort input to the ordered list of Prisma `orderBy` entries. The input is accepted either as a comma-separated
4
+ * field list, where a field prefixed with `-` is sorted in descending order, or as a (possibly nested) object of `asc`
5
+ * and `desc` field directions. Both forms support the same field paths: a scalar field of the
6
+ * model, a field of a to-one relation (`author.createdAt` or `{ author: { createdAt: 'desc' } }`), and the related
7
+ * record count of a to-many relation, given either as its count field (`tagsCount`) or as the relation itself
8
+ * (`tags`). The default `createdAt` sort is dropped when the model does not audit that field.
9
+ *
10
+ * @param {QuerySort} sort - The sort input to map, as a comma-separated field list or a sort object.
11
+ * @param {string} resourceName - The name of the model the sort is applied on.
12
+ * @param {ActionType} [action] - The action the sort is applied on, deciding which relations are included in the
13
+ * response and are therefore available to sort by. Defaults to the query action.
14
+ * @return {Object[]} One `orderBy` entry per field, in the order the fields were listed, each holding the single path
15
+ * of that field mapped to `asc` or `desc`. Two fields of the same relation get an entry each, since a database order
16
+ * entry accepts a single field path.
17
+ * @throws {ResourceError} `RESOURCE_INVALID_SORT` if a field does not exist on its model, cannot be sorted by, is given an unknown sort
18
+ * direction, or targets a relation that the action does not include in its response.
19
+ */
20
+ export declare function mapSortValues(sort: QuerySort, resourceName: string, action?: ActionType): any[];
21
+ /**
22
+ * Maps a sort input the same way {@link mapSortValues} does, and terminates it with the primary key when no entry
23
+ * orders by it. Without a total order, records sharing every sort value can be skipped or repeated across pages.
24
+ *
25
+ * @param {QuerySort} sort - The sort input to map, as a comma-separated field list or a sort object.
26
+ * @param {string} resourceName - The name of the model the sort is applied on.
27
+ * @param {ActionType} [action] - The action the sort is applied on. Defaults to the query action.
28
+ * @return {Object[]} The `orderBy` entries, ending in a unique one.
29
+ * @throws {ResourceError} `RESOURCE_INVALID_SORT` under the same conditions as {@link mapSortValues}.
30
+ */
31
+ export declare function mapStableSortValues(sort: QuerySort, resourceName: string, action?: ActionType): any[];
@@ -0,0 +1,235 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.mapSortValues = mapSortValues;
4
+ exports.mapStableSortValues = mapStableSortValues;
5
+ const common_1 = require("@appweaver/common");
6
+ const core_1 = require("@appweaver/core");
7
+ const relation_util_1 = require("./relation-util");
8
+ /** The audit fields every model declares unless it opts out of them. */
9
+ const defaultAuditFields = {
10
+ updatedAt: true,
11
+ createdAt: true,
12
+ createdById: true
13
+ };
14
+ /**
15
+ * Maps a sort input to the ordered list of Prisma `orderBy` entries. The input is accepted either as a comma-separated
16
+ * field list, where a field prefixed with `-` is sorted in descending order, or as a (possibly nested) object of `asc`
17
+ * and `desc` field directions. Both forms support the same field paths: a scalar field of the
18
+ * model, a field of a to-one relation (`author.createdAt` or `{ author: { createdAt: 'desc' } }`), and the related
19
+ * record count of a to-many relation, given either as its count field (`tagsCount`) or as the relation itself
20
+ * (`tags`). The default `createdAt` sort is dropped when the model does not audit that field.
21
+ *
22
+ * @param {QuerySort} sort - The sort input to map, as a comma-separated field list or a sort object.
23
+ * @param {string} resourceName - The name of the model the sort is applied on.
24
+ * @param {ActionType} [action] - The action the sort is applied on, deciding which relations are included in the
25
+ * response and are therefore available to sort by. Defaults to the query action.
26
+ * @return {Object[]} One `orderBy` entry per field, in the order the fields were listed, each holding the single path
27
+ * of that field mapped to `asc` or `desc`. Two fields of the same relation get an entry each, since a database order
28
+ * entry accepts a single field path.
29
+ * @throws {ResourceError} `RESOURCE_INVALID_SORT` if a field does not exist on its model, cannot be sorted by, is given an unknown sort
30
+ * direction, or targets a relation that the action does not include in its response.
31
+ */
32
+ function mapSortValues(sort, resourceName, action = 'query') {
33
+ const inclusions = (0, relation_util_1.mapRelationInclusions)(resourceName, action);
34
+ const orderBy = [];
35
+ const mappedPaths = new Set();
36
+ for (const [path, direction] of sortEntries(sort)) {
37
+ const mappedPath = mapSortPath(resourceName, path, inclusions);
38
+ // Every field gets an entry of its own, since a database order entry holds
39
+ // a single field path, even when several of them share a relation. A field
40
+ // listed twice is already ordered by its first entry, so it is dropped
41
+ if (!mappedPath || mappedPaths.has(mappedPath)) {
42
+ continue;
43
+ }
44
+ mappedPaths.add(mappedPath);
45
+ orderBy.push((0, common_1.setValue)({}, mappedPath, direction));
46
+ }
47
+ return orderBy;
48
+ }
49
+ /**
50
+ * Maps a sort input the same way {@link mapSortValues} does, and terminates it with the primary key when no entry
51
+ * orders by it. Without a total order, records sharing every sort value can be skipped or repeated across pages.
52
+ *
53
+ * @param {QuerySort} sort - The sort input to map, as a comma-separated field list or a sort object.
54
+ * @param {string} resourceName - The name of the model the sort is applied on.
55
+ * @param {ActionType} [action] - The action the sort is applied on. Defaults to the query action.
56
+ * @return {Object[]} The `orderBy` entries, ending in a unique one.
57
+ * @throws {ResourceError} `RESOURCE_INVALID_SORT` under the same conditions as {@link mapSortValues}.
58
+ */
59
+ function mapStableSortValues(sort, resourceName, action = 'query') {
60
+ const orderBy = mapSortValues(sort, resourceName, action);
61
+ if (!orderBy.some((entry) => 'id' in entry)) {
62
+ orderBy.push({ id: 'asc' });
63
+ }
64
+ return orderBy;
65
+ }
66
+ /**
67
+ * Flattens a sort input into the list of its field paths and directions, keeping the order the fields were declared
68
+ * in. String inputs are split on commas, with a `-` or `+` prefix selecting the direction, and object inputs are
69
+ * walked recursively, joining the nested keys with a dot.
70
+ *
71
+ * @param {QuerySort} sort - The sort input to flatten.
72
+ * @param {string} [parentPath] - The dot notation path of the object the walk descended from.
73
+ * @return {SortEntry[]} The flattened sort entries, each holding a dot notation field path and its direction.
74
+ * @throws {ResourceError} `RESOURCE_INVALID_SORT` if a sort value is neither a direction nor a nested object of directions.
75
+ */
76
+ function sortEntries(sort, parentPath = '') {
77
+ if ((0, common_1.isString)(sort)) {
78
+ return sort
79
+ .split(',')
80
+ .map((part) => part.trim())
81
+ .filter((part) => part.length > 0 && part !== '-' && part !== '+')
82
+ .map((part) => [
83
+ part.replace(/^[-+]/, ''),
84
+ part.startsWith('-') ? 'desc' : 'asc'
85
+ ]);
86
+ }
87
+ if (!(0, common_1.isPlainObject)(sort)) {
88
+ throw new common_1.ResourceError(common_1.ErrorCode.ResourceInvalidSort, `Invalid sort value${parentPath ? ` for the '${parentPath}' field` : ''}, ` +
89
+ 'expected a field list string or a sort object', { field: parentPath || undefined });
90
+ }
91
+ const entries = [];
92
+ for (const [field, value] of Object.entries(sort)) {
93
+ if (value === undefined || value === null) {
94
+ continue;
95
+ }
96
+ const path = parentPath ? `${parentPath}.${field}` : field;
97
+ if ((0, common_1.isPlainObject)(value)) {
98
+ entries.push(...sortEntries(value, path));
99
+ continue;
100
+ }
101
+ if (value !== 'asc' && value !== 'desc') {
102
+ throw new common_1.ResourceError(common_1.ErrorCode.ResourceInvalidSort, `Invalid sort direction '${value}' for the '${path}' field, expected 'asc' or 'desc'`, { field: path });
103
+ }
104
+ entries.push([path, value]);
105
+ }
106
+ return entries;
107
+ }
108
+ /**
109
+ * Resolves a single sort field path against the model it is applied on, validating every segment and rewriting the
110
+ * relation counts to their `_count` form. Relation segments are additionally checked against the relations the action
111
+ * includes in its response, since a relation that is not included cannot be sorted by.
112
+ *
113
+ * @param {string} resourceName - The name of the model the path starts at.
114
+ * @param {string} path - The dot notation field path to resolve.
115
+ * @param {Object} inclusions - The `include` clause of the action, as built by {@link mapRelationInclusions}.
116
+ * @return {string|undefined} The resolved dot notation path, or undefined when the field is the `createdAt` audit
117
+ * field of a model that does not audit it, in which case the sort entry is dropped.
118
+ * @throws {ResourceError} `RESOURCE_INVALID_SORT` if a segment does not exist on its model, cannot be sorted by, or targets a relation the
119
+ * action does not include in its response.
120
+ */
121
+ function mapSortPath(resourceName, path, inclusions) {
122
+ const segments = path.split('.').filter((segment) => segment.length > 0);
123
+ const mappedSegments = [];
124
+ let modelName = resourceName;
125
+ let modelInclusions = inclusions;
126
+ for (const [index, field] of segments.entries()) {
127
+ const model = (0, core_1.injectModel)(modelName, false);
128
+ if (!model) {
129
+ throw new common_1.ResourceError(common_1.ErrorCode.ResourceInvalidSort, `Cannot sort by the '${path}' field, the '${modelName}' model is not loaded`, { model: modelName, field: path });
130
+ }
131
+ const isLast = index === segments.length - 1;
132
+ const relation = relationField(model, field);
133
+ // A scalar field ends the path, so it is the only segment a relation of the
134
+ // same name cannot shadow
135
+ if (!relation && isScalarField(model, field)) {
136
+ if (!isLast) {
137
+ throw new common_1.ResourceError(common_1.ErrorCode.ResourceInvalidSort, `Cannot sort by the '${path}' field, '${field}' is not a relation of the ${model.name} model`, { model: model.name, field: path });
138
+ }
139
+ // The default sort still names the createdAt field on the models that do
140
+ // not audit it, so the entry is dropped instead of rejected
141
+ if (field === 'createdAt' && model.config.audit?.createdAt === false) {
142
+ return undefined;
143
+ }
144
+ mappedSegments.push(field);
145
+ break;
146
+ }
147
+ // A count field sorts by the number of related records, which the database
148
+ // orders by through the _count aggregation of the relation
149
+ if (!relation && isLast && (0, common_1.isCountField)(field)) {
150
+ const countedField = field.slice(0, -'Count'.length);
151
+ const countedRelation = relationField(model, countedField);
152
+ if (countedRelation && isFieldArray(countedRelation)) {
153
+ mappedSegments.push(countedField, '_count');
154
+ break;
155
+ }
156
+ }
157
+ if (!relation) {
158
+ throw new common_1.ResourceError(common_1.ErrorCode.ResourceInvalidSort, `Cannot sort by the '${path}' field, '${field}' is not a sortable field of the ${model.name} model`, { model: model.name, field: path });
159
+ }
160
+ // A to-many relation holds no single value to sort by, so it is sorted by
161
+ // the number of its related records instead
162
+ if (isFieldArray(relation)) {
163
+ if (!isLast) {
164
+ throw new common_1.ResourceError(common_1.ErrorCode.ResourceInvalidSort, `Cannot sort by the '${path}' field, the '${field}' relation holds a list of records, ` +
165
+ `sort by the '${(0, common_1.countFieldName)(field)}' field instead`, { model: model.name, field: path });
166
+ }
167
+ mappedSegments.push(field, '_count');
168
+ break;
169
+ }
170
+ if (isLast) {
171
+ throw new common_1.ResourceError(common_1.ErrorCode.ResourceInvalidSort, `Cannot sort by the '${path}' field, the '${field}' relation requires a nested field to sort by`, { model: model.name, field: path });
172
+ }
173
+ const inclusion = modelInclusions?.[field];
174
+ if (!inclusion) {
175
+ throw new common_1.ResourceError(common_1.ErrorCode.ResourceInvalidSort, `Cannot sort by the '${path}' field, the '${field}' relation is not included in the response`, { model: model.name, field: path });
176
+ }
177
+ mappedSegments.push(field);
178
+ modelName = (0, common_1.capitalize)(relatedModelName(model, field));
179
+ modelInclusions = (0, common_1.isPlainObject)(inclusion) ? inclusion.include : undefined;
180
+ }
181
+ return mappedSegments.join('.');
182
+ }
183
+ /**
184
+ * Reads the relation or file field configuration of a model field.
185
+ *
186
+ * @param {ResourceModel} model - The model the field belongs to.
187
+ * @param {string} field - The name of the field to read.
188
+ * @return {RelationField|FileField|undefined} The relation or file configuration of the field, or undefined when the
189
+ * field is not a relation.
190
+ */
191
+ function relationField(model, field) {
192
+ return model.config.relations?.[field] ?? model.config.files?.[field];
193
+ }
194
+ /**
195
+ * Resolves the model name a relation or file field of a model points at.
196
+ *
197
+ * @param {ResourceModel} model - The model the relation belongs to.
198
+ * @param {string} field - The name of the relation field.
199
+ * @return {string} The name of the related model, which is always `File` for a file field.
200
+ */
201
+ function relatedModelName(model, field) {
202
+ return model.config.relations?.[field]?.model ?? 'File';
203
+ }
204
+ /**
205
+ * Determines whether a relation or file field holds a list of related records, which can only be sorted by their
206
+ * count.
207
+ *
208
+ * @param {RelationField|FileField} field - The relation or file field configuration.
209
+ * @return {boolean} True when the field holds a list of related records.
210
+ */
211
+ function isFieldArray(field) {
212
+ return 'model' in field ? (0, common_1.isRelationArray)(field) : field.array === true;
213
+ }
214
+ /**
215
+ * Determines whether a model field is a scalar column the database can sort by. The id and the audited fields are
216
+ * always sortable, the configured scalars are sortable unless they are hidden or hold a list of values, and the
217
+ * virtual fields are never sortable, since they have no column of their own.
218
+ *
219
+ * @param {ResourceModel} model - The model the field belongs to.
220
+ * @param {string} field - The name of the field to check.
221
+ * @return {boolean} True when the field can be sorted by.
222
+ */
223
+ function isScalarField(model, field) {
224
+ if (field === 'id') {
225
+ return true;
226
+ }
227
+ const auditFields = { ...defaultAuditFields, ...(model.config.audit ?? {}) };
228
+ if (field in auditFields) {
229
+ // The createdAt field of a model that does not audit it is still accepted,
230
+ // so the default sort value does not have to be adjusted per model
231
+ return auditFields[field] === true || field === 'createdAt';
232
+ }
233
+ const scalar = model.config.scalars?.[field];
234
+ return !!scalar && !scalar.hidden && scalar.array !== true;
235
+ }