@appweaver/core 1.1.5 → 1.2.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 (43) hide show
  1. package/cache/eviction/lfu-eviction-index.js +3 -0
  2. package/export/export-service.d.ts +4 -2
  3. package/export/export-service.js +27 -17
  4. package/factory/create-model.js +72 -36
  5. package/factory/create-service.js +1 -1
  6. package/memory/in-memory.js +10 -6
  7. package/package.json +3 -3
  8. package/resource/index.d.ts +1 -0
  9. package/resource/index.js +1 -0
  10. package/resource/resource-schema.d.ts +0 -5
  11. package/resource/resource-schema.js +25 -10
  12. package/resource/resource-service.d.ts +214 -36
  13. package/resource/resource-service.js +247 -402
  14. package/resource/schemas/index.d.ts +3 -0
  15. package/resource/schemas/index.js +19 -0
  16. package/resource/schemas/resource-aggregate-schema.d.ts +54 -0
  17. package/resource/schemas/resource-aggregate-schema.js +131 -0
  18. package/resource/schemas/resource-filter-schema.d.ts +39 -0
  19. package/resource/schemas/resource-filter-schema.js +153 -0
  20. package/resource/schemas/resource-sort-schema.d.ts +37 -0
  21. package/resource/schemas/resource-sort-schema.js +114 -0
  22. package/resource/utils/aggregate-util.d.ts +113 -0
  23. package/resource/utils/aggregate-util.js +323 -0
  24. package/resource/utils/filter-util.d.ts +24 -0
  25. package/resource/utils/filter-util.js +283 -0
  26. package/resource/utils/index.d.ts +4 -0
  27. package/resource/utils/index.js +20 -0
  28. package/resource/utils/relation-util.d.ts +84 -0
  29. package/resource/utils/relation-util.js +344 -0
  30. package/resource/utils/sort-util.d.ts +20 -0
  31. package/resource/utils/sort-util.js +218 -0
  32. package/security/create-auth-resources.js +2 -2
  33. package/security/helper.js +1 -1
  34. package/security/resources/api-key/model.js +1 -0
  35. package/security/resources/api-key/service.d.ts +1 -1
  36. package/security/resources/role/model.js +1 -1
  37. package/server/create-server.js +6 -3
  38. package/server/register-route.js +1 -0
  39. package/server/swagger.js +87 -21
  40. package/server/virtual-projection.js +10 -8
  41. package/types/generated.d.ts +110 -4
  42. package/utils/schema-util.js +1 -1
  43. package/utils/virtual-util.js +1 -1
@@ -1,13 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ResourceService = void 0;
4
- const date_fns_1 = require("date-fns");
5
4
  const common_1 = require("@appweaver/common");
6
5
  const context_1 = require("../context");
7
6
  const utils_1 = require("../utils");
8
- const security_1 = require("../security");
9
7
  const cache_1 = require("../cache");
10
8
  const errors_1 = require("../errors");
