@airoom/nextmin-node 2.0.2 → 2.0.3
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/README.md +151 -0
- package/dist/api/apiRouter.d.ts +20 -0
- package/dist/api/apiRouter.js +122 -7
- package/dist/api/router/mountCrudRoutes.js +69 -18
- package/dist/api/router/setupAuthRoutes.js +475 -38
- package/dist/api/router/setupChatWidgetRoutes.d.ts +3 -0
- package/dist/api/router/setupChatWidgetRoutes.js +207 -0
- package/dist/api/router/setupFileRoutes.js +221 -16
- package/dist/api/router/utils.d.ts +2 -1
- package/dist/api/router/utils.js +8 -6
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +145 -64
- package/dist/database/DatabaseAdapter.d.ts +1 -0
- package/dist/database/NMAdapter.d.ts +6 -0
- package/dist/database/NMAdapter.js +437 -86
- package/dist/database/QueryEngine.js +7 -4
- package/dist/files/FileStorageAdapter.d.ts +1 -0
- package/dist/files/LocalFileStorageAdapter.d.ts +1 -0
- package/dist/files/LocalFileStorageAdapter.js +25 -3
- package/dist/files/S3FileStorageAdapter.d.ts +3 -1
- package/dist/files/S3FileStorageAdapter.js +72 -11
- package/dist/files/filename.js +6 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/models/BaseModel.d.ts +4 -0
- package/dist/policy/authorize.d.ts +1 -1
- package/dist/policy/authorize.js +64 -13
- package/dist/schemas/Users.json +20 -10
- package/dist/services/IndexingService.d.ts +24 -0
- package/dist/services/IndexingService.js +555 -0
- package/dist/services/LocalAIService.d.ts +34 -0
- package/dist/services/LocalAIService.js +471 -0
- package/dist/services/OpenAIService.d.ts +16 -0
- package/dist/services/OpenAIService.js +586 -0
- package/dist/utils/SchemaLoader.d.ts +1 -1
- package/dist/utils/SchemaLoader.js +40 -4
- package/package.json +12 -4
|
@@ -9,6 +9,7 @@ const mongodb_1 = require("mongodb");
|
|
|
9
9
|
const Logger_1 = __importDefault(require("../utils/Logger"));
|
|
10
10
|
const utils_1 = require("../api/router/utils");
|
|
11
11
|
const utils_2 = require("./utils");
|
|
12
|
+
const QueryCache_1 = require("../utils/QueryCache");
|
|
12
13
|
/**
|
|
13
14
|
* NMAdapter implements the DatabaseAdapter interface.
|
|
14
15
|
* It supports SQL databases (PostgreSQL, MySQL, MariaDB, SQLite, MSSQL) and MongoDB.
|
|
@@ -20,6 +21,7 @@ class NMAdapter {
|
|
|
20
21
|
this.entitySchemas = new Map();
|
|
21
22
|
this.registeredSchemas = {};
|
|
22
23
|
this.imageProxyOptions = {};
|
|
24
|
+
this.queryCache = new QueryCache_1.QueryCache();
|
|
23
25
|
const isValidUrl = (url) => typeof url === 'string' && url.startsWith('http');
|
|
24
26
|
let imageProxy = options.imageProxy;
|
|
25
27
|
if (!imageProxy || Object.keys(imageProxy).length === 0) {
|
|
@@ -107,8 +109,10 @@ class NMAdapter {
|
|
|
107
109
|
// 3) Sync Indexes
|
|
108
110
|
for (const [name, def] of Object.entries(schemas)) {
|
|
109
111
|
const indexes = {};
|
|
110
|
-
for (const [key, attr] of Object.entries(def.attributes)) {
|
|
111
|
-
|
|
112
|
+
for (const [key, attr] of Object.entries(def.attributes || {})) {
|
|
113
|
+
const a = Array.isArray(attr) ? attr[0] : attr;
|
|
114
|
+
const isRef = !!a?.ref || !!a?.refPath;
|
|
115
|
+
if (attr?.index === true || isRef || key === 'name' || key === 'title' || key === 'slug' || key === 'createdAt') {
|
|
112
116
|
indexes[key] = 1;
|
|
113
117
|
}
|
|
114
118
|
}
|
|
@@ -116,7 +120,9 @@ class NMAdapter {
|
|
|
116
120
|
await this.syncIndexes(name, indexes);
|
|
117
121
|
}
|
|
118
122
|
}
|
|
119
|
-
// 4)
|
|
123
|
+
// 4) Validate refPath configurations
|
|
124
|
+
this.validateRefPathSchemas();
|
|
125
|
+
// 5) Cache repositories
|
|
120
126
|
for (const name of Object.keys(schemas)) {
|
|
121
127
|
const lcName = name.toLowerCase();
|
|
122
128
|
const schema = this.entitySchemas.get(lcName);
|
|
@@ -125,6 +131,44 @@ class NMAdapter {
|
|
|
125
131
|
}
|
|
126
132
|
}
|
|
127
133
|
}
|
|
134
|
+
validateRefPathSchemas() {
|
|
135
|
+
for (const [schemaName, schema] of Object.entries(this.registeredSchemas)) {
|
|
136
|
+
if (!schema?.attributes)
|
|
137
|
+
continue;
|
|
138
|
+
for (const [attrName, attr] of Object.entries(schema.attributes)) {
|
|
139
|
+
const a = Array.isArray(attr) ? attr[0] : attr;
|
|
140
|
+
if (!a?.refPath)
|
|
141
|
+
continue;
|
|
142
|
+
const refPathField = String(a.refPath);
|
|
143
|
+
// 1) Warning if both 'ref' and 'refPath' are defined
|
|
144
|
+
if (a.ref) {
|
|
145
|
+
Logger_1.default.warn('SchemaValidation', `Model "${schema.modelName}": Attribute "${attrName}" defines both "ref" ("${a.ref}") and "refPath" ("${refPathField}"). "refPath" takes precedence and "ref" will be ignored.`);
|
|
146
|
+
}
|
|
147
|
+
// 2) Warning if referenced refPath field does not exist in the same schema
|
|
148
|
+
const targetAttr = schema.attributes[refPathField];
|
|
149
|
+
if (!targetAttr) {
|
|
150
|
+
Logger_1.default.warn('SchemaValidation', `Model "${schema.modelName}": Attribute "${attrName}" specifies refPath "${refPathField}", but field "${refPathField}" does not exist in schema "${schema.modelName}".`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// 3) Warning if target field is not an enum field with string items
|
|
154
|
+
const targetA = Array.isArray(targetAttr) ? targetAttr[0] : targetAttr;
|
|
155
|
+
const enumList = targetA?.enum;
|
|
156
|
+
if (!Array.isArray(enumList) || enumList.length === 0) {
|
|
157
|
+
Logger_1.default.warn('SchemaValidation', `Model "${schema.modelName}": Attribute "${attrName}" refPath "${refPathField}" references field "${refPathField}", which must be a string attribute with an "enum" array of target model names.`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
// 4) Warning for any enum value that is not a registered model
|
|
161
|
+
for (const enumVal of enumList) {
|
|
162
|
+
if (typeof enumVal !== 'string')
|
|
163
|
+
continue;
|
|
164
|
+
const exists = Object.values(this.registeredSchemas).some(s => s.modelName.toLowerCase() === enumVal.trim().toLowerCase());
|
|
165
|
+
if (!exists) {
|
|
166
|
+
Logger_1.default.warn('SchemaValidation', `Model "${schema.modelName}": Attribute "${attrName}" refPath "${refPathField}" enum contains "${enumVal}", but schema "${enumVal}" is not a registered model.`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
128
172
|
async syncIndexes(modelName, spec) {
|
|
129
173
|
const repo = this.repositories.get(modelName.toLowerCase());
|
|
130
174
|
if (!repo)
|
|
@@ -150,6 +194,8 @@ class NMAdapter {
|
|
|
150
194
|
}
|
|
151
195
|
buildEntitySchema(def) {
|
|
152
196
|
const isMongo = this.dataSource.options.type === 'mongodb';
|
|
197
|
+
const isPostgres = this.dataSource.options.type === 'postgres';
|
|
198
|
+
const dateTimeType = isPostgres ? 'timestamp' : 'datetime';
|
|
153
199
|
const columns = {
|
|
154
200
|
id: {
|
|
155
201
|
primary: true,
|
|
@@ -161,13 +207,13 @@ class NMAdapter {
|
|
|
161
207
|
generated: isMongo ? true : 'increment',
|
|
162
208
|
},
|
|
163
209
|
createdAt: {
|
|
164
|
-
type:
|
|
210
|
+
type: dateTimeType,
|
|
165
211
|
createDate: true,
|
|
166
212
|
default: () => 'CURRENT_TIMESTAMP',
|
|
167
213
|
nullable: true,
|
|
168
214
|
},
|
|
169
215
|
updatedAt: {
|
|
170
|
-
type:
|
|
216
|
+
type: dateTimeType,
|
|
171
217
|
updateDate: true,
|
|
172
218
|
default: () => 'CURRENT_TIMESTAMP',
|
|
173
219
|
nullable: true,
|
|
@@ -216,7 +262,7 @@ class NMAdapter {
|
|
|
216
262
|
sparse: true,
|
|
217
263
|
});
|
|
218
264
|
}
|
|
219
|
-
if (typeORMType === 'datetime' && attr.default === 'now') {
|
|
265
|
+
if ((typeORMType === 'datetime' || typeORMType === 'timestamp') && attr.default === 'now') {
|
|
220
266
|
columns[key].default = () => 'CURRENT_TIMESTAMP';
|
|
221
267
|
}
|
|
222
268
|
}
|
|
@@ -232,12 +278,13 @@ class NMAdapter {
|
|
|
232
278
|
return 'simple-json';
|
|
233
279
|
}
|
|
234
280
|
const type = String(attr?.type || 'string').toLowerCase();
|
|
281
|
+
const isPostgres = this.dataSource.options.type === 'postgres';
|
|
235
282
|
switch (type) {
|
|
236
283
|
case 'string': return 'varchar';
|
|
237
284
|
case 'number': return 'float';
|
|
238
285
|
case 'boolean': return 'boolean';
|
|
239
286
|
case 'date':
|
|
240
|
-
case 'datetime': return 'datetime';
|
|
287
|
+
case 'datetime': return isPostgres ? 'timestamp' : 'datetime';
|
|
241
288
|
case 'json':
|
|
242
289
|
case 'object': return 'json';
|
|
243
290
|
case 'array': return 'simple-json';
|
|
@@ -253,9 +300,120 @@ class NMAdapter {
|
|
|
253
300
|
}
|
|
254
301
|
return repo;
|
|
255
302
|
}
|
|
303
|
+
getActiveBaseUrl() {
|
|
304
|
+
if (this.options.imageProxy && this.options.imageProxy.proxyBase) {
|
|
305
|
+
return this.options.imageProxy.proxyBase.replace(/\/+$/, '');
|
|
306
|
+
}
|
|
307
|
+
if (this.imageProxyOptions.originalBase) {
|
|
308
|
+
return this.imageProxyOptions.originalBase.replace(/\/+$/, '');
|
|
309
|
+
}
|
|
310
|
+
if (this.imageProxyOptions.proxyBase) {
|
|
311
|
+
return this.imageProxyOptions.proxyBase.replace(/\/+$/, '');
|
|
312
|
+
}
|
|
313
|
+
if (process.env.S3_PUBLIC_BASE_URL) {
|
|
314
|
+
return process.env.S3_PUBLIC_BASE_URL.replace(/\/+$/, '');
|
|
315
|
+
}
|
|
316
|
+
return '';
|
|
317
|
+
}
|
|
318
|
+
cleanUrlForStorage(val) {
|
|
319
|
+
if (typeof val !== 'string')
|
|
320
|
+
return val;
|
|
321
|
+
// If string has spaces or HTML tags, it is not a pure image URL/key
|
|
322
|
+
if (val.includes(' ') || val.includes('<') || val.includes('>')) {
|
|
323
|
+
return val;
|
|
324
|
+
}
|
|
325
|
+
const basesToStrip = [
|
|
326
|
+
this.imageProxyOptions.originalBase,
|
|
327
|
+
this.imageProxyOptions.proxyBase,
|
|
328
|
+
process.env.S3_PUBLIC_BASE_URL,
|
|
329
|
+
this.options.s3?.publicBaseUrl
|
|
330
|
+
].filter((b) => typeof b === 'string' && b.length > 0);
|
|
331
|
+
let cleaned = val;
|
|
332
|
+
for (const base of basesToStrip) {
|
|
333
|
+
const cleanBase = base.replace(/\/+$/, '');
|
|
334
|
+
if (cleaned.startsWith(cleanBase)) {
|
|
335
|
+
cleaned = cleaned.substring(cleanBase.length);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const uploadsIdx = cleaned.indexOf('uploads/');
|
|
339
|
+
if (uploadsIdx !== -1) {
|
|
340
|
+
cleaned = cleaned.substring(uploadsIdx);
|
|
341
|
+
}
|
|
342
|
+
return cleaned.replace(/^\/+/, '');
|
|
343
|
+
}
|
|
344
|
+
cleanPayloadForStorage(obj) {
|
|
345
|
+
if (!obj || typeof obj !== 'object')
|
|
346
|
+
return obj;
|
|
347
|
+
if (obj instanceof Date || Buffer.isBuffer(obj)) {
|
|
348
|
+
return obj;
|
|
349
|
+
}
|
|
350
|
+
if (Array.isArray(obj)) {
|
|
351
|
+
return obj.map(item => this.cleanPayloadForStorage(item));
|
|
352
|
+
}
|
|
353
|
+
const cleaned = {};
|
|
354
|
+
for (const key in obj) {
|
|
355
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
356
|
+
const val = obj[key];
|
|
357
|
+
if (typeof val === 'string') {
|
|
358
|
+
cleaned[key] = this.cleanUrlForStorage(val);
|
|
359
|
+
}
|
|
360
|
+
else if (val && typeof val === 'object') {
|
|
361
|
+
cleaned[key] = this.cleanPayloadForStorage(val);
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
cleaned[key] = val;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return cleaned;
|
|
369
|
+
}
|
|
370
|
+
formatUrlForRead(val) {
|
|
371
|
+
if (typeof val !== 'string')
|
|
372
|
+
return val;
|
|
373
|
+
// If string has spaces or HTML tags, it is not a pure image URL/key
|
|
374
|
+
if (val.includes(' ') || val.includes('<') || val.includes('>')) {
|
|
375
|
+
return val;
|
|
376
|
+
}
|
|
377
|
+
let cleaned = val;
|
|
378
|
+
let matched = false;
|
|
379
|
+
const uploadsIdx = cleaned.indexOf('uploads/');
|
|
380
|
+
if (uploadsIdx !== -1) {
|
|
381
|
+
cleaned = cleaned.substring(uploadsIdx);
|
|
382
|
+
matched = true;
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
// Also check if we can strip known bases
|
|
386
|
+
const basesToStrip = [
|
|
387
|
+
this.imageProxyOptions.originalBase,
|
|
388
|
+
this.imageProxyOptions.proxyBase,
|
|
389
|
+
process.env.S3_PUBLIC_BASE_URL,
|
|
390
|
+
this.options.s3?.publicBaseUrl
|
|
391
|
+
].filter((b) => typeof b === 'string' && b.length > 0);
|
|
392
|
+
for (const base of basesToStrip) {
|
|
393
|
+
const cleanBase = base.replace(/\/+$/, '');
|
|
394
|
+
if (cleaned.startsWith(cleanBase)) {
|
|
395
|
+
cleaned = cleaned.substring(cleanBase.length);
|
|
396
|
+
matched = true;
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
cleaned = cleaned.replace(/^\/+/, '');
|
|
402
|
+
// If it was matched as a file URL/key, or if it is a clean storage path
|
|
403
|
+
if (matched || cleaned.startsWith('uploads/')) {
|
|
404
|
+
const activeBase = this.getActiveBaseUrl();
|
|
405
|
+
if (activeBase) {
|
|
406
|
+
return `${activeBase}/${cleaned}`;
|
|
407
|
+
}
|
|
408
|
+
return '/' + cleaned;
|
|
409
|
+
}
|
|
410
|
+
return val;
|
|
411
|
+
}
|
|
256
412
|
async create(collection, data, schemaDefinition) {
|
|
413
|
+
await this.queryCache.invalidateCollection(collection);
|
|
257
414
|
const repo = this.getRepository(collection);
|
|
258
|
-
const
|
|
415
|
+
const cleanedData = this.cleanPayloadForStorage(data);
|
|
416
|
+
const coerced = this.coercePayloadForStorage(cleanedData, schemaDefinition);
|
|
259
417
|
// Ensure timestamps are set if not provided (especially for MongoDB)
|
|
260
418
|
const now = new Date();
|
|
261
419
|
if (!coerced.createdAt)
|
|
@@ -266,38 +424,81 @@ class NMAdapter {
|
|
|
266
424
|
return this.prepareResult(created);
|
|
267
425
|
}
|
|
268
426
|
coercePayloadForStorage(payload, schema) {
|
|
269
|
-
if (!payload || !schema
|
|
427
|
+
if (!payload || !schema)
|
|
270
428
|
return payload;
|
|
429
|
+
const isMongo = this.dataSource.options.type === 'mongodb';
|
|
271
430
|
const coerced = { ...payload };
|
|
272
431
|
for (const [key, attr] of Object.entries(schema.attributes)) {
|
|
273
432
|
const a = Array.isArray(attr) ? attr[0] : attr;
|
|
274
433
|
const isObjectIdField = String(a?.type || '').toLowerCase() === 'objectid';
|
|
275
|
-
//
|
|
276
|
-
if (coerced[key] === null) {
|
|
277
|
-
delete coerced[key];
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
434
|
+
// Flatten nested objects/relations to their IDs
|
|
280
435
|
if (isObjectIdField && coerced[key] != null) {
|
|
281
436
|
if (Array.isArray(coerced[key])) {
|
|
282
|
-
coerced[key] = coerced[key].map((v) =>
|
|
437
|
+
coerced[key] = coerced[key].map((v) => {
|
|
438
|
+
if (v && typeof v === 'object') {
|
|
439
|
+
return v.id || v._id || v;
|
|
440
|
+
}
|
|
441
|
+
return v;
|
|
442
|
+
});
|
|
283
443
|
}
|
|
284
|
-
else {
|
|
285
|
-
coerced[key] =
|
|
444
|
+
else if (coerced[key] && typeof coerced[key] === 'object') {
|
|
445
|
+
coerced[key] = coerced[key].id || coerced[key]._id || coerced[key];
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (isMongo) {
|
|
449
|
+
// For MongoDB, we remove null values to satisfy sparse indexes
|
|
450
|
+
if (coerced[key] === null) {
|
|
451
|
+
delete coerced[key];
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (isObjectIdField && coerced[key] != null) {
|
|
455
|
+
if (Array.isArray(coerced[key])) {
|
|
456
|
+
coerced[key] = coerced[key].map((v) => this.convertToObjectId(v));
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
coerced[key] = this.convertToObjectId(coerced[key]);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
// For SQL databases: single-value ObjectId relations are stored via simple-json.
|
|
465
|
+
// Coerce string numeric IDs to actual numbers so JSON.stringify produces '1' (not '"1"'),
|
|
466
|
+
// preventing the double-quote storage bug ("\"1\"" in the DB).
|
|
467
|
+
if (isObjectIdField && Array.isArray(attr) && !a?.multiple && coerced[key] != null) {
|
|
468
|
+
const val = coerced[key];
|
|
469
|
+
if (typeof val === 'string') {
|
|
470
|
+
const n = Number(val);
|
|
471
|
+
if (!isNaN(n) && val.trim() !== '') {
|
|
472
|
+
coerced[key] = n;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
286
475
|
}
|
|
287
476
|
}
|
|
288
477
|
}
|
|
289
478
|
return coerced;
|
|
290
479
|
}
|
|
291
480
|
async read(collection, query, limit, skip, schemaDefinition, includePrivateFields, options) {
|
|
481
|
+
const cacheKey = await this.queryCache.generateKey(collection, 'read', {
|
|
482
|
+
query, limit, skip, schema: schemaDefinition?.modelName, includePrivateFields, options
|
|
483
|
+
});
|
|
484
|
+
const cached = await this.queryCache.get(cacheKey);
|
|
485
|
+
if (cached) {
|
|
486
|
+
return cached;
|
|
487
|
+
}
|
|
292
488
|
const repo = this.getRepository(collection);
|
|
293
489
|
const isMongo = this.dataSource.options.type === 'mongodb';
|
|
294
490
|
const resolvedQuery = await this.resolveNestedSearches(query);
|
|
295
491
|
const where = this.transformQuery(resolvedQuery, schemaDefinition);
|
|
492
|
+
let order = options?.sort;
|
|
493
|
+
if (order && !isMongo) {
|
|
494
|
+
order = { ...order };
|
|
495
|
+
delete order._id;
|
|
496
|
+
}
|
|
296
497
|
const findOptions = {
|
|
297
498
|
where,
|
|
298
499
|
take: limit,
|
|
299
500
|
skip: skip,
|
|
300
|
-
order
|
|
501
|
+
order,
|
|
301
502
|
};
|
|
302
503
|
if (options?.projection) {
|
|
303
504
|
const hasRootProj = this.isExclusiveProjection(options.projection, schemaDefinition);
|
|
@@ -388,6 +589,7 @@ class NMAdapter {
|
|
|
388
589
|
if (schemaDefinition) {
|
|
389
590
|
await this.hydrateRelations(mappedResults, schemaDefinition, options?.depth ?? 0, options);
|
|
390
591
|
}
|
|
592
|
+
await this.queryCache.set(cacheKey, mappedResults);
|
|
391
593
|
return mappedResults;
|
|
392
594
|
}
|
|
393
595
|
prepareResult(item) {
|
|
@@ -405,37 +607,32 @@ class NMAdapter {
|
|
|
405
607
|
if (item.id != null)
|
|
406
608
|
item.id = String(item.id);
|
|
407
609
|
}
|
|
408
|
-
// Apply image URL proxying/transformation
|
|
409
|
-
|
|
410
|
-
this.transformUrls(item);
|
|
411
|
-
}
|
|
610
|
+
// Apply image URL proxying/transformation
|
|
611
|
+
this.transformUrls(item);
|
|
412
612
|
return item;
|
|
413
613
|
}
|
|
414
614
|
transformUrls(obj) {
|
|
415
615
|
if (!obj || typeof obj !== 'object')
|
|
416
616
|
return;
|
|
417
|
-
const { originalBase, proxyBase } = this.imageProxyOptions;
|
|
418
|
-
if (!originalBase || !proxyBase)
|
|
419
|
-
return;
|
|
420
617
|
for (const key in obj) {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
if (val
|
|
424
|
-
obj[key] =
|
|
618
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
619
|
+
const val = obj[key];
|
|
620
|
+
if (typeof val === 'string') {
|
|
621
|
+
obj[key] = this.formatUrlForRead(val);
|
|
425
622
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
623
|
+
else if (Array.isArray(val)) {
|
|
624
|
+
for (let i = 0; i < val.length; i++) {
|
|
625
|
+
if (typeof val[i] === 'string') {
|
|
626
|
+
val[i] = this.formatUrlForRead(val[i]);
|
|
627
|
+
}
|
|
628
|
+
else if (val[i] && typeof val[i] === 'object') {
|
|
629
|
+
this.transformUrls(val[i]);
|
|
630
|
+
}
|
|
434
631
|
}
|
|
435
632
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
633
|
+
else if (val && typeof val === 'object') {
|
|
634
|
+
this.transformUrls(val);
|
|
635
|
+
}
|
|
439
636
|
}
|
|
440
637
|
}
|
|
441
638
|
}
|
|
@@ -662,8 +859,31 @@ class NMAdapter {
|
|
|
662
859
|
if (key === 'baseId')
|
|
663
860
|
return false;
|
|
664
861
|
const a = Array.isArray(attr) ? attr[0] : attr;
|
|
665
|
-
|
|
862
|
+
const t = String(a?.type || '').toLowerCase();
|
|
863
|
+
return (t === 'objectid' || t === 'ref') && (a?.ref || a?.refPath);
|
|
666
864
|
});
|
|
865
|
+
const isHydratedObject = (val) => {
|
|
866
|
+
if (val == null)
|
|
867
|
+
return false;
|
|
868
|
+
if (typeof val !== 'object')
|
|
869
|
+
return false;
|
|
870
|
+
if (Array.isArray(val))
|
|
871
|
+
return false;
|
|
872
|
+
if (val instanceof mongodb_1.ObjectId)
|
|
873
|
+
return false;
|
|
874
|
+
if (typeof val.toHexString === 'function')
|
|
875
|
+
return false;
|
|
876
|
+
if (val.constructor && val.constructor.name === 'ObjectId')
|
|
877
|
+
return false;
|
|
878
|
+
if (val instanceof Date)
|
|
879
|
+
return false;
|
|
880
|
+
if (Buffer.isBuffer(val))
|
|
881
|
+
return false;
|
|
882
|
+
const keys = Object.keys(val);
|
|
883
|
+
const hasId = keys.includes('id') || keys.includes('_id');
|
|
884
|
+
const hasOtherKeys = keys.some(k => k !== 'id' && k !== '_id');
|
|
885
|
+
return hasId && hasOtherKeys;
|
|
886
|
+
};
|
|
667
887
|
for (const [key, attr] of relFields) {
|
|
668
888
|
hydrationPromises.push((async () => {
|
|
669
889
|
const hasRootProj = this.isExclusiveProjection(projection, schema);
|
|
@@ -671,27 +891,87 @@ class NMAdapter {
|
|
|
671
891
|
if (projection && hasRootProj && !isTargeted)
|
|
672
892
|
return;
|
|
673
893
|
const isArray = Array.isArray(attr);
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
894
|
+
const attrObj = isArray ? attr[0] : attr;
|
|
895
|
+
const refPathKey = attrObj.refPath ? String(attrObj.refPath) : undefined;
|
|
896
|
+
const staticRefModel = attrObj.ref ? String(attrObj.ref) : undefined;
|
|
897
|
+
// Group items by resolved target model name
|
|
898
|
+
const itemsByModel = new Map();
|
|
899
|
+
const resolveModelName = (raw) => {
|
|
900
|
+
// Strictly accept only string enum values (no objects or unknown types)
|
|
901
|
+
if (typeof raw !== 'string' || !raw.trim())
|
|
902
|
+
return null;
|
|
903
|
+
const nameStr = raw.trim();
|
|
904
|
+
// Verify that refPathKey refers to an existing field in the schema
|
|
905
|
+
const refPathAttr = schema.attributes[refPathKey];
|
|
906
|
+
if (!refPathAttr)
|
|
907
|
+
return null;
|
|
908
|
+
const targetA = Array.isArray(refPathAttr) ? refPathAttr[0] : refPathAttr;
|
|
909
|
+
const allowedEnums = targetA?.enum;
|
|
910
|
+
// Verify that value is listed in the refPath field's enum array
|
|
911
|
+
if (Array.isArray(allowedEnums) && allowedEnums.length > 0) {
|
|
912
|
+
const isAllowed = allowedEnums.some(e => typeof e === 'string' && e.trim().toLowerCase() === nameStr.toLowerCase());
|
|
913
|
+
if (!isAllowed)
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
// Match case-insensitively against registered schemas
|
|
917
|
+
const targetSchema = Object.values(this.registeredSchemas).find(s => s.modelName.toLowerCase() === nameStr.toLowerCase());
|
|
918
|
+
return targetSchema ? targetSchema.modelName : null;
|
|
919
|
+
};
|
|
682
920
|
for (const item of results) {
|
|
683
|
-
|
|
921
|
+
let val = item[key];
|
|
684
922
|
if (val == null)
|
|
685
923
|
continue;
|
|
924
|
+
// Determine target model for this specific item
|
|
925
|
+
const targetModel = refPathKey ? resolveModelName(item[refPathKey]) : staticRefModel;
|
|
926
|
+
if (!targetModel)
|
|
927
|
+
continue;
|
|
928
|
+
// Gracefully parse JSON-stringified relation data if stored in DB (as object or array)
|
|
929
|
+
if (typeof val === 'string') {
|
|
930
|
+
const trimmed = val.trim();
|
|
931
|
+
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
|
932
|
+
try {
|
|
933
|
+
const parsed = JSON.parse(trimmed);
|
|
934
|
+
if (parsed != null) {
|
|
935
|
+
item[key] = parsed;
|
|
936
|
+
val = parsed;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
catch (e) { }
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
if (!itemsByModel.has(targetModel)) {
|
|
943
|
+
itemsByModel.set(targetModel, []);
|
|
944
|
+
}
|
|
945
|
+
const group = itemsByModel.get(targetModel);
|
|
686
946
|
if (isArray && Array.isArray(val)) {
|
|
687
|
-
val.
|
|
688
|
-
|
|
947
|
+
item[key] = val.map(v => {
|
|
948
|
+
if (typeof v === 'string') {
|
|
949
|
+
const trimmed = v.trim();
|
|
950
|
+
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
|
951
|
+
try {
|
|
952
|
+
const parsed = JSON.parse(trimmed);
|
|
953
|
+
if (parsed != null)
|
|
954
|
+
return parsed;
|
|
955
|
+
}
|
|
956
|
+
catch (e) { }
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
return v;
|
|
960
|
+
});
|
|
961
|
+
const mappedVal = item[key];
|
|
962
|
+
mappedVal.forEach(v => {
|
|
963
|
+
if (v != null && !isHydratedObject(v)) {
|
|
964
|
+
group.push({ item, idVal: v });
|
|
965
|
+
}
|
|
966
|
+
});
|
|
689
967
|
}
|
|
690
968
|
else {
|
|
691
|
-
|
|
969
|
+
if (!isHydratedObject(val)) {
|
|
970
|
+
group.push({ item, idVal: val });
|
|
971
|
+
}
|
|
692
972
|
}
|
|
693
973
|
}
|
|
694
|
-
if (
|
|
974
|
+
if (itemsByModel.size === 0)
|
|
695
975
|
return;
|
|
696
976
|
let subProjection = options?._populate?.[key];
|
|
697
977
|
if (subProjection) {
|
|
@@ -707,25 +987,44 @@ class NMAdapter {
|
|
|
707
987
|
}
|
|
708
988
|
}
|
|
709
989
|
}
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
const docMap = new Map();
|
|
715
|
-
fetchedDocs.forEach(d => {
|
|
716
|
-
const sid = this.idToString(d.id);
|
|
717
|
-
if (sid)
|
|
718
|
-
docMap.set(sid, d);
|
|
719
|
-
});
|
|
720
|
-
for (const item of results) {
|
|
721
|
-
const val = item[key];
|
|
722
|
-
if (val == null)
|
|
990
|
+
for (const [targetModelName, entries] of itemsByModel.entries()) {
|
|
991
|
+
const refLc = targetModelName.toLowerCase();
|
|
992
|
+
const refRepo = this.repositories.get(refLc);
|
|
993
|
+
if (!refRepo)
|
|
723
994
|
continue;
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
995
|
+
const idsToFetch = new Set();
|
|
996
|
+
entries.forEach(e => idsToFetch.add(e.idVal));
|
|
997
|
+
if (idsToFetch.size === 0)
|
|
998
|
+
continue;
|
|
999
|
+
const fetchedDocs = await this.read(targetModelName, { id: { $in: Array.from(idsToFetch) } }, idsToFetch.size, 0, this.registeredSchemas[targetModelName], true, {
|
|
1000
|
+
depth: depth + 1,
|
|
1001
|
+
projection: subProjection
|
|
1002
|
+
});
|
|
1003
|
+
const docMap = new Map();
|
|
1004
|
+
fetchedDocs.forEach(d => {
|
|
1005
|
+
const sid = this.idToString(d.id);
|
|
1006
|
+
if (sid)
|
|
1007
|
+
docMap.set(sid, d);
|
|
1008
|
+
});
|
|
1009
|
+
for (const { item } of entries) {
|
|
1010
|
+
const val = item[key];
|
|
1011
|
+
if (val == null)
|
|
1012
|
+
continue;
|
|
1013
|
+
if (Array.isArray(val)) {
|
|
1014
|
+
item[key] = val.map(v => {
|
|
1015
|
+
if (isHydratedObject(v))
|
|
1016
|
+
return v;
|
|
1017
|
+
return docMap.get(this.idToString(v)) || v;
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
else {
|
|
1021
|
+
if (isHydratedObject(val)) {
|
|
1022
|
+
// Keep as-is
|
|
1023
|
+
}
|
|
1024
|
+
else {
|
|
1025
|
+
item[key] = docMap.get(this.idToString(val)) || val;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
729
1028
|
}
|
|
730
1029
|
}
|
|
731
1030
|
})());
|
|
@@ -767,6 +1066,7 @@ class NMAdapter {
|
|
|
767
1066
|
await Promise.all(hydrationPromises);
|
|
768
1067
|
}
|
|
769
1068
|
async update(collection, targetId, data, schemaDefinition) {
|
|
1069
|
+
await this.queryCache.invalidateCollection(collection);
|
|
770
1070
|
if (targetId == null || targetId === '' || targetId === 'undefined' || targetId === 'null') {
|
|
771
1071
|
throw new Error(`[NMAdapter] Cannot perform update on '${collection}': targetId is required and cannot be null/undefined. This guard prevents accidental bulk updates.`);
|
|
772
1072
|
}
|
|
@@ -775,9 +1075,10 @@ class NMAdapter {
|
|
|
775
1075
|
throw new Error(`Repository for ${collection} not found.`);
|
|
776
1076
|
const isMongo = this.dataSource.options.type === 'mongodb';
|
|
777
1077
|
const resolvedTargetId = isMongo ? this.convertToObjectId(targetId) : targetId;
|
|
778
|
-
const
|
|
1078
|
+
const cleanedData = this.cleanPayloadForStorage(data);
|
|
1079
|
+
const coerced = this.coercePayloadForStorage(cleanedData, schemaDefinition);
|
|
779
1080
|
// Strip ID fields from data to prevent primary key conflicts
|
|
780
|
-
const { id: _unusedId, _id: _unusedRawId, ...updateData } = coerced;
|
|
1081
|
+
const { id: _unusedId, _id: _unusedRawId, exId: _unusedExId, ...updateData } = coerced;
|
|
781
1082
|
const now = new Date();
|
|
782
1083
|
updateData.updatedAt = now;
|
|
783
1084
|
if (isMongo) {
|
|
@@ -786,8 +1087,8 @@ class NMAdapter {
|
|
|
786
1087
|
const mongoRepo = this.dataSource.getMongoRepository(repo.metadata.name);
|
|
787
1088
|
const setObj = {};
|
|
788
1089
|
const unsetObj = {};
|
|
789
|
-
// We look at the original data for nulls because coercePayloadForStorage strips them
|
|
790
|
-
for (const [k, v] of Object.entries(
|
|
1090
|
+
// We look at the original cleaned data for nulls because coercePayloadForStorage strips them
|
|
1091
|
+
for (const [k, v] of Object.entries(cleanedData)) {
|
|
791
1092
|
if (k === 'id' || k === '_id')
|
|
792
1093
|
continue;
|
|
793
1094
|
if (v === null) {
|
|
@@ -816,6 +1117,7 @@ class NMAdapter {
|
|
|
816
1117
|
return updated[0];
|
|
817
1118
|
}
|
|
818
1119
|
async delete(collection, targetId, schemaDefinition) {
|
|
1120
|
+
await this.queryCache.invalidateCollection(collection);
|
|
819
1121
|
if (targetId == null || targetId === '' || targetId === 'undefined' || targetId === 'null') {
|
|
820
1122
|
throw new Error(`[NMAdapter] Cannot perform delete on '${collection}': targetId is required and cannot be null/undefined. This guard prevents accidental bulk deletions.`);
|
|
821
1123
|
}
|
|
@@ -838,6 +1140,11 @@ class NMAdapter {
|
|
|
838
1140
|
return existing[0] || null;
|
|
839
1141
|
}
|
|
840
1142
|
async count(collection, query, schemaDefinition) {
|
|
1143
|
+
const cacheKey = await this.queryCache.generateKey(collection, 'count', { query, schema: schemaDefinition?.modelName });
|
|
1144
|
+
const cached = await this.queryCache.get(cacheKey);
|
|
1145
|
+
if (cached !== null) {
|
|
1146
|
+
return cached;
|
|
1147
|
+
}
|
|
841
1148
|
const repo = this.getRepository(collection);
|
|
842
1149
|
const resolvedQuery = await this.resolveNestedSearches(query);
|
|
843
1150
|
const where = this.transformQuery(resolvedQuery, schemaDefinition);
|
|
@@ -848,6 +1155,7 @@ class NMAdapter {
|
|
|
848
1155
|
else {
|
|
849
1156
|
total = await repo.count({ where });
|
|
850
1157
|
}
|
|
1158
|
+
await this.queryCache.set(cacheKey, total);
|
|
851
1159
|
return total;
|
|
852
1160
|
}
|
|
853
1161
|
async countBy(collection, field, query, schema) {
|
|
@@ -1043,6 +1351,16 @@ class NMAdapter {
|
|
|
1043
1351
|
continue;
|
|
1044
1352
|
}
|
|
1045
1353
|
if ((0, utils_1.isPlainObject)(value)) {
|
|
1354
|
+
if (!isMongo && Array.isArray(attr) && value.$in !== undefined) {
|
|
1355
|
+
const vals = Array.isArray(value.$in) ? value.$in : [value.$in];
|
|
1356
|
+
transformed[targetKey] = (0, typeorm_1.Raw)(alias => {
|
|
1357
|
+
return vals.map((v, i) => `${alias} LIKE :val_${key}_${i}`).join(' OR ');
|
|
1358
|
+
}, vals.reduce((acc, v, i) => {
|
|
1359
|
+
acc[`val_${key}_${i}`] = `%"${v}"%`;
|
|
1360
|
+
return acc;
|
|
1361
|
+
}, {}));
|
|
1362
|
+
continue;
|
|
1363
|
+
}
|
|
1046
1364
|
const keys = Object.keys(value);
|
|
1047
1365
|
if (keys.some(k => k.startsWith('$'))) {
|
|
1048
1366
|
transformed[targetKey] = this.mapOperator(value, isMongo && isObjectIdField);
|
|
@@ -1062,24 +1380,47 @@ class NMAdapter {
|
|
|
1062
1380
|
}
|
|
1063
1381
|
}
|
|
1064
1382
|
else {
|
|
1065
|
-
//
|
|
1066
|
-
if (Array.isArray(
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1383
|
+
// Check if it is a simple-json relation array attribute in the SQL database
|
|
1384
|
+
if (Array.isArray(attr)) {
|
|
1385
|
+
if (Array.isArray(value)) {
|
|
1386
|
+
transformed[targetKey] = (0, typeorm_1.Raw)(alias => {
|
|
1387
|
+
return value.map((v, i) => `${alias} LIKE :val_${key}_${i}`).join(' OR ');
|
|
1388
|
+
}, value.reduce((acc, v, i) => {
|
|
1389
|
+
acc[`val_${key}_${i}`] = `%"${v}"%`;
|
|
1390
|
+
return acc;
|
|
1391
|
+
}, {}));
|
|
1392
|
+
}
|
|
1393
|
+
else {
|
|
1394
|
+
transformed[targetKey] = (0, typeorm_1.Like)(`%"${value}"%`);
|
|
1395
|
+
}
|
|
1071
1396
|
}
|
|
1072
1397
|
else {
|
|
1073
|
-
|
|
1074
|
-
|
|
1398
|
+
// Coerce to number for SQL if it is a string numeric
|
|
1399
|
+
if (Array.isArray(value)) {
|
|
1400
|
+
transformed[targetKey] = (0, typeorm_1.In)(value.map(v => {
|
|
1401
|
+
const n = Number(v);
|
|
1402
|
+
return !isNaN(n) ? n : v;
|
|
1403
|
+
}));
|
|
1404
|
+
}
|
|
1405
|
+
else {
|
|
1406
|
+
const num = Number(value);
|
|
1407
|
+
transformed[targetKey] = !isNaN(num) ? num : value;
|
|
1408
|
+
}
|
|
1075
1409
|
}
|
|
1076
1410
|
}
|
|
1077
1411
|
}
|
|
1078
1412
|
else {
|
|
1079
1413
|
// Coerce string numeric values for SQL, but keep as-is for Mongo (integrity for string fields)
|
|
1080
1414
|
if (!isMongo) {
|
|
1081
|
-
const
|
|
1082
|
-
|
|
1415
|
+
const attrType = a && String(a.type || '').toLowerCase();
|
|
1416
|
+
const isStringField = attrType === 'string' || attrType === 'text';
|
|
1417
|
+
if (isStringField) {
|
|
1418
|
+
transformed[key] = value;
|
|
1419
|
+
}
|
|
1420
|
+
else {
|
|
1421
|
+
const num = Number(value);
|
|
1422
|
+
transformed[key] = !isNaN(num) ? num : value;
|
|
1423
|
+
}
|
|
1083
1424
|
}
|
|
1084
1425
|
else {
|
|
1085
1426
|
transformed[key] = value;
|
|
@@ -1087,6 +1428,16 @@ class NMAdapter {
|
|
|
1087
1428
|
}
|
|
1088
1429
|
}
|
|
1089
1430
|
}
|
|
1431
|
+
// Special handling for top-level $and in SQL (TypeORM expects flat object, not $and array)
|
|
1432
|
+
if (!isMongo && transformed.$and && Array.isArray(transformed.$and)) {
|
|
1433
|
+
const andPart = transformed.$and;
|
|
1434
|
+
delete transformed.$and;
|
|
1435
|
+
for (const sub of andPart) {
|
|
1436
|
+
if ((0, utils_1.isPlainObject)(sub)) {
|
|
1437
|
+
Object.assign(transformed, sub);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1090
1441
|
// Special handling for top-level $or in SQL (TypeORM expects array for OR, but our $or needs to be flattened/merged if combined with other ANDs)
|
|
1091
1442
|
if (!isMongo && transformed.$or && Array.isArray(transformed.$or)) {
|
|
1092
1443
|
const orPart = transformed.$or;
|