@rapidd/core 2.1.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.
- package/.dockerignore +71 -0
- package/.env.example +70 -0
- package/.gitignore +11 -0
- package/LICENSE +15 -0
- package/README.md +231 -0
- package/bin/cli.js +145 -0
- package/config/app.json +166 -0
- package/config/rate-limit.json +12 -0
- package/dist/main.js +26 -0
- package/dockerfile +57 -0
- package/locales/ar_SA.json +179 -0
- package/locales/de_DE.json +179 -0
- package/locales/en_US.json +180 -0
- package/locales/es_ES.json +179 -0
- package/locales/fr_FR.json +179 -0
- package/locales/it_IT.json +179 -0
- package/locales/ja_JP.json +179 -0
- package/locales/pt_BR.json +179 -0
- package/locales/ru_RU.json +179 -0
- package/locales/tr_TR.json +179 -0
- package/main.ts +25 -0
- package/package.json +126 -0
- package/prisma/schema.prisma +9 -0
- package/prisma.config.ts +12 -0
- package/public/static/favicon.ico +0 -0
- package/public/static/image/logo.png +0 -0
- package/routes/api/v1/index.ts +113 -0
- package/src/app.ts +197 -0
- package/src/auth/Auth.ts +446 -0
- package/src/auth/stores/ISessionStore.ts +19 -0
- package/src/auth/stores/MemoryStore.ts +70 -0
- package/src/auth/stores/RedisStore.ts +92 -0
- package/src/auth/stores/index.ts +149 -0
- package/src/config/acl.ts +9 -0
- package/src/config/rls.ts +38 -0
- package/src/core/dmmf.ts +226 -0
- package/src/core/env.ts +183 -0
- package/src/core/errors.ts +87 -0
- package/src/core/i18n.ts +144 -0
- package/src/core/middleware.ts +123 -0
- package/src/core/prisma.ts +236 -0
- package/src/index.ts +112 -0
- package/src/middleware/model.ts +61 -0
- package/src/orm/Model.ts +881 -0
- package/src/orm/QueryBuilder.ts +2078 -0
- package/src/plugins/auth.ts +162 -0
- package/src/plugins/language.ts +79 -0
- package/src/plugins/rateLimit.ts +210 -0
- package/src/plugins/response.ts +80 -0
- package/src/plugins/rls.ts +51 -0
- package/src/plugins/security.ts +23 -0
- package/src/plugins/upload.ts +299 -0
- package/src/types.ts +308 -0
- package/src/utils/ApiClient.ts +526 -0
- package/src/utils/Mailer.ts +348 -0
- package/src/utils/index.ts +25 -0
- package/templates/email/example.ejs +17 -0
- package/templates/layouts/email.ejs +35 -0
- package/tsconfig.json +33 -0
|
@@ -0,0 +1,2078 @@
|
|
|
1
|
+
import { prisma, prismaTransaction, getAcl } from '../core/prisma';
|
|
2
|
+
import { ErrorResponse } from '../core/errors';
|
|
3
|
+
import * as dmmf from '../core/dmmf';
|
|
4
|
+
import type {
|
|
5
|
+
RelationConfig,
|
|
6
|
+
DMMFField,
|
|
7
|
+
DMMFModel,
|
|
8
|
+
PrismaWhereClause,
|
|
9
|
+
PrismaIncludeClause,
|
|
10
|
+
PrismaOrderBy,
|
|
11
|
+
QueryErrorResponse,
|
|
12
|
+
AclConfig,
|
|
13
|
+
RapiddUser,
|
|
14
|
+
PrismaErrorInfo,
|
|
15
|
+
} from '../types';
|
|
16
|
+
|
|
17
|
+
const API_RESULT_LIMIT: number = parseInt(process.env.API_RESULT_LIMIT as string, 10) || 500;
|
|
18
|
+
const MAX_NESTING_DEPTH: number = 10;
|
|
19
|
+
|
|
20
|
+
// Pre-compiled regex patterns for better performance
|
|
21
|
+
const FILTER_PATTERNS = {
|
|
22
|
+
// Split on comma, but not inside brackets
|
|
23
|
+
FILTER_SPLIT: /,(?![^\[]*\])/,
|
|
24
|
+
// ISO date format: 2024-01-01 or 2024-01-01T00:00:00
|
|
25
|
+
ISO_DATE: /^\d{4}-\d{2}-\d{2}(T.*)?$/,
|
|
26
|
+
// Pure number (integer or decimal, optionally negative)
|
|
27
|
+
PURE_NUMBER: /^-?\d+(\.\d+)?$/,
|
|
28
|
+
// Numeric operators
|
|
29
|
+
NUMERIC_OPS: ['lt:', 'lte:', 'gt:', 'gte:', 'eq:', 'ne:', 'between:'] as const,
|
|
30
|
+
// Date operators
|
|
31
|
+
DATE_OPS: ['before:', 'after:', 'from:', 'to:', 'on:', 'between:'] as const,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Prisma error code mappings
|
|
36
|
+
* Maps Prisma error codes to HTTP status codes and user-friendly messages
|
|
37
|
+
*/
|
|
38
|
+
const PRISMA_ERROR_MAP: Record<string, PrismaErrorInfo> = {
|
|
39
|
+
// Connection errors
|
|
40
|
+
P1001: { status: 500, message: 'Connection to the database could not be established' },
|
|
41
|
+
|
|
42
|
+
// Query errors (4xx - client errors)
|
|
43
|
+
P2000: { status: 400, message: 'The provided value for the column is too long' },
|
|
44
|
+
P2001: { status: 404, message: 'The record searched for in the where condition does not exist' },
|
|
45
|
+
P2002: { status: 409, message: null }, // Dynamic message for duplicates
|
|
46
|
+
P2003: { status: 400, message: 'Foreign key constraint failed' },
|
|
47
|
+
P2004: { status: 400, message: 'A constraint failed on the database' },
|
|
48
|
+
P2005: { status: 400, message: 'The value stored in the database is invalid for the field type' },
|
|
49
|
+
P2006: { status: 400, message: 'The provided value is not valid' },
|
|
50
|
+
P2007: { status: 400, message: 'Data validation error' },
|
|
51
|
+
P2008: { status: 400, message: 'Failed to parse the query' },
|
|
52
|
+
P2009: { status: 400, message: 'Failed to validate the query' },
|
|
53
|
+
P2010: { status: 500, message: 'Raw query failed' },
|
|
54
|
+
P2011: { status: 400, message: 'Null constraint violation' },
|
|
55
|
+
P2012: { status: 400, message: 'Missing a required value' },
|
|
56
|
+
P2013: { status: 400, message: 'Missing the required argument' },
|
|
57
|
+
P2014: { status: 400, message: 'The change would violate the required relation' },
|
|
58
|
+
P2015: { status: 404, message: 'A related record could not be found' },
|
|
59
|
+
P2016: { status: 400, message: 'Query interpretation error' },
|
|
60
|
+
P2017: { status: 400, message: 'The records for relation are not connected' },
|
|
61
|
+
P2018: { status: 404, message: 'The required connected records were not found' },
|
|
62
|
+
P2019: { status: 400, message: 'Input error' },
|
|
63
|
+
P2020: { status: 400, message: 'Value out of range for the type' },
|
|
64
|
+
P2021: { status: 404, message: 'The table does not exist in the current database' },
|
|
65
|
+
P2022: { status: 404, message: 'The column does not exist in the current database' },
|
|
66
|
+
P2023: { status: 400, message: 'Inconsistent column data' },
|
|
67
|
+
P2024: { status: 408, message: 'Timed out fetching a new connection from the connection pool' },
|
|
68
|
+
P2025: { status: 404, message: 'Operation failed: required records not found' },
|
|
69
|
+
P2026: { status: 400, message: 'Database provider does not support this feature' },
|
|
70
|
+
P2027: { status: 500, message: 'Multiple errors occurred during query execution' },
|
|
71
|
+
P2028: { status: 500, message: 'Transaction API error' },
|
|
72
|
+
P2030: { status: 404, message: 'Cannot find a fulltext index for the search' },
|
|
73
|
+
P2033: { status: 400, message: 'A number in the query exceeds 64 bit signed integer' },
|
|
74
|
+
P2034: { status: 409, message: 'Transaction failed due to write conflict or deadlock' },
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* QueryBuilder - Builds Prisma queries with relation handling, filtering, and ACL support
|
|
79
|
+
*
|
|
80
|
+
* A comprehensive query builder that translates simplified API requests into valid Prisma queries.
|
|
81
|
+
* Handles nested relations, field validation, filtering with operators, and access control.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* const qb = new QueryBuilder('users');
|
|
85
|
+
* const filter = qb.filter('name=%John%,age=gt:18');
|
|
86
|
+
* const include = qb.include('posts.comments', user);
|
|
87
|
+
*/
|
|
88
|
+
class QueryBuilder {
|
|
89
|
+
name: string;
|
|
90
|
+
_relationshipsCache: RelationConfig[] | null;
|
|
91
|
+
_relatedFieldsCache: Record<string, Record<string, DMMFField>>;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Initialize QueryBuilder with model name and configuration
|
|
95
|
+
* @param name - The Prisma model name (e.g., 'users', 'company_profiles')
|
|
96
|
+
*/
|
|
97
|
+
constructor(name: string) {
|
|
98
|
+
this.name = name;
|
|
99
|
+
this._relationshipsCache = null;
|
|
100
|
+
this._relatedFieldsCache = {};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get all fields for this model from DMMF (including relation fields)
|
|
105
|
+
*/
|
|
106
|
+
get fields(): Record<string, DMMFField> {
|
|
107
|
+
return dmmf.getFields(this.name);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get only scalar fields (non-relation) for this model from DMMF
|
|
112
|
+
*/
|
|
113
|
+
get scalarFields(): Record<string, DMMFField> {
|
|
114
|
+
return dmmf.getScalarFields(this.name);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get relationships configuration for this model from DMMF
|
|
119
|
+
* Builds relationships dynamically from Prisma schema
|
|
120
|
+
*/
|
|
121
|
+
get relatedObjects(): RelationConfig[] {
|
|
122
|
+
if (this._relationshipsCache) {
|
|
123
|
+
return this._relationshipsCache;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
this._relationshipsCache = dmmf.buildRelationships(this.name);
|
|
127
|
+
return this._relationshipsCache;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get DMMF model object by name
|
|
132
|
+
*/
|
|
133
|
+
getDmmfModel(name: string = this.name): DMMFModel | undefined {
|
|
134
|
+
return dmmf.getModel(name);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get primary key field(s) for a given model
|
|
139
|
+
*/
|
|
140
|
+
getPrimaryKey(modelName: string = this.name): string | string[] {
|
|
141
|
+
return dmmf.getPrimaryKey(modelName);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Get fields for a related model (cached for performance)
|
|
146
|
+
*/
|
|
147
|
+
#getRelatedModelFields(modelName: string): Record<string, DMMFField> {
|
|
148
|
+
if (!this._relatedFieldsCache[modelName]) {
|
|
149
|
+
this._relatedFieldsCache[modelName] = dmmf.getFields(modelName);
|
|
150
|
+
}
|
|
151
|
+
return this._relatedFieldsCache[modelName];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Check if a field exists on a model
|
|
156
|
+
*/
|
|
157
|
+
#fieldExistsOnModel(modelName: string, fieldName: string): boolean {
|
|
158
|
+
const fields = this.#getRelatedModelFields(modelName);
|
|
159
|
+
return fields[fieldName] != null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Ensure a relation object has its nested relations populated.
|
|
164
|
+
* If relatedObject.relation is undefined, dynamically builds it from DMMF.
|
|
165
|
+
* This enables deep relationship processing beyond 2 levels.
|
|
166
|
+
*/
|
|
167
|
+
#ensureRelations(relatedObject: RelationConfig): RelationConfig {
|
|
168
|
+
if (!relatedObject.relation && relatedObject.object) {
|
|
169
|
+
const targetRelations = dmmf.getRelations(relatedObject.object);
|
|
170
|
+
if (targetRelations.length > 0) {
|
|
171
|
+
relatedObject.relation = targetRelations.map((nested: DMMFField) => ({
|
|
172
|
+
name: nested.name,
|
|
173
|
+
object: nested.type,
|
|
174
|
+
isList: nested.isList,
|
|
175
|
+
field: nested.relationFromFields?.[0],
|
|
176
|
+
foreignKey: nested.relationToFields?.[0] || 'id',
|
|
177
|
+
...(nested.relationFromFields && nested.relationFromFields.length > 1 ? {
|
|
178
|
+
fields: nested.relationFromFields,
|
|
179
|
+
foreignKeys: nested.relationToFields,
|
|
180
|
+
} : {}),
|
|
181
|
+
}));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return relatedObject;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Build select object for specified fields
|
|
189
|
+
*/
|
|
190
|
+
select(fields: string[] | null = null): Record<string, boolean> {
|
|
191
|
+
if (fields == null) {
|
|
192
|
+
const result: Record<string, boolean> = {};
|
|
193
|
+
for (const key in this.fields) {
|
|
194
|
+
result[key] = true;
|
|
195
|
+
}
|
|
196
|
+
return result;
|
|
197
|
+
} else {
|
|
198
|
+
return fields.reduce((acc: Record<string, boolean>, curr: string) => {
|
|
199
|
+
acc[curr] = true;
|
|
200
|
+
return acc;
|
|
201
|
+
}, {});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Parse filter string into Prisma where conditions
|
|
207
|
+
* Supports: numeric/date/string operators, not:, #NULL, not:#NULL
|
|
208
|
+
*/
|
|
209
|
+
filter(q: string): Record<string, unknown> {
|
|
210
|
+
if (typeof q !== 'string' || q.trim() === '') {
|
|
211
|
+
return {};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const result: Record<string, unknown> = {};
|
|
215
|
+
const filterParts = q.split(FILTER_PATTERNS.FILTER_SPLIT);
|
|
216
|
+
|
|
217
|
+
for (const part of filterParts) {
|
|
218
|
+
// Split only on first '=' to handle values containing '='
|
|
219
|
+
const eqIndex = part.indexOf('=');
|
|
220
|
+
if (eqIndex === -1) continue; // Skip invalid filter parts without '='
|
|
221
|
+
const key = part.substring(0, eqIndex);
|
|
222
|
+
const value = part.substring(eqIndex + 1);
|
|
223
|
+
const relationPath = key.split('.').map((e: string) => e.trim());
|
|
224
|
+
const fieldName = relationPath.pop()!;
|
|
225
|
+
const trimmedValue = value?.trim() ?? null;
|
|
226
|
+
|
|
227
|
+
// Validate field exists on model (for non-relation filters)
|
|
228
|
+
if (relationPath.length === 0 && !this.fields[fieldName]) {
|
|
229
|
+
throw new ErrorResponse(400, "invalid_filter_field", { field: fieldName });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Navigate to the correct filter context for nested relations
|
|
233
|
+
const { filter: filterContext, modelName } = this.#navigateToFilterContext(result, relationPath);
|
|
234
|
+
|
|
235
|
+
// Apply the filter value (with model context for null/relation handling)
|
|
236
|
+
this.#applyFilterValue(filterContext, fieldName, trimmedValue, modelName);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return result;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Navigate through relation path and return the filter context object and current model name
|
|
244
|
+
*/
|
|
245
|
+
#navigateToFilterContext(rootFilter: Record<string, any>, relationPath: string[]): { filter: Record<string, any>; modelName: string } {
|
|
246
|
+
let filter: Record<string, any> = rootFilter;
|
|
247
|
+
let currentRelations: RelationConfig[] | RelationConfig = this.relatedObjects;
|
|
248
|
+
let currentModelName = this.name;
|
|
249
|
+
|
|
250
|
+
for (const relationName of relationPath) {
|
|
251
|
+
// Find the relation in current context
|
|
252
|
+
const rel: RelationConfig | undefined = Array.isArray(currentRelations)
|
|
253
|
+
? currentRelations.find((r: RelationConfig) => r.name === relationName)
|
|
254
|
+
: (currentRelations as RelationConfig)?.relation?.find((r: RelationConfig) => r.name === relationName);
|
|
255
|
+
|
|
256
|
+
if (!rel) {
|
|
257
|
+
throw new ErrorResponse(400, "relation_not_exist", {
|
|
258
|
+
relation: relationName,
|
|
259
|
+
modelName: this.name,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Create or navigate to the relation filter
|
|
264
|
+
if (!filter[rel.name]) {
|
|
265
|
+
const parentModelName = Array.isArray(currentRelations) ? this.name : (currentRelations as RelationConfig).object;
|
|
266
|
+
const isListRel = rel.isList || dmmf.isListRelation(parentModelName, rel.name);
|
|
267
|
+
|
|
268
|
+
if (isListRel && rel.field) {
|
|
269
|
+
filter[rel.name] = { some: {} };
|
|
270
|
+
filter = filter[rel.name].some;
|
|
271
|
+
} else {
|
|
272
|
+
filter[rel.name] = {};
|
|
273
|
+
filter = filter[rel.name];
|
|
274
|
+
}
|
|
275
|
+
} else {
|
|
276
|
+
filter = filter[rel.name].some || filter[rel.name];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
currentModelName = rel.object;
|
|
280
|
+
currentRelations = rel;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return { filter, modelName: currentModelName };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Apply a filter value to a field in the filter context
|
|
288
|
+
*/
|
|
289
|
+
#applyFilterValue(filter: Record<string, any>, fieldName: string, value: string | null, modelName: string = this.name): void {
|
|
290
|
+
// Resolve field metadata from the correct model
|
|
291
|
+
const fields = modelName === this.name ? this.fields : this.#getRelatedModelFields(modelName);
|
|
292
|
+
const field = fields[fieldName];
|
|
293
|
+
const isRelation = field?.kind === 'object';
|
|
294
|
+
|
|
295
|
+
// Handle explicit null filter tokens
|
|
296
|
+
if (value === '#NULL') {
|
|
297
|
+
if (isRelation) {
|
|
298
|
+
// Relations use { is: null } in Prisma
|
|
299
|
+
filter[fieldName] = { is: null };
|
|
300
|
+
} else if (field?.isRequired) {
|
|
301
|
+
// Non-nullable scalar fields can never be null — reject the filter
|
|
302
|
+
throw new ErrorResponse(400, "field_not_nullable", { field: fieldName });
|
|
303
|
+
} else {
|
|
304
|
+
filter[fieldName] = { equals: null };
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (value === 'not:#NULL') {
|
|
309
|
+
if (isRelation) {
|
|
310
|
+
// Relations use { isNot: null } in Prisma
|
|
311
|
+
filter[fieldName] = { isNot: null };
|
|
312
|
+
} else if (field?.isRequired) {
|
|
313
|
+
// Non-nullable scalar fields are always not-null — skip (always true)
|
|
314
|
+
return;
|
|
315
|
+
} else {
|
|
316
|
+
filter[fieldName] = { not: { equals: null } };
|
|
317
|
+
}
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Handle not: prefix (negation)
|
|
322
|
+
if (value?.startsWith('not:')) {
|
|
323
|
+
this.#applyNegatedFilter(filter, fieldName, value.substring(4), modelName);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Skip empty/null values — don't filter on empty strings
|
|
328
|
+
if (!value) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Try to apply typed filter (date, number, array, string)
|
|
333
|
+
this.#applyTypedFilter(filter, fieldName, value);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Apply a negated filter (not:value)
|
|
338
|
+
*/
|
|
339
|
+
#applyNegatedFilter(filter: Record<string, any>, fieldName: string, value: string, modelName: string = this.name): void {
|
|
340
|
+
// not:#NULL
|
|
341
|
+
if (value === '#NULL') {
|
|
342
|
+
const fields = modelName === this.name ? this.fields : this.#getRelatedModelFields(modelName);
|
|
343
|
+
const field = fields[fieldName];
|
|
344
|
+
|
|
345
|
+
if (field?.kind === 'object') {
|
|
346
|
+
// Relations use { isNot: null } in Prisma
|
|
347
|
+
filter[fieldName] = { isNot: null };
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
// Non-nullable scalar fields are always not-null — skip (always true)
|
|
351
|
+
if (field?.isRequired) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
filter[fieldName] = { not: { equals: null } };
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// not:[array]
|
|
359
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
360
|
+
const arr = this.#parseArrayValue(value);
|
|
361
|
+
if (arr.some((v: unknown) => typeof v === 'string' && v.includes('%'))) {
|
|
362
|
+
filter.NOT = arr.map((v: unknown) => ({ [fieldName]: this.#filterString(v as string) }));
|
|
363
|
+
} else {
|
|
364
|
+
filter[fieldName] = { notIn: arr };
|
|
365
|
+
}
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// not:between:
|
|
370
|
+
if (value.startsWith('between:')) {
|
|
371
|
+
this.#applyNotBetween(filter, fieldName, value.substring(8));
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Try date filter
|
|
376
|
+
const dateFilter = this.#filterDateTime(value);
|
|
377
|
+
if (dateFilter) {
|
|
378
|
+
filter[fieldName] = { not: dateFilter };
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Try number filter
|
|
383
|
+
if (this.#looksLikeNumber(value)) {
|
|
384
|
+
const numFilter = this.#filterNumber(value);
|
|
385
|
+
filter[fieldName] = numFilter ? { not: numFilter } : { not: Number(value) };
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Default to string filter
|
|
390
|
+
filter[fieldName] = { not: this.#filterString(value) };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Apply not:between: filter
|
|
395
|
+
*/
|
|
396
|
+
#applyNotBetween(filter: Record<string, any>, fieldName: string, rangeValue: string): void {
|
|
397
|
+
const [start, end] = rangeValue.split(';').map((v: string) => v.trim());
|
|
398
|
+
|
|
399
|
+
if (!start || !end) {
|
|
400
|
+
throw new ErrorResponse(400, "between_requires_two_values");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const isNumeric = FILTER_PATTERNS.PURE_NUMBER.test(start) && FILTER_PATTERNS.PURE_NUMBER.test(end);
|
|
404
|
+
|
|
405
|
+
if (isNumeric) {
|
|
406
|
+
filter.NOT = (filter.NOT || []).concat([{
|
|
407
|
+
AND: [
|
|
408
|
+
{ [fieldName]: { gte: parseFloat(start) } },
|
|
409
|
+
{ [fieldName]: { lte: parseFloat(end) } },
|
|
410
|
+
],
|
|
411
|
+
}]);
|
|
412
|
+
} else {
|
|
413
|
+
const startDate = new Date(start);
|
|
414
|
+
const endDate = new Date(end);
|
|
415
|
+
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
|
416
|
+
throw new ErrorResponse(400, "invalid_date_range", { start, end });
|
|
417
|
+
}
|
|
418
|
+
filter.NOT = (filter.NOT || []).concat([{
|
|
419
|
+
AND: [
|
|
420
|
+
{ [fieldName]: { gte: startDate } },
|
|
421
|
+
{ [fieldName]: { lte: endDate } },
|
|
422
|
+
],
|
|
423
|
+
}]);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Apply typed filter (auto-detect type: date, number, array, string)
|
|
429
|
+
*/
|
|
430
|
+
#applyTypedFilter(filter: Record<string, any>, fieldName: string, value: string): void {
|
|
431
|
+
// Check for date patterns first
|
|
432
|
+
const hasDateOperator = FILTER_PATTERNS.DATE_OPS.some((op: string) => value.startsWith(op));
|
|
433
|
+
const isIsoDate = FILTER_PATTERNS.ISO_DATE.test(value);
|
|
434
|
+
const isBetweenWithDates = value.startsWith('between:') && this.#looksLikeDateRange(value);
|
|
435
|
+
|
|
436
|
+
if (hasDateOperator || isIsoDate || isBetweenWithDates) {
|
|
437
|
+
const dateFilter = this.#filterDateTime(value);
|
|
438
|
+
if (dateFilter) {
|
|
439
|
+
filter[fieldName] = dateFilter;
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Try numeric filter
|
|
445
|
+
if (this.#looksLikeNumber(value)) {
|
|
446
|
+
const numFilter = this.#filterNumber(value);
|
|
447
|
+
if (numFilter) {
|
|
448
|
+
filter[fieldName] = numFilter;
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
// Plain number
|
|
452
|
+
if (!isNaN(value as unknown as number)) {
|
|
453
|
+
filter[fieldName] = { equals: Number(value) };
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Array filter
|
|
459
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
460
|
+
const arr = this.#parseArrayValue(value);
|
|
461
|
+
if (arr.some((v: unknown) => typeof v === 'string' && v.includes('%'))) {
|
|
462
|
+
if (!filter.OR) filter.OR = [];
|
|
463
|
+
arr.forEach((v: unknown) => filter.OR.push({ [fieldName]: this.#filterString(v as string) }));
|
|
464
|
+
} else {
|
|
465
|
+
filter[fieldName] = { in: arr };
|
|
466
|
+
}
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Default to string filter
|
|
471
|
+
filter[fieldName] = this.#filterString(value);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Check if value looks like a number or numeric operator
|
|
476
|
+
*/
|
|
477
|
+
#looksLikeNumber(value: string): boolean {
|
|
478
|
+
return !isNaN(value as unknown as number) || FILTER_PATTERNS.NUMERIC_OPS.some((op: string) => value.startsWith(op));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Check if between: value contains dates
|
|
483
|
+
*/
|
|
484
|
+
#looksLikeDateRange(value: string): boolean {
|
|
485
|
+
const rangeValue = value.substring(8); // Remove 'between:'
|
|
486
|
+
return (value.includes('-') && value.includes('T')) ||
|
|
487
|
+
rangeValue.split(';').some((part: string) => FILTER_PATTERNS.ISO_DATE.test(part.trim()));
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Parse array value from string
|
|
492
|
+
*/
|
|
493
|
+
#parseArrayValue(value: string): any[] {
|
|
494
|
+
try {
|
|
495
|
+
return JSON.parse(value);
|
|
496
|
+
} catch {
|
|
497
|
+
return value.slice(1, -1).split(',').map((v: string) => v.trim());
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Parse numeric filter operators
|
|
503
|
+
*/
|
|
504
|
+
#filterNumber(value: string): Record<string, number> | null {
|
|
505
|
+
const numOperators = ['lt:', 'lte:', 'gt:', 'gte:', 'eq:', 'ne:', 'between:'];
|
|
506
|
+
const foundOperator = numOperators.find((op: string) => value.startsWith(op));
|
|
507
|
+
let numValue: string | number = value;
|
|
508
|
+
let prismaOp = 'equals';
|
|
509
|
+
|
|
510
|
+
if (foundOperator) {
|
|
511
|
+
numValue = value.substring(foundOperator.length);
|
|
512
|
+
switch (foundOperator) {
|
|
513
|
+
case 'lt:': prismaOp = 'lt'; break;
|
|
514
|
+
case 'lte:': prismaOp = 'lte'; break;
|
|
515
|
+
case 'gt:': prismaOp = 'gt'; break;
|
|
516
|
+
case 'gte:': prismaOp = 'gte'; break;
|
|
517
|
+
case 'eq:': prismaOp = 'equals'; break;
|
|
518
|
+
case 'ne:': prismaOp = 'not'; break;
|
|
519
|
+
case 'between:': {
|
|
520
|
+
// Support between for decimals: between:1.5;3.7
|
|
521
|
+
const [start, end] = (numValue as string).split(';').map((v: string) => parseFloat(v.trim()));
|
|
522
|
+
if (isNaN(start) || isNaN(end)) return null;
|
|
523
|
+
return { gte: start, lte: end };
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Support decimal numbers
|
|
529
|
+
numValue = parseFloat(numValue as string);
|
|
530
|
+
if (isNaN(numValue)) return null;
|
|
531
|
+
|
|
532
|
+
return { [prismaOp]: numValue };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Parse date/datetime filter operators
|
|
537
|
+
*/
|
|
538
|
+
#filterDateTime(value: string): Record<string, Date | Record<string, Date>> | null {
|
|
539
|
+
const foundOperator = FILTER_PATTERNS.DATE_OPS.find((op: string) => value.startsWith(op));
|
|
540
|
+
if (!foundOperator) {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const operatorValue = value.substring(foundOperator.length);
|
|
545
|
+
|
|
546
|
+
try {
|
|
547
|
+
// Map operators to Prisma comparison operators
|
|
548
|
+
const simpleOperatorMap: Record<string, string> = {
|
|
549
|
+
'before:': 'lt',
|
|
550
|
+
'after:': 'gt',
|
|
551
|
+
'from:': 'gte',
|
|
552
|
+
'to:': 'lte',
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
// Handle simple date operators
|
|
556
|
+
if (simpleOperatorMap[foundOperator]) {
|
|
557
|
+
const date = this.#parseDate(operatorValue);
|
|
558
|
+
return { [simpleOperatorMap[foundOperator]]: date };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Handle 'on:' - match entire day
|
|
562
|
+
if (foundOperator === 'on:') {
|
|
563
|
+
const date = this.#parseDate(operatorValue);
|
|
564
|
+
return {
|
|
565
|
+
gte: new Date(date.getFullYear(), date.getMonth(), date.getDate()),
|
|
566
|
+
lt: new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Handle 'between:'
|
|
571
|
+
if (foundOperator === 'between:') {
|
|
572
|
+
const [start, end] = operatorValue.split(';').map((d: string) => d.trim());
|
|
573
|
+
if (!start || !end) {
|
|
574
|
+
throw new ErrorResponse(400, "between_requires_two_values");
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// If both values are pure numbers, let #filterNumber handle it
|
|
578
|
+
if (FILTER_PATTERNS.PURE_NUMBER.test(start) && FILTER_PATTERNS.PURE_NUMBER.test(end)) {
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const startDate = this.#parseDate(start);
|
|
583
|
+
const endDate = this.#parseDate(end);
|
|
584
|
+
return { gte: startDate, lte: endDate };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return null;
|
|
588
|
+
} catch (error: any) {
|
|
589
|
+
if (error instanceof ErrorResponse) throw error;
|
|
590
|
+
throw new ErrorResponse(400, "invalid_date_format", { value, error: error.message });
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Parse a date string and validate it
|
|
596
|
+
*/
|
|
597
|
+
#parseDate(dateStr: string): Date {
|
|
598
|
+
const date = new Date(dateStr);
|
|
599
|
+
if (isNaN(date.getTime())) {
|
|
600
|
+
throw new Error(`Invalid date: ${dateStr}`);
|
|
601
|
+
}
|
|
602
|
+
return date;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Parse string filters with wildcard support and URL decoding
|
|
607
|
+
*/
|
|
608
|
+
#filterString(value: string): Record<string, string> | boolean {
|
|
609
|
+
// Handle boolean literals
|
|
610
|
+
if (value === 'true') return true;
|
|
611
|
+
if (value === 'false') return false;
|
|
612
|
+
|
|
613
|
+
const startsWithWildcard = value.startsWith('%');
|
|
614
|
+
const endsWithWildcard = value.endsWith('%');
|
|
615
|
+
|
|
616
|
+
// %value% -> contains
|
|
617
|
+
if (startsWithWildcard && endsWithWildcard) {
|
|
618
|
+
return { contains: decodeURIComponent(value.slice(1, -1)) };
|
|
619
|
+
}
|
|
620
|
+
// %value -> endsWith
|
|
621
|
+
if (startsWithWildcard) {
|
|
622
|
+
return { endsWith: decodeURIComponent(value.slice(1)) };
|
|
623
|
+
}
|
|
624
|
+
// value% -> startsWith
|
|
625
|
+
if (endsWithWildcard) {
|
|
626
|
+
return { startsWith: decodeURIComponent(value.slice(0, -1)) };
|
|
627
|
+
}
|
|
628
|
+
// exact match
|
|
629
|
+
return { equals: decodeURIComponent(value) };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Build base include content with omit fields and ACL filter.
|
|
634
|
+
* Returns denied=true when ACL explicitly denies access (returns false).
|
|
635
|
+
*/
|
|
636
|
+
#buildBaseIncludeContent(
|
|
637
|
+
relation: RelationConfig,
|
|
638
|
+
user: any,
|
|
639
|
+
parentModel: string
|
|
640
|
+
): { content: Record<string, any>; hasContent: boolean; denied: boolean } {
|
|
641
|
+
const acl = getAcl();
|
|
642
|
+
const content: Record<string, any> = {};
|
|
643
|
+
let hasContent = false;
|
|
644
|
+
|
|
645
|
+
// Check ACL access for the related model
|
|
646
|
+
if (relation.object && acl.model[relation.object]?.getAccessFilter) {
|
|
647
|
+
const accessFilter = acl.model[relation.object].getAccessFilter!(user);
|
|
648
|
+
|
|
649
|
+
// ACL explicitly denies access — skip this relation entirely
|
|
650
|
+
if (accessFilter === false) {
|
|
651
|
+
return { content, hasContent: false, denied: true };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Apply ACL filter as where clause for list relations (Prisma only supports where on list includes)
|
|
655
|
+
const isListRelation = this.#isListRelation(parentModel, relation.name);
|
|
656
|
+
if (isListRelation && accessFilter && typeof accessFilter === 'object') {
|
|
657
|
+
const cleanedFilter = this.cleanFilter(accessFilter);
|
|
658
|
+
const simplifiedFilter = this.#simplifyNestedFilter(cleanedFilter, parentModel);
|
|
659
|
+
if (simplifiedFilter && typeof simplifiedFilter === 'object' && Object.keys(simplifiedFilter).length > 0) {
|
|
660
|
+
content.where = simplifiedFilter;
|
|
661
|
+
hasContent = true;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Add omit fields for this relation if available
|
|
667
|
+
const omitFields = this.getRelatedOmit(relation.object, user);
|
|
668
|
+
if (Object.keys(omitFields).length > 0) {
|
|
669
|
+
content.omit = omitFields;
|
|
670
|
+
hasContent = true;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return { content, hasContent, denied: false };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Check if include content has meaningful properties
|
|
678
|
+
*/
|
|
679
|
+
#hasIncludeContent(content: Record<string, any>): boolean {
|
|
680
|
+
return content.omit || content.where ||
|
|
681
|
+
(content.include && Object.keys(content.include).length > 0);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Get relationships configuration for a specific model
|
|
686
|
+
*/
|
|
687
|
+
#getRelationshipsForModel(modelName: string): RelationConfig[] {
|
|
688
|
+
return modelName ? dmmf.buildRelationships(modelName) : [];
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Build top-level only relationship include (no deep relations).
|
|
693
|
+
* Returns null when ACL denies access to the relation.
|
|
694
|
+
*/
|
|
695
|
+
#includeTopLevelOnly(
|
|
696
|
+
relation: RelationConfig,
|
|
697
|
+
user: any,
|
|
698
|
+
parentModel: string | null = null
|
|
699
|
+
): Record<string, any> | true | null {
|
|
700
|
+
const currentParent = parentModel || this.name;
|
|
701
|
+
const { content, hasContent, denied } = this.#buildBaseIncludeContent(relation, user, currentParent);
|
|
702
|
+
if (denied) return null;
|
|
703
|
+
return hasContent ? content : true;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Build selective deep relationship include based on dot notation paths
|
|
708
|
+
*/
|
|
709
|
+
#includeSelectiveDeepRelationships(
|
|
710
|
+
relation: RelationConfig,
|
|
711
|
+
user: any,
|
|
712
|
+
deepPaths: string[],
|
|
713
|
+
parentModel: string | null = null
|
|
714
|
+
): Record<string, any> | true | null {
|
|
715
|
+
const currentParent = parentModel || this.name;
|
|
716
|
+
const { content, denied } = this.#buildBaseIncludeContent(relation, user, currentParent);
|
|
717
|
+
if (denied) return null;
|
|
718
|
+
content.include = {};
|
|
719
|
+
|
|
720
|
+
// Process deep paths if any
|
|
721
|
+
if (deepPaths?.length > 0) {
|
|
722
|
+
// Group paths by first-level relation
|
|
723
|
+
const pathsByRelation = this.#groupPathsByFirstLevel(deepPaths);
|
|
724
|
+
const childRelationships = this.#getRelationshipsForModel(relation.object);
|
|
725
|
+
|
|
726
|
+
for (const [relationName, paths] of Object.entries(pathsByRelation)) {
|
|
727
|
+
const childRelation = childRelationships.find((r: RelationConfig) => r.name === relationName);
|
|
728
|
+
if (!childRelation) continue;
|
|
729
|
+
|
|
730
|
+
const childPaths = (paths as string[]).filter((p: string) => p !== '');
|
|
731
|
+
const childInclude = childPaths.length > 0
|
|
732
|
+
? this.#includeSelectiveDeepRelationships(childRelation, user, childPaths, relation.object)
|
|
733
|
+
: this.#includeTopLevelOnly(childRelation, user, relation.object);
|
|
734
|
+
|
|
735
|
+
// Skip denied child relations
|
|
736
|
+
if (childInclude !== null) {
|
|
737
|
+
content.include[relationName] = childInclude;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
return this.#hasIncludeContent(content) ? content : true;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Group dot-notation paths by their first level
|
|
747
|
+
*/
|
|
748
|
+
#groupPathsByFirstLevel(paths: string[]): Record<string, string[]> {
|
|
749
|
+
const grouped: Record<string, string[]> = {};
|
|
750
|
+
for (const path of paths) {
|
|
751
|
+
const parts = path.split('.');
|
|
752
|
+
const firstLevel = parts[0];
|
|
753
|
+
if (!grouped[firstLevel]) {
|
|
754
|
+
grouped[firstLevel] = [];
|
|
755
|
+
}
|
|
756
|
+
grouped[firstLevel].push(parts.length > 1 ? parts.slice(1).join('.') : '');
|
|
757
|
+
}
|
|
758
|
+
return grouped;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Build include object for related data with access controls
|
|
763
|
+
*/
|
|
764
|
+
include(
|
|
765
|
+
include: string | { query?: string; rule?: Record<string, unknown> } = "ALL",
|
|
766
|
+
user: any
|
|
767
|
+
): Record<string, unknown> {
|
|
768
|
+
const include_query = typeof include === 'string' ? include : typeof include === 'object' ? include.query : null;
|
|
769
|
+
const exclude_rule = typeof include === 'object' ? include.rule : null;
|
|
770
|
+
if (include_query) {
|
|
771
|
+
let includeRelated: Record<string, any> = {};
|
|
772
|
+
|
|
773
|
+
if (include_query === "ALL") {
|
|
774
|
+
// Load all first-level relationships only (no deep nesting to avoid endless relation loading)
|
|
775
|
+
includeRelated = this.relatedObjects.reduce((acc: Record<string, any>, curr: RelationConfig) => {
|
|
776
|
+
let rel: any = this.#includeTopLevelOnly(curr, user);
|
|
777
|
+
// Skip relations where ACL denies access
|
|
778
|
+
if (rel === null) return acc;
|
|
779
|
+
if (exclude_rule && exclude_rule[curr.name]) {
|
|
780
|
+
if (typeof rel === 'object' && rel !== null) {
|
|
781
|
+
rel.where = exclude_rule[curr.name];
|
|
782
|
+
} else {
|
|
783
|
+
rel = { where: exclude_rule[curr.name] };
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
acc[curr.name] = rel;
|
|
787
|
+
return acc;
|
|
788
|
+
}, {});
|
|
789
|
+
} else {
|
|
790
|
+
// Parse dot notation includes (e.g., "student.agency,course")
|
|
791
|
+
const includeList = include_query.split(',').map((item: string) => item.trim());
|
|
792
|
+
const topLevelIncludes = new Set<string>();
|
|
793
|
+
const deepIncludes: Record<string, string[]> = {};
|
|
794
|
+
|
|
795
|
+
// Separate top-level and deep includes
|
|
796
|
+
includeList.forEach((item: string) => {
|
|
797
|
+
const parts = item.split('.');
|
|
798
|
+
const topLevel = parts[0];
|
|
799
|
+
topLevelIncludes.add(topLevel);
|
|
800
|
+
|
|
801
|
+
if (parts.length > 1) {
|
|
802
|
+
if (!deepIncludes[topLevel]) {
|
|
803
|
+
deepIncludes[topLevel] = [];
|
|
804
|
+
}
|
|
805
|
+
deepIncludes[topLevel].push(parts.slice(1).join('.'));
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
// Build include object for each top-level relation
|
|
810
|
+
this.relatedObjects.forEach((curr: RelationConfig) => {
|
|
811
|
+
if (topLevelIncludes.has(curr.name)) {
|
|
812
|
+
let rel: any;
|
|
813
|
+
|
|
814
|
+
if (deepIncludes[curr.name]) {
|
|
815
|
+
// Build selective deep relationships
|
|
816
|
+
rel = this.#includeSelectiveDeepRelationships(curr, user, deepIncludes[curr.name]);
|
|
817
|
+
} else {
|
|
818
|
+
// Only include top-level (no deep relationships)
|
|
819
|
+
rel = this.#includeTopLevelOnly(curr, user);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Skip relations where ACL denies access
|
|
823
|
+
if (rel === null) return;
|
|
824
|
+
|
|
825
|
+
if (exclude_rule && exclude_rule[curr.name]) {
|
|
826
|
+
if (typeof rel === 'object' && rel !== null) {
|
|
827
|
+
rel.where = exclude_rule[curr.name];
|
|
828
|
+
} else {
|
|
829
|
+
rel = { where: exclude_rule[curr.name] };
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
includeRelated[curr.name] = rel;
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
return includeRelated;
|
|
838
|
+
}
|
|
839
|
+
return {};
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Build omit object for hiding fields based on user role
|
|
844
|
+
*/
|
|
845
|
+
omit(user: any, inaccessible_fields: string[] | null = null): Record<string, boolean> {
|
|
846
|
+
const acl = getAcl();
|
|
847
|
+
// Get omit fields from ACL if available
|
|
848
|
+
let omit_fields = inaccessible_fields;
|
|
849
|
+
|
|
850
|
+
if (!omit_fields && acl.model[this.name]?.getOmitFields) {
|
|
851
|
+
omit_fields = acl.model[this.name].getOmitFields!(user);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (omit_fields && Array.isArray(omit_fields)) {
|
|
855
|
+
return omit_fields.reduce((acc: Record<string, boolean>, curr: string) => {
|
|
856
|
+
acc[curr] = true;
|
|
857
|
+
return acc;
|
|
858
|
+
}, {});
|
|
859
|
+
}
|
|
860
|
+
return {};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Get omit fields for a related object based on user role
|
|
865
|
+
*/
|
|
866
|
+
getRelatedOmit(relatedModelName: string, user: any): Record<string, boolean> {
|
|
867
|
+
const acl = getAcl();
|
|
868
|
+
if (acl.model[relatedModelName]?.getOmitFields) {
|
|
869
|
+
const omit_fields = acl.model[relatedModelName].getOmitFields!(user);
|
|
870
|
+
if (omit_fields && Array.isArray(omit_fields)) {
|
|
871
|
+
return omit_fields.reduce((acc: Record<string, boolean>, curr: string) => {
|
|
872
|
+
acc[curr] = true;
|
|
873
|
+
return acc;
|
|
874
|
+
}, {});
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return {};
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Parse a fields string into scalar fields and relation field groups.
|
|
882
|
+
* e.g., "id,name,posts.title,posts.content,author.name"
|
|
883
|
+
* → { scalars: ['id','name'], relations: Map { 'posts' => ['title','content'], 'author' => ['name'] } }
|
|
884
|
+
*/
|
|
885
|
+
#parseFields(fields: string): { scalars: string[]; relations: Map<string, string[]> } {
|
|
886
|
+
const scalars: string[] = [];
|
|
887
|
+
const relations = new Map<string, string[]>();
|
|
888
|
+
|
|
889
|
+
const parts = fields.split(',').map(f => f.trim()).filter(f => f.length > 0);
|
|
890
|
+
|
|
891
|
+
for (const part of parts) {
|
|
892
|
+
const dotIndex = part.indexOf('.');
|
|
893
|
+
if (dotIndex === -1) {
|
|
894
|
+
if (!scalars.includes(part)) scalars.push(part);
|
|
895
|
+
} else {
|
|
896
|
+
const relationName = part.substring(0, dotIndex);
|
|
897
|
+
const fieldName = part.substring(dotIndex + 1);
|
|
898
|
+
if (!relations.has(relationName)) {
|
|
899
|
+
relations.set(relationName, []);
|
|
900
|
+
}
|
|
901
|
+
const arr = relations.get(relationName)!;
|
|
902
|
+
if (!arr.includes(fieldName)) arr.push(fieldName);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
return { scalars, relations };
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Recursively set a nested field path into a select object.
|
|
911
|
+
* e.g. "agency.name" on obj → obj.agency = { select: { name: true } }
|
|
912
|
+
*/
|
|
913
|
+
#setNestedField(obj: Record<string, any>, fieldPath: string): void {
|
|
914
|
+
const dotIdx = fieldPath.indexOf('.');
|
|
915
|
+
if (dotIdx === -1) {
|
|
916
|
+
obj[fieldPath] = true;
|
|
917
|
+
} else {
|
|
918
|
+
const key = fieldPath.substring(0, dotIdx);
|
|
919
|
+
const rest = fieldPath.substring(dotIdx + 1);
|
|
920
|
+
if (!obj[key]) {
|
|
921
|
+
obj[key] = { select: {} };
|
|
922
|
+
} else if (obj[key] === true) {
|
|
923
|
+
obj[key] = { select: {} };
|
|
924
|
+
}
|
|
925
|
+
this.#setNestedField(obj[key].select, rest);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Build the Prisma field selection clause.
|
|
931
|
+
* When `fields` is specified, returns `{ select: ... }` (Prisma select mode).
|
|
932
|
+
* When `fields` is null/empty, returns `{ include: ..., omit: ... }` (current behavior).
|
|
933
|
+
*
|
|
934
|
+
* Prisma does NOT support `select` and `include` together.
|
|
935
|
+
* When fields are specified, everything goes through `select`.
|
|
936
|
+
*
|
|
937
|
+
* @param fields - Comma-separated field list with dot notation for relations, or null
|
|
938
|
+
* @param include - Include string ("ALL", "author,posts", etc.)
|
|
939
|
+
* @param user - User for ACL filters
|
|
940
|
+
*/
|
|
941
|
+
buildFieldSelection(
|
|
942
|
+
fields: string | null,
|
|
943
|
+
include: string | Record<string, any>,
|
|
944
|
+
user: any
|
|
945
|
+
): { select?: Record<string, any>; include?: Record<string, any>; omit?: Record<string, any> } {
|
|
946
|
+
// No fields specified → current behavior
|
|
947
|
+
if (!fields || fields.trim() === '') {
|
|
948
|
+
const includeClause = this.include(include, user);
|
|
949
|
+
const omitClause = this.omit(user);
|
|
950
|
+
return {
|
|
951
|
+
...(Object.keys(includeClause).length > 0 ? { include: includeClause } : {}),
|
|
952
|
+
...(Object.keys(omitClause).length > 0 ? { omit: omitClause } : {}),
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const { scalars, relations } = this.#parseFields(fields);
|
|
957
|
+
const includeStr = typeof include === 'string' ? include : '';
|
|
958
|
+
|
|
959
|
+
// Determine which relations are available from the include param
|
|
960
|
+
const availableRelations = new Set<string>();
|
|
961
|
+
const isAll = includeStr.trim() === 'ALL';
|
|
962
|
+
|
|
963
|
+
if (isAll) {
|
|
964
|
+
for (const rel of this.relatedObjects) {
|
|
965
|
+
availableRelations.add(rel.name);
|
|
966
|
+
}
|
|
967
|
+
} else if (includeStr.trim() !== '') {
|
|
968
|
+
const includeList = includeStr.split(',').map(s => s.trim());
|
|
969
|
+
for (const item of includeList) {
|
|
970
|
+
availableRelations.add(item.split('.')[0]);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Validate: every relation referenced in fields must be in the include set
|
|
975
|
+
for (const relationName of relations.keys()) {
|
|
976
|
+
if (!availableRelations.has(relationName)) {
|
|
977
|
+
throw new ErrorResponse(400, "relation_not_included", {
|
|
978
|
+
relation: relationName,
|
|
979
|
+
hint: `Add '${relationName}' to the include parameter`,
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Build select object
|
|
985
|
+
const select: Record<string, any> = {};
|
|
986
|
+
|
|
987
|
+
// Add top-level omit fields to exclude from selection
|
|
988
|
+
const omitFields = this.omit(user);
|
|
989
|
+
|
|
990
|
+
// Add scalar fields
|
|
991
|
+
for (const field of scalars) {
|
|
992
|
+
if (!omitFields[field]) {
|
|
993
|
+
select[field] = true;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Add relations from the include param
|
|
998
|
+
for (const relationName of availableRelations) {
|
|
999
|
+
const rel = this.relatedObjects.find(r => r.name === relationName);
|
|
1000
|
+
if (!rel) continue;
|
|
1001
|
+
|
|
1002
|
+
// Get ACL content for this relation (where, omit)
|
|
1003
|
+
const { content, denied } = this.#buildBaseIncludeContent(rel, user, this.name);
|
|
1004
|
+
if (denied) continue;
|
|
1005
|
+
|
|
1006
|
+
const relationFields = relations.get(relationName);
|
|
1007
|
+
|
|
1008
|
+
if (relationFields && relationFields.length > 0) {
|
|
1009
|
+
// User specified specific fields for this relation
|
|
1010
|
+
const relSelect: Record<string, any> = {};
|
|
1011
|
+
const relOmit = this.getRelatedOmit(rel.object, user);
|
|
1012
|
+
|
|
1013
|
+
for (const f of relationFields) {
|
|
1014
|
+
if (f.includes('.')) {
|
|
1015
|
+
// Nested relation field (e.g. "agency.name" → agency: { select: { name: true } })
|
|
1016
|
+
const dotIdx = f.indexOf('.');
|
|
1017
|
+
const nestedRel = f.substring(0, dotIdx);
|
|
1018
|
+
const nestedField = f.substring(dotIdx + 1);
|
|
1019
|
+
|
|
1020
|
+
if (!relSelect[nestedRel]) {
|
|
1021
|
+
relSelect[nestedRel] = { select: {} };
|
|
1022
|
+
} else if (relSelect[nestedRel] === true) {
|
|
1023
|
+
relSelect[nestedRel] = { select: {} };
|
|
1024
|
+
}
|
|
1025
|
+
this.#setNestedField(relSelect[nestedRel].select, nestedField);
|
|
1026
|
+
} else if (!relOmit[f]) {
|
|
1027
|
+
relSelect[f] = true;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
const entry: Record<string, any> = { select: relSelect };
|
|
1032
|
+
if (content.where) entry.where = content.where;
|
|
1033
|
+
select[relationName] = entry;
|
|
1034
|
+
} else {
|
|
1035
|
+
// Relation is in include but no specific fields → include with all fields
|
|
1036
|
+
if (content.where || content.omit) {
|
|
1037
|
+
select[relationName] = content;
|
|
1038
|
+
} else {
|
|
1039
|
+
select[relationName] = true;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
return { select };
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Validate and limit result count
|
|
1049
|
+
*/
|
|
1050
|
+
take(limit: number): number {
|
|
1051
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
1052
|
+
throw new ErrorResponse(400, "invalid_limit");
|
|
1053
|
+
}
|
|
1054
|
+
return limit > QueryBuilder.API_RESULT_LIMIT ? QueryBuilder.API_RESULT_LIMIT : limit;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Build sort object for ordering results
|
|
1059
|
+
*/
|
|
1060
|
+
sort(sortBy: string, sortOrder: string): Record<string, unknown> {
|
|
1061
|
+
if (typeof sortBy !== 'string') {
|
|
1062
|
+
throw new ErrorResponse(400, "sortby_must_be_string", { type: typeof sortBy });
|
|
1063
|
+
}
|
|
1064
|
+
if (typeof sortOrder !== 'string' || (sortOrder != 'desc' && sortOrder != 'asc')) {
|
|
1065
|
+
throw new ErrorResponse(400, "sortorder_invalid", { value: sortOrder });
|
|
1066
|
+
}
|
|
1067
|
+
const relation_chain = sortBy.split('.').map((e: string) => e.trim());
|
|
1068
|
+
const field_name = relation_chain.pop()!;
|
|
1069
|
+
|
|
1070
|
+
const sort: Record<string, any> = {};
|
|
1071
|
+
let curr: Record<string, any> = sort;
|
|
1072
|
+
for (let i = 0; i < relation_chain.length; i++) {
|
|
1073
|
+
curr[relation_chain[i]] = {};
|
|
1074
|
+
curr = curr[relation_chain[i]];
|
|
1075
|
+
}
|
|
1076
|
+
curr[field_name] = sortOrder;
|
|
1077
|
+
|
|
1078
|
+
return sort;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Process data for create operation with relation handling
|
|
1083
|
+
* Transforms nested relation data into Prisma create/connect syntax
|
|
1084
|
+
* Does NOT mutate the input data - returns a new transformed object
|
|
1085
|
+
*/
|
|
1086
|
+
create(data: Record<string, unknown>, user: any = null): Record<string, unknown> {
|
|
1087
|
+
const acl = getAcl();
|
|
1088
|
+
let result: Record<string, any> = { ...data };
|
|
1089
|
+
|
|
1090
|
+
// Remove fields user shouldn't be able to set
|
|
1091
|
+
const modelAcl = acl.model[this.name];
|
|
1092
|
+
const omitFields = user && modelAcl?.getOmitFields
|
|
1093
|
+
? modelAcl.getOmitFields(user)
|
|
1094
|
+
: [];
|
|
1095
|
+
for (const field of omitFields) {
|
|
1096
|
+
delete result[field];
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
const keys = Object.keys(result);
|
|
1100
|
+
for (const key of keys) {
|
|
1101
|
+
const field = this.fields[key];
|
|
1102
|
+
const isRelationField = field?.kind === 'object';
|
|
1103
|
+
|
|
1104
|
+
// Handle relation fields or unknown keys
|
|
1105
|
+
if (field == null || isRelationField) {
|
|
1106
|
+
result = this.#processCreateRelation(result, key, user);
|
|
1107
|
+
} else {
|
|
1108
|
+
// Check if this scalar field is a FK that should become a connect
|
|
1109
|
+
result = this.#processCreateForeignKey(result, key, user);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
return result;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Process a relation field for create operation
|
|
1118
|
+
*/
|
|
1119
|
+
#processCreateRelation(
|
|
1120
|
+
data: Record<string, any>,
|
|
1121
|
+
key: string,
|
|
1122
|
+
user: any = null
|
|
1123
|
+
): Record<string, any> {
|
|
1124
|
+
const relatedObject = this.relatedObjects.find((e: RelationConfig) => e.name === key);
|
|
1125
|
+
if (!relatedObject) {
|
|
1126
|
+
throw new ErrorResponse(400, "unexpected_key", { key });
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
if (!data[key]) return data;
|
|
1130
|
+
|
|
1131
|
+
this.#ensureRelations(relatedObject);
|
|
1132
|
+
|
|
1133
|
+
const result: Record<string, any> = { ...data };
|
|
1134
|
+
if (Array.isArray(data[key])) {
|
|
1135
|
+
// Clone each item to avoid mutating original nested objects
|
|
1136
|
+
result[key] = this.#processCreateArrayRelation(
|
|
1137
|
+
data[key].map((item: Record<string, any>) => ({ ...item })), relatedObject, key, user
|
|
1138
|
+
);
|
|
1139
|
+
} else {
|
|
1140
|
+
// Clone the nested object to avoid mutating original
|
|
1141
|
+
result[key] = this.#processCreateSingleRelation(
|
|
1142
|
+
{ ...data[key] }, relatedObject, key, user
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
return result;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Process array relation for create operation
|
|
1150
|
+
*/
|
|
1151
|
+
#processCreateArrayRelation(
|
|
1152
|
+
items: Record<string, any>[],
|
|
1153
|
+
relatedObject: RelationConfig,
|
|
1154
|
+
relationName: string,
|
|
1155
|
+
user: any = null,
|
|
1156
|
+
depth: number = 0
|
|
1157
|
+
): Record<string, any> {
|
|
1158
|
+
const acl = getAcl();
|
|
1159
|
+
|
|
1160
|
+
if (depth > MAX_NESTING_DEPTH) {
|
|
1161
|
+
throw new ErrorResponse(400, "max_nesting_depth_exceeded", { depth: MAX_NESTING_DEPTH });
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
this.#ensureRelations(relatedObject);
|
|
1165
|
+
|
|
1166
|
+
for (let i = 0; i < items.length; i++) {
|
|
1167
|
+
this.#validateAndTransformRelationItem(items[i], relatedObject, relationName);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const relatedPrimaryKey = this.getPrimaryKey(relatedObject.object);
|
|
1171
|
+
const pkFields = Array.isArray(relatedPrimaryKey) ? relatedPrimaryKey : [relatedPrimaryKey];
|
|
1172
|
+
const foreignKey = relatedObject.foreignKey || pkFields[0];
|
|
1173
|
+
|
|
1174
|
+
// For composite keys, check if ALL PK fields are present
|
|
1175
|
+
const hasCompletePK = (item: Record<string, any>): boolean => pkFields.every((field: string) => item[field] != null);
|
|
1176
|
+
|
|
1177
|
+
// Check if an item has ONLY primary key fields (no additional data)
|
|
1178
|
+
const hasOnlyPKFields = (item: Record<string, any>): boolean => {
|
|
1179
|
+
const itemKeys = Object.keys(item);
|
|
1180
|
+
return itemKeys.every((key: string) => pkFields.includes(key));
|
|
1181
|
+
};
|
|
1182
|
+
|
|
1183
|
+
const createItems = items.filter((e: Record<string, any>) => !hasCompletePK(e));
|
|
1184
|
+
const connectOnlyItems = items.filter((e: Record<string, any>) => hasCompletePK(e) && hasOnlyPKFields(e));
|
|
1185
|
+
const upsertItems = items.filter((e: Record<string, any>) => hasCompletePK(e) && !hasOnlyPKFields(e));
|
|
1186
|
+
|
|
1187
|
+
// Get ACL for the related model
|
|
1188
|
+
const relatedAcl = acl.model[relatedObject.object];
|
|
1189
|
+
const accessFilter = user && relatedAcl?.getAccessFilter
|
|
1190
|
+
? this.cleanFilter(relatedAcl.getAccessFilter(user))
|
|
1191
|
+
: null;
|
|
1192
|
+
|
|
1193
|
+
// Get omit fields for nested creates
|
|
1194
|
+
const omitFields = user && relatedAcl?.getOmitFields
|
|
1195
|
+
? relatedAcl.getOmitFields(user)
|
|
1196
|
+
: [];
|
|
1197
|
+
|
|
1198
|
+
const result: Record<string, any> = {};
|
|
1199
|
+
|
|
1200
|
+
if (createItems.length > 0) {
|
|
1201
|
+
// Check canCreate permission for nested creates
|
|
1202
|
+
if (user && relatedAcl?.canCreate) {
|
|
1203
|
+
for (const item of createItems) {
|
|
1204
|
+
if (!relatedAcl.canCreate(user, item)) {
|
|
1205
|
+
throw new ErrorResponse(403, "no_permission_to_create", { model: relatedObject.object });
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Remove omitted fields from create items
|
|
1211
|
+
result.create = createItems.map((item: Record<string, any>) => {
|
|
1212
|
+
const cleanedItem: Record<string, any> = { ...item };
|
|
1213
|
+
for (const field of omitFields) {
|
|
1214
|
+
delete cleanedItem[field];
|
|
1215
|
+
}
|
|
1216
|
+
return cleanedItem;
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
if (connectOnlyItems.length > 0) {
|
|
1221
|
+
if (pkFields.length > 1) {
|
|
1222
|
+
// Composite key - build composite where clause with ACL
|
|
1223
|
+
result.connect = connectOnlyItems.map((e: Record<string, any>) => {
|
|
1224
|
+
const where: Record<string, any> = {};
|
|
1225
|
+
pkFields.forEach((field: string) => { where[field] = e[field]; });
|
|
1226
|
+
// Apply ACL access filter
|
|
1227
|
+
if (accessFilter && typeof accessFilter === 'object' && Object.keys(accessFilter).length > 0) {
|
|
1228
|
+
Object.assign(where, accessFilter);
|
|
1229
|
+
}
|
|
1230
|
+
return where;
|
|
1231
|
+
});
|
|
1232
|
+
} else {
|
|
1233
|
+
// Simple key with ACL
|
|
1234
|
+
result.connect = connectOnlyItems.map((e: Record<string, any>) => {
|
|
1235
|
+
const where: Record<string, any> = { [foreignKey]: e[foreignKey] || e[pkFields[0]] };
|
|
1236
|
+
// Apply ACL access filter
|
|
1237
|
+
if (accessFilter && typeof accessFilter === 'object' && Object.keys(accessFilter).length > 0) {
|
|
1238
|
+
Object.assign(where, accessFilter);
|
|
1239
|
+
}
|
|
1240
|
+
return where;
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
if (upsertItems.length > 0) {
|
|
1246
|
+
// Check canCreate permission for nested connectOrCreate
|
|
1247
|
+
if (user && relatedAcl?.canCreate) {
|
|
1248
|
+
for (const item of upsertItems) {
|
|
1249
|
+
if (!relatedAcl.canCreate(user, item)) {
|
|
1250
|
+
throw new ErrorResponse(403, "no_permission_to_create", { model: relatedObject.object });
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// Remove omitted fields from connectOrCreate items
|
|
1256
|
+
result.connectOrCreate = upsertItems.map((item: Record<string, any>) => {
|
|
1257
|
+
const cleanedItem: Record<string, any> = { ...item };
|
|
1258
|
+
for (const field of omitFields) {
|
|
1259
|
+
delete cleanedItem[field];
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// Build where clause from PK fields
|
|
1263
|
+
const where: Record<string, any> = {};
|
|
1264
|
+
if (pkFields.length > 1) {
|
|
1265
|
+
pkFields.forEach((field: string) => { where[field] = cleanedItem[field]; });
|
|
1266
|
+
} else {
|
|
1267
|
+
where[foreignKey] = cleanedItem[foreignKey] || cleanedItem[pkFields[0]];
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// Apply ACL access filter
|
|
1271
|
+
if (accessFilter && typeof accessFilter === 'object' && Object.keys(accessFilter).length > 0) {
|
|
1272
|
+
Object.assign(where, accessFilter);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
return {
|
|
1276
|
+
where,
|
|
1277
|
+
create: cleanedItem,
|
|
1278
|
+
};
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
return result;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* Process single relation for create operation
|
|
1287
|
+
*/
|
|
1288
|
+
#processCreateSingleRelation(
|
|
1289
|
+
item: Record<string, any>,
|
|
1290
|
+
relatedObject: RelationConfig,
|
|
1291
|
+
relationName: string,
|
|
1292
|
+
user: any = null,
|
|
1293
|
+
depth: number = 0
|
|
1294
|
+
): Record<string, any> {
|
|
1295
|
+
const acl = getAcl();
|
|
1296
|
+
|
|
1297
|
+
if (depth > MAX_NESTING_DEPTH) {
|
|
1298
|
+
throw new ErrorResponse(400, "max_nesting_depth_exceeded", { depth: MAX_NESTING_DEPTH });
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// Get ACL for the related model
|
|
1302
|
+
const relatedAcl = acl.model[relatedObject.object];
|
|
1303
|
+
|
|
1304
|
+
// Check canCreate permission
|
|
1305
|
+
if (user && relatedAcl?.canCreate && !relatedAcl.canCreate(user, item)) {
|
|
1306
|
+
throw new ErrorResponse(403, "no_permission_to_create", { model: relatedObject.object });
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// If item is already a Prisma operation (connect, create, etc.), skip field validation
|
|
1310
|
+
const prismaOps = ['connect', 'create', 'disconnect', 'set', 'update', 'upsert', 'deleteMany', 'updateMany', 'createMany'];
|
|
1311
|
+
if (prismaOps.some((op: string) => op in item)) {
|
|
1312
|
+
return { ...item };
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// Get and apply omit fields
|
|
1316
|
+
const omitFields = user && relatedAcl?.getOmitFields
|
|
1317
|
+
? relatedAcl.getOmitFields(user)
|
|
1318
|
+
: [];
|
|
1319
|
+
for (const field of omitFields) {
|
|
1320
|
+
delete item[field];
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// Ensure nested relations are resolved for deep processing
|
|
1324
|
+
this.#ensureRelations(relatedObject);
|
|
1325
|
+
|
|
1326
|
+
for (const fieldKey of Object.keys(item)) {
|
|
1327
|
+
if (!this.#fieldExistsOnModel(relatedObject.object, fieldKey)) {
|
|
1328
|
+
throw new ErrorResponse(400, "unexpected_key", { key: `${relationName}.${fieldKey}` });
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// Check if this field is a FK that should become a nested connect
|
|
1332
|
+
const childRelation = relatedObject?.relation?.find((e: RelationConfig) => e.field === fieldKey);
|
|
1333
|
+
if (childRelation && item[fieldKey]) {
|
|
1334
|
+
const targetPrimaryKey = childRelation.foreignKey || this.getPrimaryKey(childRelation.object);
|
|
1335
|
+
const connectWhere: Record<string, any> = {};
|
|
1336
|
+
|
|
1337
|
+
// Handle composite primary keys
|
|
1338
|
+
if (Array.isArray(targetPrimaryKey)) {
|
|
1339
|
+
if (typeof item[fieldKey] === 'object' && item[fieldKey] !== null) {
|
|
1340
|
+
targetPrimaryKey.forEach((pk: string) => {
|
|
1341
|
+
if (item[fieldKey][pk] != null) {
|
|
1342
|
+
connectWhere[pk] = item[fieldKey][pk];
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
1345
|
+
} else {
|
|
1346
|
+
connectWhere[targetPrimaryKey[0]] = item[fieldKey];
|
|
1347
|
+
}
|
|
1348
|
+
} else {
|
|
1349
|
+
connectWhere[targetPrimaryKey] = item[fieldKey];
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// Apply ACL access filter for connect
|
|
1353
|
+
const childAcl = acl.model[childRelation.object];
|
|
1354
|
+
const accessFilter = user && childAcl?.getAccessFilter
|
|
1355
|
+
? this.cleanFilter(childAcl.getAccessFilter(user))
|
|
1356
|
+
: null;
|
|
1357
|
+
if (accessFilter && typeof accessFilter === 'object' && Object.keys(accessFilter).length > 0) {
|
|
1358
|
+
Object.assign(connectWhere, accessFilter);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
item[childRelation.name] = { connect: connectWhere };
|
|
1362
|
+
delete item[fieldKey];
|
|
1363
|
+
continue;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// Check if this field is a nested relation object that needs recursive processing
|
|
1367
|
+
const nestedRelation = relatedObject?.relation?.find((e: RelationConfig) => e.name === fieldKey);
|
|
1368
|
+
if (nestedRelation && item[fieldKey] && typeof item[fieldKey] === 'object') {
|
|
1369
|
+
this.#ensureRelations(nestedRelation);
|
|
1370
|
+
if (Array.isArray(item[fieldKey])) {
|
|
1371
|
+
item[fieldKey] = this.#processCreateArrayRelation(
|
|
1372
|
+
item[fieldKey].map((i: Record<string, any>) => ({ ...i })), nestedRelation, `${relationName}.${fieldKey}`, user, depth + 1
|
|
1373
|
+
);
|
|
1374
|
+
} else {
|
|
1375
|
+
item[fieldKey] = this.#processCreateSingleRelation(
|
|
1376
|
+
{ ...item[fieldKey] }, nestedRelation, `${relationName}.${fieldKey}`, user, depth + 1
|
|
1377
|
+
);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
return { create: { ...item } };
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* Validate relation item fields and transform nested FK references
|
|
1387
|
+
*/
|
|
1388
|
+
#validateAndTransformRelationItem(
|
|
1389
|
+
item: Record<string, any>,
|
|
1390
|
+
relatedObject: RelationConfig,
|
|
1391
|
+
relationName: string
|
|
1392
|
+
): void {
|
|
1393
|
+
this.#ensureRelations(relatedObject);
|
|
1394
|
+
|
|
1395
|
+
for (const fieldKey of Object.keys(item)) {
|
|
1396
|
+
if (!this.#fieldExistsOnModel(relatedObject.object, fieldKey)) {
|
|
1397
|
+
throw new ErrorResponse(400, "unexpected_key", { key: `${relationName}.${fieldKey}` });
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// Handle composite FK fields
|
|
1401
|
+
if (relatedObject.fields?.includes(fieldKey)) {
|
|
1402
|
+
const index = relatedObject.fields.findIndex((f: string) => f === fieldKey);
|
|
1403
|
+
if (index > 0 && relatedObject.relation?.[index - 1]) {
|
|
1404
|
+
const rel = relatedObject.relation[index - 1];
|
|
1405
|
+
const relPrimaryKey = rel.foreignKey || this.getPrimaryKey(rel.object);
|
|
1406
|
+
const restData: Record<string, any> = { ...item };
|
|
1407
|
+
delete restData[fieldKey];
|
|
1408
|
+
|
|
1409
|
+
Object.assign(item, {
|
|
1410
|
+
[rel.name]: { connect: { [relPrimaryKey as string]: item[fieldKey] } },
|
|
1411
|
+
...restData,
|
|
1412
|
+
});
|
|
1413
|
+
delete item[fieldKey];
|
|
1414
|
+
} else {
|
|
1415
|
+
delete item[fieldKey];
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* Process a scalar field that might be a FK needing connect transformation
|
|
1423
|
+
*/
|
|
1424
|
+
#processCreateForeignKey(
|
|
1425
|
+
data: Record<string, any>,
|
|
1426
|
+
key: string,
|
|
1427
|
+
user: any = null
|
|
1428
|
+
): Record<string, any> {
|
|
1429
|
+
const acl = getAcl();
|
|
1430
|
+
const relatedObject = this.relatedObjects.find((e: RelationConfig) => e.field === key);
|
|
1431
|
+
if (!relatedObject) return data;
|
|
1432
|
+
|
|
1433
|
+
const result: Record<string, any> = { ...data };
|
|
1434
|
+
if (result[key]) {
|
|
1435
|
+
const targetPrimaryKey = this.getPrimaryKey(relatedObject.object);
|
|
1436
|
+
const foreignKey = relatedObject.foreignKey || (Array.isArray(targetPrimaryKey) ? targetPrimaryKey[0] : targetPrimaryKey);
|
|
1437
|
+
// Build connect where clause
|
|
1438
|
+
const connectWhere: Record<string, any> = { [foreignKey]: result[key] };
|
|
1439
|
+
|
|
1440
|
+
// Apply ACL access filter for connect
|
|
1441
|
+
const relatedAcl = acl.model[relatedObject.object];
|
|
1442
|
+
const accessFilter = user && relatedAcl?.getAccessFilter
|
|
1443
|
+
? this.cleanFilter(relatedAcl.getAccessFilter(user))
|
|
1444
|
+
: null;
|
|
1445
|
+
if (accessFilter && typeof accessFilter === 'object' && Object.keys(accessFilter).length > 0) {
|
|
1446
|
+
Object.assign(connectWhere, accessFilter);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
result[relatedObject.name] = { connect: connectWhere };
|
|
1450
|
+
}
|
|
1451
|
+
delete result[key];
|
|
1452
|
+
return result;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/**
|
|
1456
|
+
* Process data for update operation with nested relation support
|
|
1457
|
+
* Transforms nested relation data into Prisma update/upsert/connect/disconnect syntax
|
|
1458
|
+
* Does NOT mutate the input data - returns a new transformed object
|
|
1459
|
+
*/
|
|
1460
|
+
update(id: string | number, data: Record<string, unknown>, user: any = null): Record<string, unknown> {
|
|
1461
|
+
const acl = getAcl();
|
|
1462
|
+
let result: Record<string, any> = { ...data };
|
|
1463
|
+
|
|
1464
|
+
// Remove fields user shouldn't be able to modify
|
|
1465
|
+
const modelAcl = acl.model[this.name];
|
|
1466
|
+
const omitFields = user && modelAcl?.getOmitFields
|
|
1467
|
+
? modelAcl.getOmitFields(user)
|
|
1468
|
+
: [];
|
|
1469
|
+
for (const field of omitFields) {
|
|
1470
|
+
delete result[field];
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
const keys = Object.keys(result);
|
|
1474
|
+
for (const key of keys) {
|
|
1475
|
+
const field = this.fields[key];
|
|
1476
|
+
const isRelationField = field?.kind === 'object';
|
|
1477
|
+
|
|
1478
|
+
// Handle relation fields or unknown keys
|
|
1479
|
+
if (field == null || isRelationField) {
|
|
1480
|
+
result = this.#processUpdateRelation(result, key, id, user);
|
|
1481
|
+
} else {
|
|
1482
|
+
// Check if this scalar field is a FK that should become a connect/disconnect
|
|
1483
|
+
result = this.#processUpdateForeignKey(result, key, user);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
return result;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* Process a relation field for update operation
|
|
1492
|
+
*/
|
|
1493
|
+
#processUpdateRelation(
|
|
1494
|
+
data: Record<string, any>,
|
|
1495
|
+
key: string,
|
|
1496
|
+
parentId: string | number,
|
|
1497
|
+
user: any
|
|
1498
|
+
): Record<string, any> {
|
|
1499
|
+
const relatedObject = this.relatedObjects.find((e: RelationConfig) => e.name === key);
|
|
1500
|
+
if (!relatedObject) {
|
|
1501
|
+
throw new ErrorResponse(400, "unexpected_key", { key });
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
if (!data[key]) return data;
|
|
1505
|
+
|
|
1506
|
+
this.#ensureRelations(relatedObject);
|
|
1507
|
+
|
|
1508
|
+
const result: Record<string, any> = { ...data };
|
|
1509
|
+
if (Array.isArray(data[key])) {
|
|
1510
|
+
// Clone each item to avoid mutating original nested objects
|
|
1511
|
+
result[key] = this.#processArrayRelation(
|
|
1512
|
+
data[key].map((item: Record<string, any>) => ({ ...item })), relatedObject, parentId, user
|
|
1513
|
+
);
|
|
1514
|
+
} else {
|
|
1515
|
+
// Clone the nested object to avoid mutating original
|
|
1516
|
+
result[key] = this.#processSingleRelation(
|
|
1517
|
+
{ ...data[key] }, relatedObject, user
|
|
1518
|
+
);
|
|
1519
|
+
}
|
|
1520
|
+
return result;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
* Process a scalar field that might be a FK needing connect/disconnect transformation
|
|
1525
|
+
*/
|
|
1526
|
+
#processUpdateForeignKey(
|
|
1527
|
+
data: Record<string, any>,
|
|
1528
|
+
key: string,
|
|
1529
|
+
user: any = null
|
|
1530
|
+
): Record<string, any> {
|
|
1531
|
+
const acl = getAcl();
|
|
1532
|
+
const relatedObject = this.relatedObjects.find((e: RelationConfig) => e.field === key);
|
|
1533
|
+
if (!relatedObject) return data;
|
|
1534
|
+
|
|
1535
|
+
const result: Record<string, any> = { ...data };
|
|
1536
|
+
const targetPrimaryKey = this.getPrimaryKey(relatedObject.object);
|
|
1537
|
+
const foreignKey = relatedObject.foreignKey || (Array.isArray(targetPrimaryKey) ? targetPrimaryKey[0] : targetPrimaryKey);
|
|
1538
|
+
|
|
1539
|
+
if (result[key] != null) {
|
|
1540
|
+
// Build connect where clause
|
|
1541
|
+
const connectWhere: Record<string, any> = { [foreignKey]: result[key] };
|
|
1542
|
+
|
|
1543
|
+
// Apply ACL access filter for connect
|
|
1544
|
+
const relatedAcl = acl.model[relatedObject.object];
|
|
1545
|
+
const accessFilter = user && relatedAcl?.getAccessFilter
|
|
1546
|
+
? this.cleanFilter(relatedAcl.getAccessFilter(user))
|
|
1547
|
+
: null;
|
|
1548
|
+
if (accessFilter && typeof accessFilter === 'object' && Object.keys(accessFilter).length > 0) {
|
|
1549
|
+
Object.assign(connectWhere, accessFilter);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
result[relatedObject.name] = { connect: connectWhere };
|
|
1553
|
+
} else {
|
|
1554
|
+
result[relatedObject.name] = { disconnect: true };
|
|
1555
|
+
}
|
|
1556
|
+
delete result[key];
|
|
1557
|
+
return result;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
/**
|
|
1561
|
+
* Process array relations for update operations
|
|
1562
|
+
*/
|
|
1563
|
+
#processArrayRelation(
|
|
1564
|
+
dataArray: Record<string, any>[],
|
|
1565
|
+
relatedObject: RelationConfig,
|
|
1566
|
+
parentId: string | number | null,
|
|
1567
|
+
user: any = null
|
|
1568
|
+
): Record<string, any> {
|
|
1569
|
+
const acl = getAcl();
|
|
1570
|
+
this.#ensureRelations(relatedObject);
|
|
1571
|
+
|
|
1572
|
+
for (let i = 0; i < dataArray.length; i++) {
|
|
1573
|
+
// Validate all fields exist on the related model
|
|
1574
|
+
for (const _key in dataArray[i]) {
|
|
1575
|
+
if (!this.#fieldExistsOnModel(relatedObject.object, _key)) {
|
|
1576
|
+
throw new ErrorResponse(400, "unexpected_key", { key: `${relatedObject.name}.${_key}` });
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// Process nested relations recursively if they exist
|
|
1581
|
+
if (relatedObject.relation) {
|
|
1582
|
+
dataArray[i] = this.#processNestedRelations(dataArray[i], relatedObject.relation, user);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
// Get primary key for the related model
|
|
1587
|
+
const relatedPrimaryKey = this.getPrimaryKey(relatedObject.object);
|
|
1588
|
+
const pkFields = Array.isArray(relatedPrimaryKey) ? relatedPrimaryKey : [relatedPrimaryKey];
|
|
1589
|
+
const foreignKey = relatedObject.foreignKey || pkFields[0];
|
|
1590
|
+
const isCompositePK = pkFields.length > 1;
|
|
1591
|
+
|
|
1592
|
+
// Get ACL filters for the related model
|
|
1593
|
+
const relatedAcl = acl.model[relatedObject.object];
|
|
1594
|
+
const accessFilter = user && relatedAcl?.getAccessFilter
|
|
1595
|
+
? this.cleanFilter(relatedAcl.getAccessFilter(user))
|
|
1596
|
+
: null;
|
|
1597
|
+
const updateFilter = user && relatedAcl?.getUpdateFilter
|
|
1598
|
+
? this.cleanFilter(relatedAcl.getUpdateFilter(user))
|
|
1599
|
+
: null;
|
|
1600
|
+
|
|
1601
|
+
// Get omit fields for create/update operations
|
|
1602
|
+
const omitFields = user && relatedAcl?.getOmitFields
|
|
1603
|
+
? relatedAcl.getOmitFields(user)
|
|
1604
|
+
: [];
|
|
1605
|
+
|
|
1606
|
+
// Helper to remove omitted fields from an object
|
|
1607
|
+
const removeOmitFields = (obj: Record<string, any>): Record<string, any> => {
|
|
1608
|
+
const cleaned: Record<string, any> = { ...obj };
|
|
1609
|
+
for (const field of omitFields) {
|
|
1610
|
+
delete cleaned[field];
|
|
1611
|
+
}
|
|
1612
|
+
return cleaned;
|
|
1613
|
+
};
|
|
1614
|
+
|
|
1615
|
+
// Helper to check if item has ALL PK fields
|
|
1616
|
+
const hasCompletePK = (item: Record<string, any>): boolean => pkFields.every((field: string) => item[field] != null);
|
|
1617
|
+
|
|
1618
|
+
// Helper to check if item has ONLY the PK/FK fields (for connect)
|
|
1619
|
+
// For n:m relations (composite FK), checks if only the join table FK fields are present
|
|
1620
|
+
const hasOnlyPkFields = (item: Record<string, any>): boolean => {
|
|
1621
|
+
const keys = Object.keys(item);
|
|
1622
|
+
|
|
1623
|
+
// For n:m relations with composite FK (e.g., StudentCourse with studentId, courseId)
|
|
1624
|
+
if (Array.isArray(relatedObject.fields)) {
|
|
1625
|
+
// Check if all keys are part of the composite FK fields
|
|
1626
|
+
// e.g., { courseId: 5 } should be connect, { courseId: 5, grade: 'A' } should be upsert
|
|
1627
|
+
return keys.every((k: string) => relatedObject.fields!.includes(k));
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
if (isCompositePK) {
|
|
1631
|
+
// For composite PK: all keys must be PK fields, and all PK fields must be present
|
|
1632
|
+
return keys.length === pkFields.length && keys.every((k: string) => pkFields.includes(k));
|
|
1633
|
+
}
|
|
1634
|
+
// For simple: only 1 key which is FK or PK
|
|
1635
|
+
return keys.length === 1 && (keys[0] === foreignKey || keys[0] === pkFields[0]);
|
|
1636
|
+
};
|
|
1637
|
+
|
|
1638
|
+
// Helper to merge ACL filter into where clause
|
|
1639
|
+
const mergeAclFilter = (where: Record<string, any>, aclFilter: any): Record<string, any> => {
|
|
1640
|
+
if (aclFilter && typeof aclFilter === 'object' && Object.keys(aclFilter).length > 0) {
|
|
1641
|
+
Object.assign(where, aclFilter);
|
|
1642
|
+
}
|
|
1643
|
+
return where;
|
|
1644
|
+
};
|
|
1645
|
+
|
|
1646
|
+
// Logic:
|
|
1647
|
+
// - connect: item has ONLY the PK/FK fields (just linking an existing record)
|
|
1648
|
+
// - upsert: item has PK AND additional data fields (update if exists, create if not)
|
|
1649
|
+
// - create: item has NO PK (always create new record)
|
|
1650
|
+
// For n:m relations: { courseId: 5 } -> connect, { courseId: 5, grade: 'A' } -> upsert
|
|
1651
|
+
const connectItems: Record<string, any>[] = [];
|
|
1652
|
+
const upsertItems: Record<string, any>[] = [];
|
|
1653
|
+
const createItems: Record<string, any>[] = [];
|
|
1654
|
+
|
|
1655
|
+
for (const item of dataArray) {
|
|
1656
|
+
if (hasOnlyPkFields(item)) {
|
|
1657
|
+
// Only PK/FK fields provided - connect to existing record
|
|
1658
|
+
connectItems.push(item);
|
|
1659
|
+
} else if (hasCompletePK(item) || Array.isArray(relatedObject.fields)) {
|
|
1660
|
+
// Has PK + data fields OR is n:m relation with extra data - upsert
|
|
1661
|
+
upsertItems.push(item);
|
|
1662
|
+
} else {
|
|
1663
|
+
// No PK - create new record
|
|
1664
|
+
createItems.push(item);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// Check canCreate permission for items that may create records
|
|
1669
|
+
const canCreate = !user || !relatedAcl?.canCreate || relatedAcl.canCreate(user);
|
|
1670
|
+
|
|
1671
|
+
// Check per-item for createItems (no PK = always creates)
|
|
1672
|
+
if (user && relatedAcl?.canCreate && createItems.length > 0) {
|
|
1673
|
+
for (const item of createItems) {
|
|
1674
|
+
if (!relatedAcl.canCreate(user, item)) {
|
|
1675
|
+
throw new ErrorResponse(403, "no_permission_to_create", { model: relatedObject.object });
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
const result: Record<string, any> = {};
|
|
1681
|
+
|
|
1682
|
+
// Build connect array with ACL access filter
|
|
1683
|
+
if (connectItems.length > 0) {
|
|
1684
|
+
result.connect = connectItems.map((e: Record<string, any>) => {
|
|
1685
|
+
const where: Record<string, any> = {};
|
|
1686
|
+
if (Array.isArray(relatedObject.fields)) {
|
|
1687
|
+
// n:m relation - build composite key where clause
|
|
1688
|
+
// e.g., { studentId_courseId: { studentId: parentId, courseId: e.courseId } }
|
|
1689
|
+
const pair_id: Record<string, any> = {};
|
|
1690
|
+
pair_id[relatedObject.fields[0]] = parentId;
|
|
1691
|
+
for (const field in e) {
|
|
1692
|
+
if (relatedObject.fields.includes(field)) {
|
|
1693
|
+
pair_id[field] = e[field];
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
where[relatedObject.field!] = pair_id;
|
|
1697
|
+
} else if (isCompositePK) {
|
|
1698
|
+
pkFields.forEach((field: string) => { where[field] = e[field]; });
|
|
1699
|
+
} else {
|
|
1700
|
+
where[foreignKey] = e[foreignKey] || e[pkFields[0]];
|
|
1701
|
+
}
|
|
1702
|
+
// Apply access filter - user must have access to connect to this record
|
|
1703
|
+
return mergeAclFilter(where, accessFilter);
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// Build upsert or update array based on canCreate permission
|
|
1708
|
+
if (upsertItems.length > 0) {
|
|
1709
|
+
const buildWhereClause = (e: Record<string, any>): Record<string, any> => {
|
|
1710
|
+
const where: Record<string, any> = {};
|
|
1711
|
+
if (Array.isArray(relatedObject.fields)) {
|
|
1712
|
+
// Composite key relation (n:m via join table)
|
|
1713
|
+
const pair_id: Record<string, any> = {};
|
|
1714
|
+
pair_id[relatedObject.fields[0]] = parentId;
|
|
1715
|
+
for (const field in e) {
|
|
1716
|
+
if (relatedObject.fields.includes(field)) {
|
|
1717
|
+
pair_id[field] = e[field];
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
where[relatedObject.field!] = pair_id;
|
|
1721
|
+
} else if (isCompositePK) {
|
|
1722
|
+
// Composite PK - all fields must be present
|
|
1723
|
+
pkFields.forEach((field: string) => { where[field] = e[field]; });
|
|
1724
|
+
} else {
|
|
1725
|
+
// Simple PK present
|
|
1726
|
+
where[pkFields[0]] = e[pkFields[0]];
|
|
1727
|
+
}
|
|
1728
|
+
// Apply update filter - user must have permission to update
|
|
1729
|
+
mergeAclFilter(where, updateFilter);
|
|
1730
|
+
return where;
|
|
1731
|
+
};
|
|
1732
|
+
|
|
1733
|
+
if (canCreate) {
|
|
1734
|
+
// User can create - use upsert (update if exists, create if not)
|
|
1735
|
+
result.upsert = upsertItems.map((e: Record<string, any>) => {
|
|
1736
|
+
const cleanedData = removeOmitFields(e);
|
|
1737
|
+
return {
|
|
1738
|
+
'where': buildWhereClause(e),
|
|
1739
|
+
'create': cleanedData,
|
|
1740
|
+
'update': cleanedData,
|
|
1741
|
+
};
|
|
1742
|
+
});
|
|
1743
|
+
} else {
|
|
1744
|
+
// User cannot create - use update only (fails if record doesn't exist)
|
|
1745
|
+
result.update = upsertItems.map((e: Record<string, any>) => {
|
|
1746
|
+
const cleanedData = removeOmitFields(e);
|
|
1747
|
+
return {
|
|
1748
|
+
'where': buildWhereClause(e),
|
|
1749
|
+
'data': cleanedData,
|
|
1750
|
+
};
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// Build create array for items without PK (only if canCreate is true)
|
|
1756
|
+
if (createItems.length > 0 && canCreate) {
|
|
1757
|
+
result.create = createItems.map((e: Record<string, any>) => removeOmitFields(e));
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
return result;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
/**
|
|
1764
|
+
* Process single relation for update operations with create/update separation
|
|
1765
|
+
*/
|
|
1766
|
+
#processSingleRelation(
|
|
1767
|
+
dataObj: Record<string, any>,
|
|
1768
|
+
relatedObject: RelationConfig,
|
|
1769
|
+
user: any = null
|
|
1770
|
+
): Record<string, any> | null {
|
|
1771
|
+
const acl = getAcl();
|
|
1772
|
+
|
|
1773
|
+
// Get ACL for the related model
|
|
1774
|
+
const relatedAcl = acl.model[relatedObject.object];
|
|
1775
|
+
|
|
1776
|
+
// Check canCreate permission since upsert may create new records
|
|
1777
|
+
if (user && relatedAcl?.canCreate && !relatedAcl.canCreate(user, dataObj)) {
|
|
1778
|
+
throw new ErrorResponse(403, "no_permission_to_create", { model: relatedObject.object });
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
// If dataObj is already a Prisma operation (connect, create, disconnect, etc.), skip field validation
|
|
1782
|
+
const prismaOps = ['connect', 'create', 'disconnect', 'set', 'update', 'upsert', 'deleteMany', 'updateMany', 'createMany'];
|
|
1783
|
+
if (prismaOps.some((op: string) => op in dataObj)) {
|
|
1784
|
+
return dataObj;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
// Get omit fields
|
|
1788
|
+
const omitFields = user && relatedAcl?.getOmitFields
|
|
1789
|
+
? relatedAcl.getOmitFields(user)
|
|
1790
|
+
: [];
|
|
1791
|
+
|
|
1792
|
+
// Remove omitted fields from input
|
|
1793
|
+
for (const field of omitFields) {
|
|
1794
|
+
delete dataObj[field];
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
// Validate all fields exist on the related model
|
|
1798
|
+
for (const _key in dataObj) {
|
|
1799
|
+
if (!this.#fieldExistsOnModel(relatedObject.object, _key)) {
|
|
1800
|
+
throw new ErrorResponse(400, "unexpected_key", { key: `${relatedObject.name}.${_key}` });
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// Ensure nested relations are resolved for deep processing
|
|
1805
|
+
this.#ensureRelations(relatedObject);
|
|
1806
|
+
|
|
1807
|
+
// Process nested relations recursively if they exist
|
|
1808
|
+
let processedData: Record<string, any> = dataObj;
|
|
1809
|
+
if (relatedObject.relation) {
|
|
1810
|
+
processedData = this.#processNestedRelations(dataObj, relatedObject.relation, user);
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
// Prepare separate data objects for create and update
|
|
1814
|
+
const createData: Record<string, any> = { ...processedData };
|
|
1815
|
+
const updateData: Record<string, any> = { ...processedData };
|
|
1816
|
+
let hasDisconnects = false;
|
|
1817
|
+
|
|
1818
|
+
// Process direct relations
|
|
1819
|
+
if (relatedObject.relation) {
|
|
1820
|
+
for (const relation_key in processedData) {
|
|
1821
|
+
const rel = relatedObject.relation.find((e: RelationConfig) => e.field === relation_key);
|
|
1822
|
+
if (rel) {
|
|
1823
|
+
if (processedData[relation_key] != null) {
|
|
1824
|
+
// Build connect where clause
|
|
1825
|
+
const targetPK = this.getPrimaryKey(rel.object);
|
|
1826
|
+
const connectKey = rel.foreignKey || (Array.isArray(targetPK) ? targetPK[0] : targetPK);
|
|
1827
|
+
const connectWhere: Record<string, any> = {
|
|
1828
|
+
[connectKey]: processedData[relation_key],
|
|
1829
|
+
};
|
|
1830
|
+
|
|
1831
|
+
// Apply ACL access filter for connect
|
|
1832
|
+
const childAcl = acl.model[rel.object];
|
|
1833
|
+
const childAccessFilter = user && childAcl?.getAccessFilter
|
|
1834
|
+
? this.cleanFilter(childAcl.getAccessFilter(user))
|
|
1835
|
+
: null;
|
|
1836
|
+
if (childAccessFilter && typeof childAccessFilter === 'object' && Object.keys(childAccessFilter).length > 0) {
|
|
1837
|
+
Object.assign(connectWhere, childAccessFilter);
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
const connectObj = { 'connect': connectWhere };
|
|
1841
|
+
createData[rel.name] = connectObj;
|
|
1842
|
+
updateData[rel.name] = connectObj;
|
|
1843
|
+
} else {
|
|
1844
|
+
// For update, use disconnect when value is null
|
|
1845
|
+
updateData[rel.name] = {
|
|
1846
|
+
'disconnect': true,
|
|
1847
|
+
};
|
|
1848
|
+
hasDisconnects = true;
|
|
1849
|
+
// For create, remove the relation entirely
|
|
1850
|
+
delete createData[rel.name];
|
|
1851
|
+
}
|
|
1852
|
+
// Remove the original field from both
|
|
1853
|
+
delete createData[relation_key];
|
|
1854
|
+
delete updateData[relation_key];
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// Check if we have meaningful content for create and update
|
|
1860
|
+
const hasCreateContent = this.#hasMeaningfulContent(createData);
|
|
1861
|
+
const hasUpdateContent = this.#hasMeaningfulContent(updateData) || hasDisconnects;
|
|
1862
|
+
|
|
1863
|
+
// Build upsert object conditionally
|
|
1864
|
+
const upsertObj: Record<string, any> = {};
|
|
1865
|
+
|
|
1866
|
+
if (hasCreateContent) {
|
|
1867
|
+
upsertObj.create = {
|
|
1868
|
+
...createData,
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
if (hasUpdateContent) {
|
|
1873
|
+
upsertObj.update = {
|
|
1874
|
+
...updateData,
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
// Only return upsert if we have at least one operation
|
|
1879
|
+
return Object.keys(upsertObj).length > 0 ? { 'upsert': upsertObj } : null;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
/**
|
|
1883
|
+
* Recursively process nested relations in data objects
|
|
1884
|
+
*/
|
|
1885
|
+
#processNestedRelations(
|
|
1886
|
+
dataObj: Record<string, any>,
|
|
1887
|
+
relatedObjects: RelationConfig[],
|
|
1888
|
+
user: any = null
|
|
1889
|
+
): Record<string, any> {
|
|
1890
|
+
const processedData: Record<string, any> = { ...dataObj };
|
|
1891
|
+
|
|
1892
|
+
for (const key in processedData) {
|
|
1893
|
+
const nestedRelation = relatedObjects.find((rel: RelationConfig) => rel.name === key);
|
|
1894
|
+
|
|
1895
|
+
if (nestedRelation && processedData[key] && typeof processedData[key] === 'object') {
|
|
1896
|
+
// Ensure deep relations are available for recursive processing
|
|
1897
|
+
this.#ensureRelations(nestedRelation);
|
|
1898
|
+
|
|
1899
|
+
if (Array.isArray(processedData[key])) {
|
|
1900
|
+
// Clone each item to avoid mutating originals
|
|
1901
|
+
processedData[key] = this.#processArrayRelation(
|
|
1902
|
+
processedData[key].map((item: Record<string, any>) => ({ ...item })), nestedRelation, null, user
|
|
1903
|
+
);
|
|
1904
|
+
} else {
|
|
1905
|
+
// Clone the nested object to avoid mutating original
|
|
1906
|
+
const nestedResult = this.#processSingleRelation(
|
|
1907
|
+
{ ...processedData[key] }, nestedRelation, user
|
|
1908
|
+
);
|
|
1909
|
+
if (nestedResult) {
|
|
1910
|
+
processedData[key] = nestedResult;
|
|
1911
|
+
} else {
|
|
1912
|
+
delete processedData[key];
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
return processedData;
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
/**
|
|
1922
|
+
* Check if data object contains meaningful content for database operations
|
|
1923
|
+
*/
|
|
1924
|
+
#hasMeaningfulContent(dataObj: Record<string, any>): boolean {
|
|
1925
|
+
return Object.keys(dataObj).length > 0 &&
|
|
1926
|
+
Object.keys(dataObj).some((key: string) => {
|
|
1927
|
+
const value = dataObj[key];
|
|
1928
|
+
if (value === null || value === undefined) return false;
|
|
1929
|
+
if (typeof value === 'object') {
|
|
1930
|
+
// For nested objects, check if they have meaningful operations
|
|
1931
|
+
return value.connect || value.disconnect || value.create || value.update || value.upsert;
|
|
1932
|
+
}
|
|
1933
|
+
return true;
|
|
1934
|
+
});
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
/**
|
|
1938
|
+
* Recursively clean filter object by removing undefined values and empty AND/OR arrays
|
|
1939
|
+
*/
|
|
1940
|
+
cleanFilter(filter: any): any {
|
|
1941
|
+
if (!filter || typeof filter !== 'object') {
|
|
1942
|
+
return filter === undefined ? null : filter;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
if (Array.isArray(filter)) {
|
|
1946
|
+
const cleaned = filter.map((item: any) => this.cleanFilter(item)).filter((item: any) => item !== null && item !== undefined);
|
|
1947
|
+
return cleaned.length > 0 ? cleaned : null;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
const cleaned: Record<string, any> = {};
|
|
1951
|
+
for (const key in filter) {
|
|
1952
|
+
const value = filter[key];
|
|
1953
|
+
|
|
1954
|
+
if (value === undefined) {
|
|
1955
|
+
continue; // Skip undefined values
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
if (value === null) {
|
|
1959
|
+
cleaned[key] = null;
|
|
1960
|
+
continue;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
if (typeof value === 'object') {
|
|
1964
|
+
const cleanedValue = this.cleanFilter(value);
|
|
1965
|
+
if (cleanedValue !== null && cleanedValue !== undefined) {
|
|
1966
|
+
// For AND/OR arrays, only add if they have items
|
|
1967
|
+
if ((key === 'AND' || key === 'OR') && Array.isArray(cleanedValue) && cleanedValue.length === 0) {
|
|
1968
|
+
continue;
|
|
1969
|
+
}
|
|
1970
|
+
cleaned[key] = cleanedValue;
|
|
1971
|
+
}
|
|
1972
|
+
} else {
|
|
1973
|
+
cleaned[key] = value;
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
// If cleaned filter only has one condition in an AND/OR, unwrap it
|
|
1978
|
+
if (Object.keys(cleaned).length === 1 && (cleaned.AND || cleaned.OR)) {
|
|
1979
|
+
const array = cleaned.AND || cleaned.OR;
|
|
1980
|
+
if (Array.isArray(array) && array.length === 1) {
|
|
1981
|
+
return array[0];
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
return Object.keys(cleaned).length > 0 ? cleaned : null;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
/**
|
|
1989
|
+
* Check if a relation is a list (array) relation using Prisma DMMF
|
|
1990
|
+
*/
|
|
1991
|
+
#isListRelation(parentModel: string, relationName: string): boolean {
|
|
1992
|
+
return dmmf.isListRelation(parentModel, relationName);
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
/**
|
|
1996
|
+
* Simplify nested filter by removing parent relation filters
|
|
1997
|
+
* When including appointments from student_tariff, remove {student_tariff: {...}} filters
|
|
1998
|
+
*/
|
|
1999
|
+
#simplifyNestedFilter(filter: any, parentModel: string): any {
|
|
2000
|
+
if (!filter || typeof filter !== 'object') {
|
|
2001
|
+
return filter;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
if (Array.isArray(filter)) {
|
|
2005
|
+
const simplified = filter.map((item: any) => this.#simplifyNestedFilter(item, parentModel)).filter((item: any) => item !== null);
|
|
2006
|
+
return simplified.length > 0 ? simplified : null;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
const simplified: Record<string, any> = {};
|
|
2010
|
+
for (const key in filter) {
|
|
2011
|
+
const value = filter[key];
|
|
2012
|
+
|
|
2013
|
+
// Skip filters that reference the parent model (we're already in that context)
|
|
2014
|
+
if (key === parentModel) {
|
|
2015
|
+
continue;
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
// Recursively process AND/OR arrays
|
|
2019
|
+
if (key === 'AND' || key === 'OR') {
|
|
2020
|
+
const simplifiedArray = this.#simplifyNestedFilter(value, parentModel);
|
|
2021
|
+
if (simplifiedArray && Array.isArray(simplifiedArray) && simplifiedArray.length > 0) {
|
|
2022
|
+
simplified[key] = simplifiedArray;
|
|
2023
|
+
}
|
|
2024
|
+
} else {
|
|
2025
|
+
simplified[key] = value;
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
// If simplified filter only has one condition in an AND/OR, unwrap it
|
|
2030
|
+
if (Object.keys(simplified).length === 1 && (simplified.AND || simplified.OR)) {
|
|
2031
|
+
const array = simplified.AND || simplified.OR;
|
|
2032
|
+
if (array.length === 1) {
|
|
2033
|
+
return array[0];
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
return Object.keys(simplified).length > 0 ? simplified : null;
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
/**
|
|
2041
|
+
* Get API result limit constant
|
|
2042
|
+
*/
|
|
2043
|
+
static get API_RESULT_LIMIT(): number {
|
|
2044
|
+
return API_RESULT_LIMIT;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
/**
|
|
2048
|
+
* Handle Prisma errors and convert to standardized error responses
|
|
2049
|
+
*/
|
|
2050
|
+
static errorHandler(error: any, data: Record<string, unknown> = {}): QueryErrorResponse {
|
|
2051
|
+
console.error(error);
|
|
2052
|
+
|
|
2053
|
+
// Default values
|
|
2054
|
+
let statusCode: number = error.status_code || 500;
|
|
2055
|
+
let message: string = error instanceof ErrorResponse
|
|
2056
|
+
? error.message
|
|
2057
|
+
: (process.env.NODE_ENV === 'production' ? 'Something went wrong' : (error.message || String(error)));
|
|
2058
|
+
|
|
2059
|
+
// Handle Prisma error codes
|
|
2060
|
+
if (error?.code && PRISMA_ERROR_MAP[error.code]) {
|
|
2061
|
+
const errorInfo = PRISMA_ERROR_MAP[error.code];
|
|
2062
|
+
statusCode = errorInfo.status;
|
|
2063
|
+
|
|
2064
|
+
// Handle dynamic messages (e.g., P2002 duplicate)
|
|
2065
|
+
if (error.code === 'P2002') {
|
|
2066
|
+
const target = error.meta?.target;
|
|
2067
|
+
const modelName = error.meta?.modelName;
|
|
2068
|
+
message = `Duplicate entry for ${modelName}. Record with ${target}: '${data[target as string]}' already exists`;
|
|
2069
|
+
} else {
|
|
2070
|
+
message = errorInfo.message!;
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
return { status_code: statusCode, message };
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
export { QueryBuilder, prisma, prismaTransaction, PRISMA_ERROR_MAP };
|