9
+ const utils_2 = require("./utils");
11
10
  class ResourceService {
12
11
  modelName;
13
12
  /** @internal */
@@ -18,6 +17,14 @@ class ResourceService {
18
17
  _cacheService = (0, context_1.inject)(cache_1.CacheService);
19
18
  /** @internal */
20
19
  _client;
20
+ /**
21
+ * Creates the service for the given resource model and resolves its database
22
+ * client from the Prisma client instance.
23
+ *
24
+ * @param {string} modelName The resource model name this service operates on,
25
+ * as defined by its model file (i.e. `User`, `Post`).
26
+ * @throws An error if no database client exists for the provided model name.
27
+ */
21
28
  constructor(modelName) {
22
29
  this.modelName = modelName;
23
30
  this._client = this._db.client()[(0, common_1.uncapitalize)(modelName)];
@@ -25,12 +32,32 @@ class ResourceService {
25
32
  throw new Error(`ResourceService initialized with invalid model name: ${modelName}`);
26
33
  }
27
34
  }
35
+ /**
36
+ * The underlying Prisma model delegate for this resource, useful for
37
+ * executing custom database operations that the service methods do not cover.
38
+ *
39
+ * @returns {ResourceClient} The resource client of the model this service was
40
+ * created for.
41
+ */
28
42
  get client() {
29
43
  return this._client;
30
44
  }
45
+ /**
46
+ * Finds a single resource by its id, applying the read restrictions and the
47
+ * access check of this service, and including all relation and file fields
48
+ * configured for output on the find action. A resource event is emitted after
49
+ * a successful lookup.
50
+ *
51
+ * @param {number} id The id of the resource to find.
52
+ * @returns {Promise<Object>} The found resource with its virtual fields and
53
+ * relation counts projected.
54
+ * @throws {@link HttpError} 404 if the resource does not exist or is filtered
55
+ * out by the read restrictions, 403 if the access check denies it, and 500 on
56
+ * a database error.
57
+ */
31
58
  async find(id) {
32
59
  const restrictions = await this.readRestrictions('find', id);
33
- const includeRelations = this.mapRelationInclusions('find');
60
+ const includeRelations = (0, utils_2.mapRelationInclusions)(this._client.name, 'find');
34
61
  let resource;
35
62
  try {
36
63
  resource = await this._client.findFirst({
@@ -53,13 +80,42 @@ class ResourceService {
53
80
  });
54
81
  return this.projectResource(resource);
55
82
  }
83
+ /**
84
+ * Queries a page of resources matching the provided filter. The filter is
85
+ * mapped to a database query, combined with the optional `searchText` full
86
+ * text search query and the read restrictions of this service, and executed
87
+ * together with the total count in a single transaction. A resource event is
88
+ * emitted after a successful query.
89
+ *
90
+ * @param {Object} [filter] The query filter object, supporting the logical
91
+ * (`_and`, `_or`, `_not`, `_nor`), comparison (`_eq`, `_ne`, `_gt`, `_gte`,
92
+ * `_lt`, `_lte`, `_in`, `_nin`, `_between`, `_like`, `_ilike`, `_starts`,
93
+ * `_ends`, `_contains`, `_exists`), list (`_has`, `_hasSome`, `_hasEvery`,
94
+ * `_isEmpty`), and relation (`_some`, `_every`, `_none`) operators, as well
95
+ * as plain field values. Its `searchText` property, if present, is passed to
96
+ * {@link ResourceService.textSearchQuery} instead of being matched as a
97
+ * field.
98
+ * @param {number} [page] The one-based page number of results to return.
99
+ * @param {number} [size] The maximum number of results per page.
100
+ * @param {QuerySort} [sort] The fields to sort by, either as a comma-separated
101
+ * list where a field prefixed with `-` is sorted in descending order
102
+ * (i.e. `-createdAt,id`), or as an object of field directions
103
+ * (i.e. `{ createdAt: 'desc', id: 'asc' }`). Both forms support the fields of
104
+ * the included to-one relations, given with a dot notation (`author.createdAt`)
105
+ * or as a nested object (`{ author: { createdAt: 'desc' } }`).
106
+ * @returns {Promise<QueryResponse<Object>>} The paged query response containing
107
+ * the returned resources, the count of the returned items and the total count
108
+ * of matching resources.
109
+ * @throws {@link HttpError} 400 if the sort input names a field that cannot be
110
+ * sorted by, and 500 on a database error.
111
+ */
56
112
  async query(filter = {}, page = 1, size = 50, sort = '-createdAt,id') {
57
113
  const restrictions = await this.readRestrictions('query', filter);
58
114
  const textSearch = this.extractTextSearchQuery(filter);
59
- const mappedFilter = this.mapQueryFilter(filter);
115
+ const mappedFilter = (0, utils_2.mapQueryFilter)(filter, this._client.name);
60
116
  const query = { AND: [mappedFilter, textSearch, restrictions] };
61
- const includeRelations = this.mapRelationInclusions('query');
62
- const orderBy = this.mapSortValues(sort);
117
+ const includeRelations = (0, utils_2.mapRelationInclusions)(this._client.name, 'query');
118
+ const orderBy = (0, utils_2.mapSortValues)(sort, this._client.name, 'query');
63
119
  let resources;
64
120
  let totalCount;
65
121
  try {
@@ -88,60 +144,72 @@ class ResourceService {
88
144
  items: resources.map((resource) => this.projectResource(resource))
89
145
  };
90
146
  }
147
+ /**
148
+ * Aggregates resources matching the provided filter over a date range, both
149
+ * as a single overall result and as a series of results for the equally sized
150
+ * periods the range is split into. All aggregations are executed in a single
151
+ * transaction.
152
+ *
153
+ * @param {Object} [filter] The query filter object, mapped the same way as in
154
+ * {@link ResourceService.query}.
155
+ * @param {AggregateSelect<Object>} select The aggregation operations to perform
156
+ * per field (i.e. `{ views: { count: true, sum: true } }`). Only the numeric
157
+ * fields of the model accept every operator, while its date fields accept
158
+ * `count`, `min`, `max`, `first` and `last`. The `first` and `last` operators
159
+ * take the value the earliest and the latest record of a period holds, which
160
+ * costs one additional query per period and boundary, skipped for the periods
161
+ * holding no record.
162
+ * @param {string} [dateField] The date field the range is applied on.
163
+ * @param {string} [from] The ISO date string of the range start. Defaults to
164
+ * seven days before the range end.
165
+ * @param {string} [to] The ISO date string of the range end. Defaults to the
166
+ * current date and time.
167
+ * @param {number} [step] The size of a single period in units of the
168
+ * automatically selected time unit. If not provided, one unit is used and the
169
+ * unit is derived from the range length (seconds up to a minute, minutes up to
170
+ * an hour, hours up to a day, days up to a month, months up to a year, and
171
+ * years beyond that).
172
+ * @param {boolean} [safeIncrement] Whether the period increments use the
173
+ * derived time unit and stay consistent across daylight saving time changes.
174
+ * When false, the step is interpreted in seconds.
175
+ * @returns {Promise<AggregateResponse<Object>>} The aggregation response with
176
+ * the overall total and one result per period, each labeled with the median
177
+ * date of its period.
178
+ * @throws {@link HttpError} 400 if the selection is empty or names a field or
179
+ * operator that cannot be aggregated, and 500 on a database error.
180
+ */
91
181
  async aggregate(filter = {}, select, dateField = 'createdAt', from, to, step, safeIncrement = true) {
92
- const toDate = (0, date_fns_1.parseISO)(to ?? new Date().toISOString());
93
- const fromDate = from ? (0, date_fns_1.parseISO)(from) : (0, date_fns_1.subDays)(toDate, 7);
94
- const iterator = this.makeAggregationIterator(fromDate, toDate, step, safeIncrement);
95
- const dateRanges = [];
96
- let currentDate = (0, date_fns_1.parseISO)(fromDate.toISOString());
97
- while (currentDate < toDate) {
98
- const date = currentDate;
99
- const median = iterator.addPeriod(date, (iterator.step - 1) / 2);
100
- currentDate = iterator.addPeriod(currentDate, iterator.step);
101
- dateRanges.push({ from: date, to: currentDate, median });
102
- }
103
- const aggregateOperations = this.mapAggregationValues(select);
182
+ const { fromDate, toDate, ranges: dateRanges } = (0, utils_2.buildAggregationPeriods)(from, to, step, safeIncrement);
183
+ const operations = (0, utils_2.mapAggregationSelect)(select, this._client.name);
184
+ (0, utils_2.checkAggregationDateField)(dateField, this._client.name);
104
185
  const restrictions = await this.readRestrictions('aggregate', filter);
105
186
  const textSearch = this.extractTextSearchQuery(filter);
106
- const mappedFilter = this.mapQueryFilter(filter);
187
+ const mappedFilter = (0, utils_2.mapQueryFilter)(filter, this._client.name);
107
188
  const query = { AND: [mappedFilter, textSearch, restrictions] };
189
+ const rangeQuery = (rangeFrom, rangeTo) => ({
190
+ AND: [query, { [dateField]: { gte: rangeFrom, lt: rangeTo } }]
191
+ });
192
+ // Aggregates a single range and reads the boundary records it holds the
193
+ // first and last values of which the database cannot aggregate
194
+ const aggregateRange = async (txModel, where) => {
195
+ const result = await txModel.aggregate({
196
+ ...operations.aggregate,
197
+ where
198
+ });
199
+ const boundaries = await (0, utils_2.readAggregationBoundaries)(txModel, where, dateField, operations, (0, utils_2.aggregationRecordCount)(result));
200
+ return { ...result, ...boundaries };
201
+ };
108
202
  let total = {};
109
203
  let items = [];
110
204
  try {
111
205
  [total, items] = await this._db.client().$transaction(async (tx) => {
112
206
  const txModel = tx[this._client.name];
113
- const overall = await txModel.aggregate({
114
- ...aggregateOperations,
115
- where: {
116
- AND: [
117
- query,
118
- {
119
- [dateField]: {
120
- gte: fromDate,
121
- lt: toDate
122
- }
123
- }
124
- ]
125
- }
126
- });
207
+ const overall = await aggregateRange(txModel, rangeQuery(fromDate, toDate));
127
208
  // Skip executing the same query if only one range value is generated
128
209
  if (dateRanges.length === 1) {
129
210
  return [overall, [overall]];
130
211
  }
131
- const ranges = await Promise.all(dateRanges.map((dateRange) => txModel.aggregate({
132
- ...aggregateOperations,
133
- where: {
134
- AND: [
135
- query,
136
- {
137
- [dateField]: {
138
- gte: dateRange.from,
139
- lt: dateRange.to
140
- }
141
- }
142
- ]
143
- }
144
- })));
212
+ const ranges = await Promise.all(dateRanges.map((dateRange) => aggregateRange(txModel, rangeQuery(dateRange.from, dateRange.to))));
145
213
  return [overall, ranges];
146
214
  });
147
215
  }
@@ -149,15 +217,32 @@ class ResourceService {
149
217
  throw new errors_1.HttpError('Error on results aggregation', 500, e);
150
218
  }
151
219
  return {
152
- total: this.mapAggregationValues(total, true),
220
+ total: (0, utils_2.mapAggregationResult)(total),
153
221
  items: items.map((item, index) => ({
154
222
  date: dateRanges[index].median,
155
- result: this.mapAggregationValues(item, true)
223
+ result: (0, utils_2.mapAggregationResult)(item)
156
224
  }))
157
225
  };
158
226
  }
227
+ /**
228
+ * Creates a new resource. The provided data is merged with the write
229
+ * restrictions, checked for access, sanitized against the model configuration
230
+ * and mapped to the relation write actions (connect, create, or
231
+ * connect-or-create) before the record is created. The `createdBy` audit
232
+ * relation is connected to the currently authenticated user when configured.
233
+ * The resource cache is invalidated and a resource event is emitted after a
234
+ * successful create.
235
+ *
236
+ * @param {Object} data The data of the resource to create, including any inline
237
+ * relation and file payloads.
238
+ * @returns {Promise<Object>} The created resource with its virtual fields and
239
+ * relation counts projected.
240
+ * @throws {@link HttpError} 403 if the access check denies the action, 400 if
241
+ * an inline relation payload is missing required fields or the relation does
242
+ * not accept new records, and 500 on a database error.
243
+ */
159
244
  async create(data) {
160
- const createdBy = this.createdByConnect();
245
+ const createdBy = (0, utils_2.createdByConnect)(this._client.name);
161
246
  const restrictions = await this.writeRestrictions('create', data);
162
247
  const createData = (0, common_1.removeUndefined)({
163
248
  ...data,
@@ -168,8 +253,8 @@ class ResourceService {
168
253
  throw new errors_1.HttpError(`${this._client.name} create action is forbidden`, 403);
169
254
  }
170
255
  const sanitizedData = this.sanitizeData('create', createData);
171
- const connectRelations = this.mapRelationActions('create', sanitizedData);
172
- const includeRelations = this.mapRelationInclusions('create');
256
+ const connectRelations = (0, utils_2.mapRelationActions)(this._client.name, 'create', sanitizedData);
257
+ const includeRelations = (0, utils_2.mapRelationInclusions)(this._client.name, 'create');
173
258
  let resource;
174
259
  try {
175
260
  resource = await this._client.create({
@@ -190,6 +275,25 @@ class ResourceService {
190
275
  });
191
276
  return this.projectResource(resource);
192
277
  }
278
+ /**
279
+ * Updates an existing resource by its id. The current record is loaded with
280
+ * the read restrictions applied and checked for access, then updated with the
281
+ * data merged with the write restrictions inside a single transaction.
282
+ * Relations missing from the new value are disconnected, or deleted when
283
+ * `orphanRemoval` is configured for them. The resource cache is invalidated
284
+ * and a resource event carrying both the previous and the current state is
285
+ * emitted after a successful update.
286
+ *
287
+ * @param {number} id The id of the resource to update.
288
+ * @param {Object} data The partial data to update the resource with, including
289
+ * any inline relation and file payloads.
290
+ * @returns {Promise<Object>} The updated resource with its virtual fields and
291
+ * relation counts projected.
292
+ * @throws {@link HttpError} 404 if the resource does not exist or is filtered
293
+ * out by the read restrictions, 403 if the access check denies the action, 400
294
+ * if an inline relation payload is missing required fields or the relation
295
+ * does not accept new records, and 500 on a database error.
296
+ */
193
297
  async update(id, data) {
194
298
  const readRestrictions = await this.readRestrictions('update', {
195
299
  id,
@@ -204,7 +308,7 @@ class ResourceService {
204
308
  ...writeRestrictions
205
309
  });
206
310
  const sanitizedData = this.sanitizeData('update', updateData);
207
- const includeRelations = this.mapRelationInclusions('update');
311
+ const includeRelations = (0, utils_2.mapRelationInclusions)(this._client.name, 'update');
208
312
  let updateResource;
209
313
  let resource;
210
314
  try {
@@ -223,7 +327,7 @@ class ResourceService {
223
327
  if (!access) {
224
328
  throw new errors_1.HttpError(`${this._client.name} update action is forbidden`, 403);
225
329
  }
226
- const setRelations = this.mapRelationActions('update', sanitizedData, current);
330
+ const setRelations = (0, utils_2.mapRelationActions)(this._client.name, 'update', sanitizedData, current);
227
331
  const updated = await txModel.update({
228
332
  where: { id },
229
333
  include: includeRelations,
@@ -245,6 +349,19 @@ class ResourceService {
245
349
  });
246
350
  return this.projectResource(resource);
247
351
  }
352
+ /**
353
+ * Deletes an existing resource by its id. The current record is loaded with
354
+ * the read restrictions applied and checked for access before it is deleted
355
+ * inside a single transaction. The resource cache is invalidated and a
356
+ * resource event is emitted after a successful delete.
357
+ *
358
+ * @param {number} id The id of the resource to delete.
359
+ * @returns {Promise<Object>} The deleted resource with its virtual fields and
360
+ * relation counts projected.
361
+ * @throws {@link HttpError} 404 if the resource does not exist or is filtered
362
+ * out by the read restrictions, 403 if the access check denies the action, and
363
+ * 500 on a database error.
364
+ */
248
365
  async delete(id) {
249
366
  const restrictions = await this.readRestrictions('delete', id);
250
367
  let resource;
@@ -261,7 +378,7 @@ class ResourceService {
261
378
  if (!access) {
262
379
  throw new errors_1.HttpError(`${this._client.name} delete action is forbidden`, 403);
263
380
  }
264
- const includeRelations = this.mapRelationInclusions('delete');
381
+ const includeRelations = (0, utils_2.mapRelationInclusions)(this._client.name, 'delete');
265
382
  return await txModel.delete({
266
383
  where: { id },
267
384
  include: includeRelations
@@ -285,21 +402,24 @@ class ResourceService {
285
402
  * operations on specific data for currently logged-in user and other
286
403
  * authorization rules. The returned object will be applied as a filter on
287
404
  * all actions (except create action) which will prevent unwanted data access
288
- * and modifications. This method can also cancel current action by throwing
289
- * an error, recommended is {@link HttpError } with appropriate HTTP error
290
- * code.
405
+ * and modifications. This method can also cancel the current action by
406
+ * throwing an error, recommended is {@link HttpError } with appropriate HTTP
407
+ * error code.
291
408
  *
292
- * @param action The called action method on this service (find, query,
293
- * aggregate, update, or delete)
294
- * @param data The passed data to the called function, can be number or
295
- * object. If the data is a type of number, then it represents the resource
296
- * id, otherwise it depends on the action and can be one of the following:
409
+ * @param {ActionType} action The called action method on this service (find,
410
+ * query, aggregate, update, or delete)
411
+ * @param {Object|number} data The passed data to the called function can be
412
+ * number or object. If the data is a type of number, then it represents the
413
+ * resource id, otherwise it depends on the action and can be one of the
414
+ * following:
297
415
  *
298
416
  * - query and aggregate -> filter object
299
417
  * - update -> combined id and the data object (i.e. { id, ...data })
300
418
  *
301
419
  * For other actions (find and delete) it represents the resource id.
302
- * @return The filter containing additional query restrictions.
420
+ * @return {Promise<Object>} The database-level `where` conditions containing
421
+ * additional query restrictions. The returned object is applied directly to
422
+ * the database query, so it uses the native Prisma filter syntax.
303
423
  */
304
424
  async readRestrictions(action, data) {
305
425
  return {};
@@ -313,13 +433,15 @@ class ResourceService {
313
433
  * current action by throwing an error, with the recommended type being
314
434
  * {@link HttpError} with the appropriate HTTP error code.
315
435
  *
316
- * @param action The called action method on this service (create or update)
317
- * @param data The passed data to the called function, which should be an
318
- * object representing the resource data to be created or updated. For
436
+ * @param {'create'|'update'} action The called action method on this service
437
+ * (create or update)
438
+ * @param {Object} data The passed data to the called function, which should be
439
+ * an object representing the resource data to be created or updated. For
319
440
  * update operations, this object will also include the resource ID.
320
441
  *
321
- * @return A partial object containing additional data restrictions to be
322
- * applied, or an empty object if no restrictions are necessary.
442
+ * @return {Promise<Object>} A partial object containing additional data
443
+ * restrictions to be applied, or an empty object if no restrictions are
444
+ * necessary.
323
445
  */
324
446
  async writeRestrictions(action, data) {
325
447
  return {};
@@ -330,10 +452,11 @@ class ResourceService {
330
452
  * a currently authenticated user or other logic, this method should return
331
453
  * false. Otherwise, it returns true and continues with the request execution.
332
454
  *
333
- * @param action The called action method on this service (find, query,
334
- * aggregate, create, update, or delete)
335
- * @param resource The resource object that is being check for access.
336
- * @returns True if the access for resource is granted, false otherwise.
455
+ * @param {ActionType} action The called action method on this service (find,
456
+ * query, aggregate, create, update, or delete)
457
+ * @param {Object} resource The resource object that is being checked for access.
458
+ * @returns {Promise<boolean>} True if the access for resource is granted, false
459
+ * otherwise.
337
460
  */
338
461
  async checkAccess(action, resource) {
339
462
  return true;
@@ -344,14 +467,25 @@ class ResourceService {
344
467
  * search text into a format suitable for text search functionality, returning
345
468
  * a query object that can be used to filter results.
346
469
  *
347
- * @param searchText The text string used for searching resources.
348
- * @returns A query object that represents the conditions for the text
349
- * search operation. This will be used by the database query methods to
350
- * retrieve matching resources.
470
+ * @param {string} searchText The text string used for searching resources.
471
+ * @returns {Object} A query object that represents the conditions for the text
472
+ * search operation, using the native Prisma filter syntax. This will be used
473
+ * by the database query methods to retrieve matching resources.
351
474
  */
352
475
  textSearchQuery(searchText) {
353
476
  return {};
354
477
  }
478
+ /**
479
+ * Removes the `searchText` property from the provided filter and converts it
480
+ * into a text search query using
481
+ * {@link ResourceService.textSearchQuery}. The filter object is mutated so the
482
+ * search text is not matched as a regular resource field.
483
+ *
484
+ * @param {Object} filter The request filter object, possibly containing a
485
+ * `searchText` property. The property is deleted from this object when present.
486
+ * @returns {Object} The text search query for the extracted search text, or an
487
+ * empty object if the filter contains no search text.
488
+ */
355
489
  extractTextSearchQuery(filter) {
356
490
  if (filter.searchText) {
357
491
  const searchQuery = this.textSearchQuery(filter.searchText);
@@ -360,92 +494,20 @@ class ResourceService {
360
494
  }
361
495
  return {};
362
496
  }
363
- mapSortValues(sort) {
364
- const sortMap = {};
365
- const resourceModel = (0, context_1.injectModel)(this._client.name);
366
- const parts = sort.split(',');
367
- for (const part of parts) {
368
- let path = part.trim();
369
- const order = path.startsWith('-') ? 'desc' : 'asc';
370
- path = path.replace(/[-+]/g, '');
371
- // Skip the default sort by createdAt field if not configured
372
- if (resourceModel.config.audit?.createdAt === false &&
373
- path === 'createdAt') {
374
- continue;
375
- }
376
- // Map relations count field sort order.
377
- if ((0, common_1.isCountField)(path)) {
378
- path = path.replace('Count', '._count');
379
- }
380
- (0, common_1.setValue)(sortMap, path, order);
381
- }
382
- return Object.entries(sortMap).map(([key, value]) => ({ [key]: value }));
383
- }
384
- mapAggregationValues(select, isOutput = false) {
385
- const aggregationMap = {};
386
- for (const field in select) {
387
- const operators = select[field];
388
- for (const operator in operators) {
389
- const value = operators[operator];
390
- const path = isOutput
391
- ? `${operator}.${field.substring(1)}`
392
- : `_${operator}.${field}`;
393
- (0, common_1.setValue)(aggregationMap, path, value);
394
- }
395
- }
396
- return aggregationMap;
397
- }
398
- makeAggregationIterator(fromDate, toDate, step, safeIncrement = true) {
399
- let stepAmount = step;
400
- let incrementFn = date_fns_1.addSeconds;
401
- if (!stepAmount) {
402
- const diffInSeconds = (0, date_fns_1.differenceInSeconds)(toDate, fromDate);
403
- const diffInMonths = (0, date_fns_1.differenceInMonths)(toDate, fromDate);
404
- const diffInYears = (0, date_fns_1.differenceInYears)(toDate, fromDate);
405
- stepAmount = 1;
406
- // 1-second step if the difference is less than or equal to 1 minute
407
- if (diffInSeconds <= 60) {
408
- incrementFn = date_fns_1.addSeconds;
409
- }
410
- // 1-minute step if the difference is less than or equal to 1 hour
411
- else if (diffInSeconds <= 3600) {
412
- incrementFn = date_fns_1.addMinutes;
413
- }
414
- // 1-hour step if the difference is less than or equal to 1 day
415
- else if (diffInSeconds <= 86400) {
416
- incrementFn = date_fns_1.addHours;
417
- }
418
- // 1-day step if the difference is less than or equal to 1 month
419
- else if (diffInMonths <= 1) {
420
- incrementFn = date_fns_1.addDays;
421
- }
422
- // 1-month step if the difference is less than or equal to 1 year
423
- else if (diffInYears <= 1) {
424
- incrementFn = date_fns_1.addMonths;
425
- }
426
- // 1-year step if the difference is equal to 1 year or more
427
- else {
428
- incrementFn = date_fns_1.addYears;
429
- }
430
- }
431
- // A higher-order function that adjusts date increments to account for
432
- // changes in daylight saving time (DST). When incrementing dates in time
433
- // zones that observe DST, this function ensures that the resulting date
434
- // remains consistent with the original date's time zone offset.
435
- const dstAgnosticFn = (fn) => {
436
- return (date, amount) => {
437
- const endDate = fn(date, amount);
438
- return (0, date_fns_1.addMinutes)(endDate, date.getTimezoneOffset() - endDate.getTimezoneOffset());
439
- };
440
- };
441
- return {
442
- addPeriod: dstAgnosticFn(safeIncrement ? incrementFn : date_fns_1.addSeconds),
443
- step: stepAmount
444
- };
445
- }
497
+ /**
498
+ * Prepares a resource for the response by resolving the virtual fields of its
499
+ * model and flattening the Prisma `_count` aggregation into the individual
500
+ * relation count fields (i.e. `_count.posts` becomes `postsCount`).
501
+ *
502
+ * @param {Object} resource The resource object as returned by the database
503
+ * client.
504
+ * @returns {Object} The same resource with the virtual fields resolved and, if a
505
+ * `_count` selection was present, with a count property per counted relation
506
+ * and the `_count` property removed.
507
+ */
446
508
  projectResource(resource) {
447
509
  const projectedResource = (0, utils_1.projectVirtualFields)(resource, this._client.name);
448
- if (!(0, common_1.isObject)(projectedResource['_count'])) {
510
+ if (!(0, common_1.isPlainObject)(projectedResource['_count'])) {
449
511
  return projectedResource;
450
512
  }
451
513
  // Create new relation count properties on the resource object
@@ -455,6 +517,25 @@ class ResourceService {
455
517
  delete projectedResource['_count'];
456
518
  return projectedResource;
457
519
  }
520
+ /**
521
+ * Prepares a write payload for the database by removing the virtual fields
522
+ * that have no column, filling in default values for the hidden required
523
+ * scalars of a create action, and recursively applying the same rules to the
524
+ * nested relation and file payloads. Unique key values, arrays of them and null
525
+ * values are left untouched, so the relation actions can still map them to the
526
+ * connect and disconnect operations.
527
+ *
528
+ * @param {'create'|'update'} action The write action the payload is sanitized
529
+ * for. Default values for the hidden required scalars are only applied on a
530
+ * create action.
531
+ * @param {Object} data The write payload to sanitize.
532
+ * @param {string} [resourceName] The name of the model the payload belongs to.
533
+ * Defaults to the model of this service and is set to the related model name
534
+ * when recursing into a nested relation or file payload.
535
+ * @returns {Object} A shallow copy of the payload without the virtual fields,
536
+ * with the missing hidden required scalars defaulted, and with the nested
537
+ * payloads sanitized against their own models.
538
+ */
458
539
  sanitizeData(action, data, resourceName) {
459
540
  const sanitizedData = { ...data };
460
541
  const resourceModel = (0, context_1.injectModel)(resourceName ?? this._client.name, false);
@@ -481,255 +562,19 @@ class ResourceService {
481
562
  const value = sanitizedData[key];
482
563
  const relationSchema = (0, common_1.extractSchemaProperties)(relationsModel, key);
483
564
  const fileSchema = (0, common_1.extractSchemaProperties)(filesModel, key);
484
- if ((0, common_1.isObject)(value) || ((0, common_1.isArray)(value) && (0, common_1.isObject)(value[0]))) {
565
+ // Only nested resource payloads are sanitized. Unique key values, arrays
566
+ // of them and null values are left untouched, so the relation actions can
567
+ // still map them to connect and disconnect operations.
568
+ if ((0, common_1.isPlainObject)(value) || ((0, common_1.isArray)(value) && (0, common_1.isPlainObject)(value[0]))) {
485
569
  const resourceName = (0, common_1.extractResourceName)(relationSchema ?? fileSchema);
486
570
  if (resourceName) {
487
571
  sanitizedData[key] = (0, common_1.isArray)(value)
488
- ? value.map((item) => this.sanitizeData(item, resourceName))
572
+ ? value.map((item) => this.sanitizeData(action, item, resourceName))
489
573
  : this.sanitizeData(action, value, resourceName);
490
574
  }
491
575
  }
492
576
  }
493
577
  return sanitizedData;
494
578
  }
495
- mapQueryFilter(filter, resourceName) {
496
- const queryFilter = {};
497
- const resourceModel = (0, context_1.injectModel)(resourceName ?? this._client.name, false);
498
- const readModel = resourceModel?.readModel;
499
- const relationsModel = resourceModel?.relationsModel;
500
- const filesModel = resourceModel?.filesModel;
501
- for (const key in filter) {
502
- const value = filter[key];
503
- const readSchema = (0, common_1.extractSchemaProperties)(readModel, key);
504
- const relationSchema = (0, common_1.extractSchemaProperties)(relationsModel, key);
505
- const fileSchema = (0, common_1.extractSchemaProperties)(filesModel, key);
506
- const isArrayType = readSchema?.type === 'array' ||
507
- relationSchema?.type === 'array' ||
508
- fileSchema?.type === 'array';
509
- const isArrayValue = (0, common_1.isArray)(value);
510
- // Recursively map nested objects and handle arrays of objects
511
- if ((0, common_1.isObject)(value) || (isArrayValue && (0, common_1.isObject)(value[0]))) {
512
- const resourceName = (0, common_1.extractResourceName)(relationSchema ?? fileSchema);
513
- if (resourceName) {
514
- queryFilter[key] = isArrayValue
515
- ? value.map((item) => this.mapQueryFilter(item, resourceName))
516
- : this.mapQueryFilter(value, resourceName);
517
- }
518
- }
519
- // Map ID values for both single and array types of relationships
520
- else if (relationSchema || fileSchema) {
521
- const queryId = { id: isArrayValue ? { in: value } : value };
522
- queryFilter[key] = isArrayType ? { some: queryId } : queryId;
523
- }
524
- // Map fields without relationships, supporting array types
525
- else if (readSchema) {
526
- if (isArrayType) {
527
- queryFilter[key] = isArrayValue ? { hasSome: value } : { has: value };
528
- }
529
- else if (isArrayValue) {
530
- // For date and numeric types, apply range filtering with inclusive
531
- // intervals
532
- if (value.length > 0 &&
533
- value.length <= 2 &&
534
- (['number', 'integer'].includes(readSchema.type) ||
535
- ['date', 'date-time'].includes(readSchema.format))) {
536
- queryFilter[key] = {
537
- gte: value[0],
538
- lte: value[1]
539
- };
540
- }
541
- // Map array values for inclusion checks
542
- else {
543
- queryFilter[key] = { in: value };
544
- }
545
- }
546
- }
547
- // If no query filter was defined, assign the original value
548
- if (queryFilter[key] === undefined) {
549
- queryFilter[key] = value;
550
- }
551
- }
552
- return queryFilter;
553
- }
554
- mapRelationInclusions(action, resourceName) {
555
- const inclusion = {};
556
- const resourceModel = (0, context_1.injectModel)(resourceName ?? this._client.name);
557
- const relationConfig = resourceModel.config.relations;
558
- const fileConfig = resourceModel.config.files;
559
- const relationModelProps = (0, common_1.extractSchemaProperties)(resourceModel.relationsModel);
560
- const fileModelProps = (0, common_1.extractSchemaProperties)(resourceModel.filesModel);
561
- // Add relation and file fields to the inclusion map if the include type is
562
- // satisfied or the requested action is not specified. Also, add the count
563
- // aggregation actions for relations if configured.
564
- for (const key of Object.keys({
565
- ...relationModelProps,
566
- ...fileModelProps
567
- })) {
568
- const relationField = relationConfig?.[key] || fileConfig?.[key];
569
- if (relationField?.output?.count) {
570
- inclusion._count = inclusion._count ?? { select: {} };
571
- inclusion._count.select[key] = true;
572
- }
573
- // Check if the relation should be included based on the output type
574
- if (this.shouldIncludeRelation(relationField?.output?.type, action)) {
575
- inclusion[key] = this.buildNestedInclusion(relationField, action);
576
- }
577
- }
578
- return inclusion;
579
- }
580
- buildNestedInclusion(relationField, action) {
581
- const nestedIncludeConfig = relationField?.output?.include;
582
- if (!nestedIncludeConfig || Object.keys(nestedIncludeConfig).length === 0) {
583
- return true;
584
- }
585
- const nestedInclusion = {};
586
- for (const [nestedKey, nestedOutput] of Object.entries(nestedIncludeConfig)) {
587
- // Check if the nested relation should be included
588
- if (this.shouldIncludeRelation(nestedOutput?.type, action)) {
589
- // Recursively build nested inclusions
590
- nestedInclusion[nestedKey] = this.buildNestedInclusion({ output: nestedOutput }, action);
591
- }
592
- }
593
- return Object.keys(nestedInclusion).length > 0
594
- ? { include: nestedInclusion }
595
- : true;
596
- }
597
- shouldIncludeRelation(outputType, action) {
598
- if (outputType === 'none') {
599
- return false;
600
- }
601
- if (outputType === 'single' && action === 'query') {
602
- return false;
603
- }
604
- return !(outputType === 'multiple' && action && action !== 'query');
605
- }
606
- mapRelationActions(action, data, currentData) {
607
- const relations = {};
608
- const resourceModel = (0, context_1.injectModel)(this._client.name);
609
- const readModel = resourceModel.readModel;
610
- const relationsModel = resourceModel.relationsModel;
611
- const relationsConfig = resourceModel.config.relations;
612
- for (const key in data) {
613
- let value = data[key];
614
- const relationSchema = (0, common_1.extractSchemaProperties)(relationsModel, key);
615
- if (!relationSchema) {
616
- // Set empty array or undefined value for null array type fields
617
- if (value === null) {
618
- if ((0, common_1.extractSchemaProperties)(readModel, key)?.type === 'array') {
619
- relations[key] = action === 'update' ? [] : undefined;
620
- }
621
- else if (action === 'create') {
622
- relations[key] = undefined;
623
- }
624
- }
625
- // Skip mapping for non-relation fields
626
- continue;
627
- }
628
- const config = relationsConfig?.[key];
629
- const uniqueKey = config?.input?.uniqueKey || 'id';
630
- const isArrayType = relationSchema.type === 'array';
631
- // Normalize array values to single values if a value type is not an array
632
- if (!isArrayType && (0, common_1.isArray)(value)) {
633
- value = value[0];
634
- }
635
- // Set null values to undefined value for create actions. On update action
636
- // they will be returned as disconnected relations.
637
- if (action === 'create' && value === null) {
638
- relations[key] = undefined;
639
- continue;
640
- }
641
- // Normalize plain unique key values or arrays to object values
642
- if (isArrayType) {
643
- if ((0, common_1.isArray)(value) && !(0, common_1.isObject)([0])) {
644
- value = value.map((v) => ({
645
- [uniqueKey]: v
646
- }));
647
- }
648
- }
649
- else {
650
- if (!(0, common_1.isObject)(value)) {
651
- value = { [uniqueKey]: value };
652
- }
653
- }
654
- // Map relation connections with an option to create a non-existing entity
655
- if (config?.createIfNotExists && value) {
656
- const createdBy = this.createdByConnect(config.model);
657
- if (isArrayType) {
658
- relations[key] = {
659
- connectOrCreate: value.map((v) => ({
660
- where: v[uniqueKey] ? { [uniqueKey]: v[uniqueKey] } : { id: 0 },
661
- create: { ...v, createdBy, id: undefined }
662
- }))
663
- };
664
- }
665
- else {
666
- relations[key] = {
667
- connectOrCreate: {
668
- where: value[uniqueKey]
669
- ? { [uniqueKey]: value[uniqueKey] }
670
- : { id: 0 },
671
- create: { ...value, createdBy, id: undefined }
672
- }
673
- };
674
- }
675
- }
676
- else if (value) {
677
- if (isArrayType && (0, common_1.isArray)(value)) {
678
- relations[key] = {
679
- connect: value.map((v) => ({ [uniqueKey]: v[uniqueKey] }))
680
- };
681
- }
682
- else {
683
- relations[key] = { connect: { [uniqueKey]: value[uniqueKey] } };
684
- }
685
- }
686
- // Map relation disconnects if unique keys are no longer present or the
687
- // new value is null. Delete relations if orphanRemoval is set to true.
688
- // Also, do not create a disconnect action if the currentValue is already
689
- // null.
690
- if (action === 'update') {
691
- const currentValue = currentData[key];
692
- const removalMethod = config?.orphanRemoval ? 'delete' : 'disconnect';
693
- if (currentValue && isArrayType) {
694
- const newValueKeys = value?.map((v) => v[uniqueKey]) ?? [];
695
- const currentValueKeys = currentValue
696
- .filter((v) => newValueKeys.indexOf(v[uniqueKey]) === -1)
697
- .map((v) => ({
698
- [uniqueKey]: v[uniqueKey]
699
- }));
700
- if (currentValueKeys.length > 0) {
701
- relations[key] = {
702
- [removalMethod]: currentValueKeys,
703
- ...(relations[key] ?? {})
704
- };
705
- }
706
- }
707
- else if (currentValue && value === null) {
708
- relations[key] = {
709
- [removalMethod]: { [uniqueKey]: currentValue[uniqueKey] },
710
- ...(relations[key] ?? {})
711
- };
712
- }
713
- else if (!currentValue) {
714
- relations[key] = undefined;
715
- }
716
- }
717
- }
718
- return relations;
719
- }
720
- createdByConnect(resourceName) {
721
- const resourceModel = (0, context_1.injectModel)(resourceName ?? this._client.name, false);
722
- if (resourceModel?.config.audit?.createdById === false) {
723
- return undefined;
724
- }
725
- const currentUser = (0, security_1.currentAuthUser)();
726
- return currentUser
727
- ? {
728
- connect: {
729
- id: currentUser.id
730
- }
731
- }
732
- : undefined;
733
- }
734
579
  }
735
580
  exports.ResourceService = ResourceService;