@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.
Files changed (59) hide show
  1. package/.dockerignore +71 -0
  2. package/.env.example +70 -0
  3. package/.gitignore +11 -0
  4. package/LICENSE +15 -0
  5. package/README.md +231 -0
  6. package/bin/cli.js +145 -0
  7. package/config/app.json +166 -0
  8. package/config/rate-limit.json +12 -0
  9. package/dist/main.js +26 -0
  10. package/dockerfile +57 -0
  11. package/locales/ar_SA.json +179 -0
  12. package/locales/de_DE.json +179 -0
  13. package/locales/en_US.json +180 -0
  14. package/locales/es_ES.json +179 -0
  15. package/locales/fr_FR.json +179 -0
  16. package/locales/it_IT.json +179 -0
  17. package/locales/ja_JP.json +179 -0
  18. package/locales/pt_BR.json +179 -0
  19. package/locales/ru_RU.json +179 -0
  20. package/locales/tr_TR.json +179 -0
  21. package/main.ts +25 -0
  22. package/package.json +126 -0
  23. package/prisma/schema.prisma +9 -0
  24. package/prisma.config.ts +12 -0
  25. package/public/static/favicon.ico +0 -0
  26. package/public/static/image/logo.png +0 -0
  27. package/routes/api/v1/index.ts +113 -0
  28. package/src/app.ts +197 -0
  29. package/src/auth/Auth.ts +446 -0
  30. package/src/auth/stores/ISessionStore.ts +19 -0
  31. package/src/auth/stores/MemoryStore.ts +70 -0
  32. package/src/auth/stores/RedisStore.ts +92 -0
  33. package/src/auth/stores/index.ts +149 -0
  34. package/src/config/acl.ts +9 -0
  35. package/src/config/rls.ts +38 -0
  36. package/src/core/dmmf.ts +226 -0
  37. package/src/core/env.ts +183 -0
  38. package/src/core/errors.ts +87 -0
  39. package/src/core/i18n.ts +144 -0
  40. package/src/core/middleware.ts +123 -0
  41. package/src/core/prisma.ts +236 -0
  42. package/src/index.ts +112 -0
  43. package/src/middleware/model.ts +61 -0
  44. package/src/orm/Model.ts +881 -0
  45. package/src/orm/QueryBuilder.ts +2078 -0
  46. package/src/plugins/auth.ts +162 -0
  47. package/src/plugins/language.ts +79 -0
  48. package/src/plugins/rateLimit.ts +210 -0
  49. package/src/plugins/response.ts +80 -0
  50. package/src/plugins/rls.ts +51 -0
  51. package/src/plugins/security.ts +23 -0
  52. package/src/plugins/upload.ts +299 -0
  53. package/src/types.ts +308 -0
  54. package/src/utils/ApiClient.ts +526 -0
  55. package/src/utils/Mailer.ts +348 -0
  56. package/src/utils/index.ts +25 -0
  57. package/templates/email/example.ejs +17 -0
  58. package/templates/layouts/email.ejs +35 -0
  59. 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 };