@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
@@ -0,0 +1,283 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.mapQueryFilter = mapQueryFilter;
4
+ const common_1 = require("@appweaver/common");
5
+ const context_1 = require("../../context");
6
+ /**
7
+ * Logical filter operators mapped to their database query connectives. Both
8
+ * `_not` and `_nor` negate the conjunction of their conditions.
9
+ */
10
+ const logicalOperators = {
11
+ _and: 'AND',
12
+ _or: 'OR',
13
+ _not: 'NOT',
14
+ _nor: 'NOT'
15
+ };
16
+ /** List relation filter operators mapped to their database quantifiers. */
17
+ const relationOperators = {
18
+ _some: 'some',
19
+ _every: 'every',
20
+ _none: 'none'
21
+ };
22
+ /** Reads a boolean operator value, accepting the `'false'` string as false. */
23
+ const isTruthy = (value) => !(value === false || value === 'false');
24
+ /** Wraps a single operator value into a list. */
25
+ const asArray = (value) => ((0, common_1.isArray)(value) ? value : [value]);
26
+ /**
27
+ * Maps an SQL LIKE pattern to the matching string condition, based on the
28
+ * placement of its `%` wildcards. A pattern without wildcards matches exactly.
29
+ */
30
+ const likePattern = (pattern) => {
31
+ const value = String(pattern);
32
+ const startsWildcard = value.startsWith('%');
33
+ const endsWildcard = value.endsWith('%') && value.length > 1;
34
+ const inner = value.slice(startsWildcard ? 1 : 0, endsWildcard ? -1 : undefined);
35
+ if (startsWildcard && endsWildcard) {
36
+ return { contains: inner };
37
+ }
38
+ if (endsWildcard) {
39
+ return { startsWith: inner };
40
+ }
41
+ if (startsWildcard) {
42
+ return { endsWith: inner };
43
+ }
44
+ return { equals: value };
45
+ };
46
+ /**
47
+ * Field comparison operators mapped to their database query conditions.
48
+ * Operators combined in the same object are merged into a single condition.
49
+ */
50
+ const fieldOperators = {
51
+ _eq: (value) => ({ equals: value }),
52
+ _ne: (value) => ({ not: value }),
53
+ _gt: (value) => ({ gt: value }),
54
+ _gte: (value) => ({ gte: value }),
55
+ _lt: (value) => ({ lt: value }),
56
+ _lte: (value) => ({ lte: value }),
57
+ _in: (value) => ({ in: asArray(value) }),
58
+ _nin: (value) => ({ notIn: asArray(value) }),
59
+ _between: (value) => ({ gte: value?.[0], lte: value?.[1] }),
60
+ _like: (value) => likePattern(value),
61
+ _ilike: (value) => ({ ...likePattern(value), mode: 'insensitive' }),
62
+ _starts: (value) => ({ startsWith: value }),
63
+ _ends: (value) => ({ endsWith: value }),
64
+ _contains: (value) => ({ contains: value }),
65
+ _exists: (value) => (isTruthy(value) ? { not: null } : { equals: null }),
66
+ _has: (value) => ({ has: value }),
67
+ _hasSome: (value) => ({ hasSome: asArray(value) }),
68
+ _hasEvery: (value) => ({ hasEvery: asArray(value) }),
69
+ _isEmpty: (value) => ({ isEmpty: isTruthy(value) })
70
+ };
71
+ /**
72
+ * Maps a request filter to a Prisma `where` clause based on the schema of the
73
+ * given resource model. The logical operators (`_and`, `_or`, `_not`, `_nor`)
74
+ * combine their nested conditions with the matching connective, the field
75
+ * operator objects (`_eq`, `_gt`, `_like`, `_exists`, ...) are translated to
76
+ * their database conditions, and the list relation quantifiers (`_some`,
77
+ * `_every`, `_none`) filter the related records. Plain values keep their
78
+ * shorthand meaning: relation and file fields are matched by id (wrapped in a
79
+ * `some` condition for the array relations), array fields use the `has` and
80
+ * `hasSome` operators, an array value on a numeric or date field becomes an
81
+ * inclusive `gte`/`lte` range, other array values become an `in` inclusion,
82
+ * nested objects are mapped recursively against the related model, and null
83
+ * values are passed through to match records without a value or a related
84
+ * record. Values that match no known field are passed through with only their
85
+ * operators translated, so the Prisma operators can still be used directly.
86
+ *
87
+ * @param {Object} filter The request filter object to map.
88
+ * @param {string} resourceName The name of the model the filter is matched
89
+ * against. It is set to the related model name when recursing into a nested
90
+ * relation or file filter.
91
+ * @returns {Object} The `where` clause with every recognized field mapped to its
92
+ * database condition and the unrecognized values passed through unchanged.
93
+ */
94
+ function mapQueryFilter(filter, resourceName) {
95
+ const queryFilter = {};
96
+ const resourceModel = (0, context_1.injectModel)(resourceName, false);
97
+ const readModel = resourceModel?.readModel;
98
+ const relationsModel = resourceModel?.relationsModel;
99
+ const filesModel = resourceModel?.filesModel;
100
+ for (const key in filter) {
101
+ const value = filter[key];
102
+ // Logical operators combine their conditions, given either as a filter
103
+ // object whose entries become separate conditions or as a list of
104
+ // filters, with the matching database connective
105
+ if (logicalOperators[key]) {
106
+ const operator = logicalOperators[key];
107
+ const conditions = mapFilterConditions(value, (item) => mapQueryFilter(item, resourceName));
108
+ queryFilter[operator] = [...(queryFilter[operator] ?? []), ...conditions];
109
+ continue;
110
+ }
111
+ // List relation quantifiers filter the related records of the model the
112
+ // current filter level is matched against
113
+ if (relationOperators[key] && (0, common_1.isPlainObject)(value)) {
114
+ queryFilter[relationOperators[key]] = mapQueryFilter(value, resourceName);
115
+ continue;
116
+ }
117
+ const readSchema = (0, common_1.extractSchemaProperties)(readModel, key);
118
+ const relationSchema = (0, common_1.extractSchemaProperties)(relationsModel, key);
119
+ const fileSchema = (0, common_1.extractSchemaProperties)(filesModel, key);
120
+ const isArrayType = readSchema?.type === 'array' ||
121
+ relationSchema?.type === 'array' ||
122
+ fileSchema?.type === 'array';
123
+ const isArrayValue = (0, common_1.isArray)(value);
124
+ // Null values are passed through untouched, matching records without a
125
+ // value or, for relations, without a related record.
126
+ if (value === null) {
127
+ queryFilter[key] = null;
128
+ continue;
129
+ }
130
+ // Recursively map nested objects and handle arrays of objects. Arrays of
131
+ // plain values are mapped below as inclusion, range or relation filters.
132
+ if ((0, common_1.isPlainObject)(value) || (isArrayValue && (0, common_1.isPlainObject)(value[0]))) {
133
+ const relatedName = (0, common_1.extractResourceName)(relationSchema ?? fileSchema);
134
+ if (relatedName) {
135
+ queryFilter[key] = isArrayValue
136
+ ? value.map((item) => mapQueryFilter(item, relatedName))
137
+ : mapRelationFilter(value, relatedName, isArrayType);
138
+ }
139
+ else {
140
+ // Objects that match no relation have their field operators
141
+ // translated and everything else passed through unchanged
142
+ queryFilter[key] = mapFieldFilter(value);
143
+ }
144
+ }
145
+ // Map ID values for both single and array types of relationships
146
+ else if (relationSchema || fileSchema) {
147
+ const queryId = { id: isArrayValue ? { in: value } : value };
148
+ queryFilter[key] = isArrayType ? { some: queryId } : queryId;
149
+ }
150
+ // Map fields without relationships, supporting array types
151
+ else if (readSchema) {
152
+ if (isArrayType) {
153
+ queryFilter[key] = isArrayValue ? { hasSome: value } : { has: value };
154
+ }
155
+ else if (isArrayValue) {
156
+ // For date and numeric types, apply range filtering with inclusive
157
+ // intervals
158
+ if (value.length > 0 &&
159
+ value.length <= 2 &&
160
+ (['number', 'integer'].includes(readSchema.type) ||
161
+ ['date', 'date-time'].includes(readSchema.format))) {
162
+ queryFilter[key] = {
163
+ gte: value[0],
164
+ lte: value[1]
165
+ };
166
+ }
167
+ // Map array values for inclusion checks
168
+ else {
169
+ queryFilter[key] = { in: value };
170
+ }
171
+ }
172
+ }
173
+ // If no query filter was defined, assign the original value
174
+ if (queryFilter[key] === undefined) {
175
+ queryFilter[key] = value;
176
+ }
177
+ }
178
+ return queryFilter;
179
+ }
180
+ /**
181
+ * Normalizes the value of a logical filter operator to the list of its
182
+ * mapped conditions. A list of filters maps every item separately, while a
183
+ * single filter object maps every entry into its own condition, so the
184
+ * logical connective is applied per field.
185
+ *
186
+ * @param {Object|Object[]} value The logical operator value to normalize.
187
+ * @param {Function} map The function mapping a single filter condition.
188
+ * @returns {Object[]} The list of mapped filter conditions.
189
+ */
190
+ function mapFilterConditions(value, map) {
191
+ if ((0, common_1.isArray)(value)) {
192
+ return value.map(map);
193
+ }
194
+ if ((0, common_1.isPlainObject)(value)) {
195
+ return Object.entries(value).map(([key, item]) => map({ [key]: item }));
196
+ }
197
+ return [value];
198
+ }
199
+ /**
200
+ * Maps the filter of a relation or file field to its database condition
201
+ * against the related model. The `_exists` operator is resolved based on the
202
+ * relation cardinality: a list relation wraps the remaining conditions in a
203
+ * `some` (or `none`) quantifier, while a single relation maps to an `is` or
204
+ * `isNot` null check, wrapping the remaining conditions in an `is` filter.
205
+ *
206
+ * @param {Object} filter The relation filter object to map.
207
+ * @param {string} resourceName The name of the related model the filter is
208
+ * matched against.
209
+ * @param {boolean} isArrayType Whether the relation is a list (to-many)
210
+ * relation.
211
+ * @returns {Object} The database condition of the relation field.
212
+ */
213
+ function mapRelationFilter(filter, resourceName, isArrayType) {
214
+ const { _exists, ...conditions } = filter;
215
+ const mapped = mapQueryFilter(conditions, resourceName);
216
+ if (_exists === undefined) {
217
+ return mapped;
218
+ }
219
+ const exists = isTruthy(_exists);
220
+ const hasConditions = Object.keys(conditions).length > 0;
221
+ if (isArrayType) {
222
+ return exists ? { some: mapped } : { none: mapped };
223
+ }
224
+ if (!exists) {
225
+ return { is: null };
226
+ }
227
+ return hasConditions ? { is: mapped } : { isNot: null };
228
+ }
229
+ /**
230
+ * Maps a field condition object to its database condition by translating the
231
+ * filter operators anywhere in its structure. The field operators of one
232
+ * object are merged into a single condition, a `_not` holding an operator
233
+ * object or a plain value negates it, the logical operators combine their
234
+ * nested conditions, and everything else is passed through unchanged, so
235
+ * native database conditions keep working.
236
+ *
237
+ * @param {*} value The field condition value to map.
238
+ * @returns {*} The database condition with every filter operator translated.
239
+ */
240
+ function mapFieldFilter(value) {
241
+ if ((0, common_1.isArray)(value)) {
242
+ return value.map((item) => mapFieldFilter(item));
243
+ }
244
+ if (!(0, common_1.isPlainObject)(value)) {
245
+ return value;
246
+ }
247
+ const condition = {};
248
+ for (const [key, item] of Object.entries(value)) {
249
+ // A `_not` negates its operator object or plain value directly, while an
250
+ // object of field conditions is combined as a logical NOT below
251
+ if (key === '_not' && (!(0, common_1.isPlainObject)(item) || hasFieldOperators(item))) {
252
+ condition['not'] = mapFieldFilter(item);
253
+ continue;
254
+ }
255
+ if (logicalOperators[key]) {
256
+ const operator = logicalOperators[key];
257
+ const conditions = mapFilterConditions(item, (entry) => mapFieldFilter(entry));
258
+ condition[operator] = [...(condition[operator] ?? []), ...conditions];
259
+ continue;
260
+ }
261
+ if (relationOperators[key]) {
262
+ condition[relationOperators[key]] = mapFieldFilter(item);
263
+ continue;
264
+ }
265
+ if (fieldOperators[key]) {
266
+ Object.assign(condition, fieldOperators[key](item));
267
+ continue;
268
+ }
269
+ condition[key] = mapFieldFilter(item);
270
+ }
271
+ return condition;
272
+ }
273
+ /**
274
+ * Checks whether the given value is an object holding at least one field
275
+ * comparison operator (i.e. `_eq`, `_gt`, `_like`, ...).
276
+ *
277
+ * @param {*} value The value to check.
278
+ * @returns {boolean} True if the value contains a field operator.
279
+ */
280
+ function hasFieldOperators(value) {
281
+ return ((0, common_1.isPlainObject)(value) &&
282
+ Object.keys(value).some((key) => fieldOperators[key] || key === '_not'));
283
+ }
@@ -0,0 +1,4 @@
1
+ export * from './aggregate-util';
2
+ export * from './filter-util';
3
+ export * from './relation-util';
4
+ export * from './sort-util';
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./aggregate-util"), exports);
18
+ __exportStar(require("./filter-util"), exports);
19
+ __exportStar(require("./relation-util"), exports);
20
+ __exportStar(require("./sort-util"), exports);
@@ -0,0 +1,84 @@
1
+ import { ActionType } from '@appweaver/common';
2
+ /** The nested write actions a single relation field can be mapped to. */
3
+ export type RelationActions = Record<string, Partial<{
4
+ connect: any;
5
+ create: any;
6
+ update: any;
7
+ connectOrCreate: any;
8
+ disconnect: any;
9
+ delete: any;
10
+ }>>;
11
+ /**
12
+ * Builds the Prisma `include` clause for the relation and file fields of a resource model. A field is included when
13
+ * its configured output type allows it for the given action, or always when no action is specified, and the relations
14
+ * configured with an output count contribute to the `_count` selection instead.
15
+ *
16
+ * @param {string} resourceName - The name of the model whose relations are included.
17
+ * @param {ActionType} [action] - The action the inclusions are built for, matched against the configured output type
18
+ * of every field. When omitted, all fields whose output type is not `none` are included.
19
+ * @return {Object} The `include` clause mapping each included field to `true` or to its own nested `include` clause,
20
+ * extended with a `_count` selection for the relations configured with an output count.
21
+ */
22
+ export declare function mapRelationInclusions(resourceName: string, action?: ActionType): Record<string, any>;
23
+ /**
24
+ * Maps the relation and file fields of a write payload to the Prisma nested write actions. Bare key values and arrays
25
+ * of them are normalized to objects first, then every item is classified: items with an id and additional data become
26
+ * inline updates when the parent action is an update and inline updates are enabled for the relation, items with only
27
+ * an id are connected, and items without an id are created inline or matched through a connect-or-create when
28
+ * `input.uniqueKey` is configured. On an update action, the items of the current record that are absent from the new
29
+ * value, as well as the relations set to null, are disconnected, or deleted when `orphanRemoval` is configured.
30
+ * Relations that were not loaded on the current record are left untouched.
31
+ *
32
+ * @param {string} resourceName - The name of the model the write payload belongs to.
33
+ * @param {'create'|'update'} action - The write action the relation actions are mapped for. Inline updates and the
34
+ * removal of the relations absent from the new value are only applied on an update action.
35
+ * @param {Object} data - The sanitized write payload whose relation and file fields are mapped. The non-relation
36
+ * fields are skipped, except for the null values of the array scalar fields, which are mapped to an empty array on an
37
+ * update action and to undefined on a create action.
38
+ * @param {Object} [currentData] - The currently stored record with its relations loaded, required on an update action
39
+ * to determine which relations to disconnect or delete.
40
+ * @return {RelationActions} The nested write clause per relation field, mapping each one to its `connect`, `create`,
41
+ * `update`, `connectOrCreate`, `disconnect` and `delete` actions, or to undefined for the relations no action can be
42
+ * applied to.
43
+ * @throws {HttpError} 400 if an inline create payload is missing required fields, or if the relation accepts no new
44
+ * records and an id was not provided.
45
+ */
46
+ export declare function mapRelationActions(resourceName: string, action: 'create' | 'update', data: any, currentData?: any): RelationActions;
47
+ /**
48
+ * Restricts an inline relation payload to the fields the related model accepts for the given action. The request
49
+ * schema accepts the create and the update fields together, since the properties it does not declare are stripped
50
+ * before the request reaches the service, so the configured field restrictions of the related model are applied here
51
+ * instead.
52
+ *
53
+ * @param {string} [resourceName] - The name of the related model whose field restrictions are applied. When omitted,
54
+ * the payload is returned unchanged.
55
+ * @param {'create'|'update'} action - The write action the payload is restricted for, selecting either the relation
56
+ * create or the relation update model of the related resource.
57
+ * @param {Object} data - The inline relation payload to restrict.
58
+ * @return {Object} The payload reduced to the fields the related model accepts for the action, excluding the `id`
59
+ * field, or the payload unchanged if the related model declares no fields for it.
60
+ */
61
+ export declare function relationWriteData(resourceName: string | undefined, action: 'create' | 'update', data: any): any;
62
+ /**
63
+ * Lists the fields the related model requires on creation that the given inline relation payload does not provide.
64
+ *
65
+ * @param {string} [resourceName] - The name of the related model whose required fields are checked. When omitted, no
66
+ * fields are reported as missing.
67
+ * @param {Object} data - The inline relation payload to check.
68
+ * @return {string[]} The names of the fields the related model requires on creation that the payload leaves
69
+ * undefined, or an empty list if the related model declares no create schema.
70
+ */
71
+ export declare function missingRelationFields(resourceName: string | undefined, data: any): string[];
72
+ /**
73
+ * Builds the connect action for the `createdBy` audit relation of a resource, pointing at the currently authenticated
74
+ * user. Returns undefined when the model does not audit the `createdById` field or when no user is authenticated.
75
+ *
76
+ * @param {string} resourceName - The name of the model the audit relation is built for.
77
+ * @return {{connect: {id: number}}|undefined} The connect action pointing at the id of the currently authenticated
78
+ * user, or undefined if the model does not audit the `createdById` field or no user is authenticated.
79
+ */
80
+ export declare function createdByConnect(resourceName: string): {
81
+ connect: {
82
+ id: number;
83
+ };
84
+ } | undefined;