@airoom/nextmin-node 2.0.1 → 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 +28 -1
- package/dist/api/apiRouter.js +138 -8
- package/dist/api/router/mountBatchRoutes.d.ts +2 -0
- package/dist/api/router/mountBatchRoutes.js +90 -0
- package/dist/api/router/mountCrudRoutes.js +79 -20
- 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 +258 -12
- package/dist/api/router/utils.d.ts +4 -1
- package/dist/api/router/utils.js +25 -7
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +145 -64
- package/dist/database/DatabaseAdapter.d.ts +2 -0
- package/dist/database/NMAdapter.d.ts +11 -0
- package/dist/database/NMAdapter.js +688 -73
- package/dist/database/QueryEngine.js +7 -4
- package/dist/files/FileStorageAdapter.d.ts +6 -0
- package/dist/files/LocalFileStorageAdapter.d.ts +6 -0
- package/dist/files/LocalFileStorageAdapter.js +39 -3
- package/dist/files/S3FileStorageAdapter.d.ts +8 -1
- package/dist/files/S3FileStorageAdapter.js +86 -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 +19 -2
- package/dist/models/BaseModel.js +4 -4
- 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/AggregateService.d.ts +8 -0
- package/dist/services/AggregateService.js +87 -0
- 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/Events.js +1 -1
- package/dist/utils/Logger.d.ts +2 -1
- package/dist/utils/Logger.js +15 -6
- package/dist/utils/SchemaLoader.d.ts +2 -2
- package/dist/utils/SchemaLoader.js +44 -6
- 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.
|
|
@@ -19,6 +20,39 @@ class NMAdapter {
|
|
|
19
20
|
this.repositories = new Map();
|
|
20
21
|
this.entitySchemas = new Map();
|
|
21
22
|
this.registeredSchemas = {};
|
|
23
|
+
this.imageProxyOptions = {};
|
|
24
|
+
this.queryCache = new QueryCache_1.QueryCache();
|
|
25
|
+
const isValidUrl = (url) => typeof url === 'string' && url.startsWith('http');
|
|
26
|
+
let imageProxy = options.imageProxy;
|
|
27
|
+
if (!imageProxy || Object.keys(imageProxy).length === 0) {
|
|
28
|
+
imageProxy = { proxyBase: '/images' };
|
|
29
|
+
}
|
|
30
|
+
if (imageProxy && imageProxy.proxyBase) {
|
|
31
|
+
this.imageProxyOptions = { ...imageProxy };
|
|
32
|
+
// Auto-detect originalBase if missing but s3/r2 config exists
|
|
33
|
+
if (!this.imageProxyOptions.originalBase) {
|
|
34
|
+
const s3 = options.s3 || {
|
|
35
|
+
bucket: process.env.S3_BUCKET,
|
|
36
|
+
region: process.env.S3_REGION,
|
|
37
|
+
endpoint: process.env.S3_ENDPOINT,
|
|
38
|
+
publicBaseUrl: process.env.S3_PUBLIC_BASE_URL
|
|
39
|
+
};
|
|
40
|
+
if (s3.publicBaseUrl) {
|
|
41
|
+
this.imageProxyOptions.originalBase = s3.publicBaseUrl.replace(/\/+$/, '');
|
|
42
|
+
}
|
|
43
|
+
else if (s3.bucket) {
|
|
44
|
+
if (isValidUrl(s3.endpoint)) {
|
|
45
|
+
this.imageProxyOptions.originalBase = `${s3.endpoint.replace(/\/+$/, '')}/${s3.bucket}`;
|
|
46
|
+
}
|
|
47
|
+
else if (s3.region) {
|
|
48
|
+
this.imageProxyOptions.originalBase = `https://${s3.bucket}.s3.${s3.region}.amazonaws.com`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (this.imageProxyOptions.originalBase && this.imageProxyOptions.proxyBase) {
|
|
53
|
+
Logger_1.default.info('NMAdapter', `Image proxy enabled: ${this.imageProxyOptions.originalBase} -> ${this.imageProxyOptions.proxyBase}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
22
56
|
this.dataSource = new typeorm_1.DataSource({
|
|
23
57
|
...options,
|
|
24
58
|
entities: [], // Will be populated dynamically
|
|
@@ -75,8 +109,10 @@ class NMAdapter {
|
|
|
75
109
|
// 3) Sync Indexes
|
|
76
110
|
for (const [name, def] of Object.entries(schemas)) {
|
|
77
111
|
const indexes = {};
|
|
78
|
-
for (const [key, attr] of Object.entries(def.attributes)) {
|
|
79
|
-
|
|
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') {
|
|
80
116
|
indexes[key] = 1;
|
|
81
117
|
}
|
|
82
118
|
}
|
|
@@ -84,7 +120,9 @@ class NMAdapter {
|
|
|
84
120
|
await this.syncIndexes(name, indexes);
|
|
85
121
|
}
|
|
86
122
|
}
|
|
87
|
-
// 4)
|
|
123
|
+
// 4) Validate refPath configurations
|
|
124
|
+
this.validateRefPathSchemas();
|
|
125
|
+
// 5) Cache repositories
|
|
88
126
|
for (const name of Object.keys(schemas)) {
|
|
89
127
|
const lcName = name.toLowerCase();
|
|
90
128
|
const schema = this.entitySchemas.get(lcName);
|
|
@@ -93,6 +131,44 @@ class NMAdapter {
|
|
|
93
131
|
}
|
|
94
132
|
}
|
|
95
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
|
+
}
|
|
96
172
|
async syncIndexes(modelName, spec) {
|
|
97
173
|
const repo = this.repositories.get(modelName.toLowerCase());
|
|
98
174
|
if (!repo)
|
|
@@ -118,6 +194,8 @@ class NMAdapter {
|
|
|
118
194
|
}
|
|
119
195
|
buildEntitySchema(def) {
|
|
120
196
|
const isMongo = this.dataSource.options.type === 'mongodb';
|
|
197
|
+
const isPostgres = this.dataSource.options.type === 'postgres';
|
|
198
|
+
const dateTimeType = isPostgres ? 'timestamp' : 'datetime';
|
|
121
199
|
const columns = {
|
|
122
200
|
id: {
|
|
123
201
|
primary: true,
|
|
@@ -129,12 +207,14 @@ class NMAdapter {
|
|
|
129
207
|
generated: isMongo ? true : 'increment',
|
|
130
208
|
},
|
|
131
209
|
createdAt: {
|
|
132
|
-
type:
|
|
210
|
+
type: dateTimeType,
|
|
211
|
+
createDate: true,
|
|
133
212
|
default: () => 'CURRENT_TIMESTAMP',
|
|
134
213
|
nullable: true,
|
|
135
214
|
},
|
|
136
215
|
updatedAt: {
|
|
137
|
-
type:
|
|
216
|
+
type: dateTimeType,
|
|
217
|
+
updateDate: true,
|
|
138
218
|
default: () => 'CURRENT_TIMESTAMP',
|
|
139
219
|
nullable: true,
|
|
140
220
|
},
|
|
@@ -159,6 +239,9 @@ class NMAdapter {
|
|
|
159
239
|
continue; // Base model protects this field; skip column in child
|
|
160
240
|
}
|
|
161
241
|
}
|
|
242
|
+
const head = Array.isArray(attr) ? attr[0] : attr;
|
|
243
|
+
if (head?.type === 'virtual')
|
|
244
|
+
continue;
|
|
162
245
|
const typeORMType = this.mapType(attr);
|
|
163
246
|
const isUnique = attr.unique === true;
|
|
164
247
|
const isSparse = attr.sparse === true;
|
|
@@ -179,7 +262,7 @@ class NMAdapter {
|
|
|
179
262
|
sparse: true,
|
|
180
263
|
});
|
|
181
264
|
}
|
|
182
|
-
if (typeORMType === 'datetime' && attr.default === 'now') {
|
|
265
|
+
if ((typeORMType === 'datetime' || typeORMType === 'timestamp') && attr.default === 'now') {
|
|
183
266
|
columns[key].default = () => 'CURRENT_TIMESTAMP';
|
|
184
267
|
}
|
|
185
268
|
}
|
|
@@ -195,12 +278,13 @@ class NMAdapter {
|
|
|
195
278
|
return 'simple-json';
|
|
196
279
|
}
|
|
197
280
|
const type = String(attr?.type || 'string').toLowerCase();
|
|
281
|
+
const isPostgres = this.dataSource.options.type === 'postgres';
|
|
198
282
|
switch (type) {
|
|
199
283
|
case 'string': return 'varchar';
|
|
200
284
|
case 'number': return 'float';
|
|
201
285
|
case 'boolean': return 'boolean';
|
|
202
286
|
case 'date':
|
|
203
|
-
case 'datetime': return 'datetime';
|
|
287
|
+
case 'datetime': return isPostgres ? 'timestamp' : 'datetime';
|
|
204
288
|
case 'json':
|
|
205
289
|
case 'object': return 'json';
|
|
206
290
|
case 'array': return 'simple-json';
|
|
@@ -216,51 +300,220 @@ class NMAdapter {
|
|
|
216
300
|
}
|
|
217
301
|
return repo;
|
|
218
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
|
+
}
|
|
219
412
|
async create(collection, data, schemaDefinition) {
|
|
413
|
+
await this.queryCache.invalidateCollection(collection);
|
|
220
414
|
const repo = this.getRepository(collection);
|
|
221
|
-
const
|
|
415
|
+
const cleanedData = this.cleanPayloadForStorage(data);
|
|
416
|
+
const coerced = this.coercePayloadForStorage(cleanedData, schemaDefinition);
|
|
417
|
+
// Ensure timestamps are set if not provided (especially for MongoDB)
|
|
418
|
+
const now = new Date();
|
|
419
|
+
if (!coerced.createdAt)
|
|
420
|
+
coerced.createdAt = now;
|
|
421
|
+
if (!coerced.updatedAt)
|
|
422
|
+
coerced.updatedAt = now;
|
|
222
423
|
const created = await repo.save(coerced);
|
|
223
424
|
return this.prepareResult(created);
|
|
224
425
|
}
|
|
225
426
|
coercePayloadForStorage(payload, schema) {
|
|
226
|
-
if (!payload || !schema
|
|
427
|
+
if (!payload || !schema)
|
|
227
428
|
return payload;
|
|
429
|
+
const isMongo = this.dataSource.options.type === 'mongodb';
|
|
228
430
|
const coerced = { ...payload };
|
|
229
431
|
for (const [key, attr] of Object.entries(schema.attributes)) {
|
|
230
432
|
const a = Array.isArray(attr) ? attr[0] : attr;
|
|
231
433
|
const isObjectIdField = String(a?.type || '').toLowerCase() === 'objectid';
|
|
232
|
-
//
|
|
233
|
-
if (coerced[key] === null) {
|
|
234
|
-
delete coerced[key];
|
|
235
|
-
continue;
|
|
236
|
-
}
|
|
434
|
+
// Flatten nested objects/relations to their IDs
|
|
237
435
|
if (isObjectIdField && coerced[key] != null) {
|
|
238
436
|
if (Array.isArray(coerced[key])) {
|
|
239
|
-
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
|
+
});
|
|
240
443
|
}
|
|
241
|
-
else {
|
|
242
|
-
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
|
+
}
|
|
243
475
|
}
|
|
244
476
|
}
|
|
245
477
|
}
|
|
246
478
|
return coerced;
|
|
247
479
|
}
|
|
248
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
|
+
}
|
|
249
488
|
const repo = this.getRepository(collection);
|
|
250
489
|
const isMongo = this.dataSource.options.type === 'mongodb';
|
|
251
490
|
const resolvedQuery = await this.resolveNestedSearches(query);
|
|
252
491
|
const where = this.transformQuery(resolvedQuery, schemaDefinition);
|
|
492
|
+
let order = options?.sort;
|
|
493
|
+
if (order && !isMongo) {
|
|
494
|
+
order = { ...order };
|
|
495
|
+
delete order._id;
|
|
496
|
+
}
|
|
253
497
|
const findOptions = {
|
|
254
498
|
where,
|
|
255
499
|
take: limit,
|
|
256
500
|
skip: skip,
|
|
257
|
-
order
|
|
501
|
+
order,
|
|
258
502
|
};
|
|
259
503
|
if (options?.projection) {
|
|
260
504
|
const hasRootProj = this.isExclusiveProjection(options.projection, schemaDefinition);
|
|
261
505
|
if (hasRootProj) {
|
|
262
506
|
const dbProjection = {};
|
|
507
|
+
// Always include ID for hydration purposes
|
|
508
|
+
if (isMongo)
|
|
509
|
+
dbProjection._id = true;
|
|
510
|
+
else
|
|
511
|
+
dbProjection.id = true;
|
|
263
512
|
for (const [key, value] of Object.entries(options.projection)) {
|
|
513
|
+
// Skip dynamic aggregate fields like model[count] for the DB query
|
|
514
|
+
if (key.includes('[') && key.endsWith(']')) {
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
264
517
|
if (key.includes('.') && schemaDefinition) {
|
|
265
518
|
const root = key.split('.')[0];
|
|
266
519
|
const attr = schemaDefinition.attributes[root];
|
|
@@ -288,7 +541,15 @@ class NMAdapter {
|
|
|
288
541
|
}
|
|
289
542
|
}
|
|
290
543
|
let results;
|
|
291
|
-
if (isMongo &&
|
|
544
|
+
if (isMongo && options?.sample) {
|
|
545
|
+
const mongoRepo = repo.manager.getMongoRepository(repo.metadata.name);
|
|
546
|
+
const aggregation = [{ $match: where }];
|
|
547
|
+
aggregation.push({ $sample: { size: options.sample } });
|
|
548
|
+
if (findOptions.projection)
|
|
549
|
+
aggregation.push({ $project: findOptions.projection });
|
|
550
|
+
results = await mongoRepo.aggregate(aggregation).toArray();
|
|
551
|
+
}
|
|
552
|
+
else if (isMongo && findOptions.projection) {
|
|
292
553
|
const mongoRepo = repo.manager.getMongoRepository(repo.metadata.name);
|
|
293
554
|
const cursor = mongoRepo.createCursor(where);
|
|
294
555
|
cursor.project(findOptions.projection);
|
|
@@ -301,12 +562,34 @@ class NMAdapter {
|
|
|
301
562
|
results = await cursor.toArray();
|
|
302
563
|
}
|
|
303
564
|
else {
|
|
304
|
-
|
|
565
|
+
if (options?.sample && !isMongo) {
|
|
566
|
+
const qb = repo.createQueryBuilder('entity');
|
|
567
|
+
qb.where(where);
|
|
568
|
+
if (limit)
|
|
569
|
+
qb.take(limit);
|
|
570
|
+
if (skip)
|
|
571
|
+
qb.skip(skip);
|
|
572
|
+
const dbType = this.dataSource.options.type;
|
|
573
|
+
if (dbType === 'postgres' || dbType === 'sqlite') {
|
|
574
|
+
qb.orderBy('RANDOM()');
|
|
575
|
+
}
|
|
576
|
+
else if (dbType === 'mysql' || dbType === 'mariadb') {
|
|
577
|
+
qb.orderBy('RAND()');
|
|
578
|
+
}
|
|
579
|
+
else if (dbType === 'mssql') {
|
|
580
|
+
qb.orderBy('NEWID()');
|
|
581
|
+
}
|
|
582
|
+
results = await qb.getMany();
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
results = await repo.find(findOptions);
|
|
586
|
+
}
|
|
305
587
|
}
|
|
306
588
|
const mappedResults = results.map(r => this.prepareResult(r));
|
|
307
589
|
if (schemaDefinition) {
|
|
308
|
-
await this.hydrateRelations(mappedResults, schemaDefinition, options?.depth ?? 0, options
|
|
590
|
+
await this.hydrateRelations(mappedResults, schemaDefinition, options?.depth ?? 0, options);
|
|
309
591
|
}
|
|
592
|
+
await this.queryCache.set(cacheKey, mappedResults);
|
|
310
593
|
return mappedResults;
|
|
311
594
|
}
|
|
312
595
|
prepareResult(item) {
|
|
@@ -324,8 +607,35 @@ class NMAdapter {
|
|
|
324
607
|
if (item.id != null)
|
|
325
608
|
item.id = String(item.id);
|
|
326
609
|
}
|
|
610
|
+
// Apply image URL proxying/transformation
|
|
611
|
+
this.transformUrls(item);
|
|
327
612
|
return item;
|
|
328
613
|
}
|
|
614
|
+
transformUrls(obj) {
|
|
615
|
+
if (!obj || typeof obj !== 'object')
|
|
616
|
+
return;
|
|
617
|
+
for (const key in obj) {
|
|
618
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
619
|
+
const val = obj[key];
|
|
620
|
+
if (typeof val === 'string') {
|
|
621
|
+
obj[key] = this.formatUrlForRead(val);
|
|
622
|
+
}
|
|
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
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
else if (val && typeof val === 'object') {
|
|
634
|
+
this.transformUrls(val);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
329
639
|
idToString(val) {
|
|
330
640
|
if (val == null)
|
|
331
641
|
return '';
|
|
@@ -358,8 +668,15 @@ class NMAdapter {
|
|
|
358
668
|
return this.convertToObjectId(extractedId);
|
|
359
669
|
}
|
|
360
670
|
try {
|
|
361
|
-
if (
|
|
362
|
-
|
|
671
|
+
if (this.dataSource.options.type === 'mongodb') {
|
|
672
|
+
if (typeof val === 'string' && val.length === 24 && /^[0-9a-fA-F]{24}$/.test(val)) {
|
|
673
|
+
return new mongodb_1.ObjectId(val);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
const n = Number(val);
|
|
678
|
+
if (!isNaN(n) && String(val).trim() !== '')
|
|
679
|
+
return n;
|
|
363
680
|
}
|
|
364
681
|
return val;
|
|
365
682
|
}
|
|
@@ -367,9 +684,87 @@ class NMAdapter {
|
|
|
367
684
|
return val;
|
|
368
685
|
}
|
|
369
686
|
}
|
|
370
|
-
|
|
687
|
+
discoverInverseRelation(targetModelName, sourceModelHint) {
|
|
688
|
+
const targetLc = targetModelName.toLowerCase();
|
|
689
|
+
const sourceLc = sourceModelHint.toLowerCase();
|
|
690
|
+
// 1. Find a schema that matches sourceModelHint (plural or singular)
|
|
691
|
+
let sourceSchema;
|
|
692
|
+
// Try exact match first
|
|
693
|
+
sourceSchema = this.registeredSchemas[sourceLc];
|
|
694
|
+
// Try common plural/singular variations
|
|
695
|
+
if (!sourceSchema) {
|
|
696
|
+
const variations = [
|
|
697
|
+
sourceLc + 's',
|
|
698
|
+
sourceLc + 'es',
|
|
699
|
+
sourceLc.endsWith('y') ? sourceLc.slice(0, -1) + 'ies' : sourceLc,
|
|
700
|
+
sourceLc.endsWith('ies') ? sourceLc.slice(0, -3) + 'y' : sourceLc,
|
|
701
|
+
sourceLc.endsWith('s') ? sourceLc.slice(0, -1) : sourceLc
|
|
702
|
+
];
|
|
703
|
+
for (const v of variations) {
|
|
704
|
+
if (this.registeredSchemas[v]) {
|
|
705
|
+
sourceSchema = this.registeredSchemas[v];
|
|
706
|
+
break;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
if (!sourceSchema)
|
|
711
|
+
return null;
|
|
712
|
+
// 2. Find which field in sourceSchema points to targetModelName
|
|
713
|
+
for (const [field, attr] of Object.entries(sourceSchema.attributes)) {
|
|
714
|
+
const a = Array.isArray(attr) ? attr[0] : attr;
|
|
715
|
+
if (a.ref?.toLowerCase() === targetLc) {
|
|
716
|
+
return { sourceModel: sourceSchema.modelName, foreignField: field };
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
async hydrateDynamicAggregates(results, schema, projection) {
|
|
722
|
+
const promises = [];
|
|
723
|
+
const idsToFetch = results.map(r => r.id).filter(v => v != null);
|
|
724
|
+
if (idsToFetch.length === 0)
|
|
725
|
+
return;
|
|
726
|
+
for (const [projKey, projVal] of Object.entries(projection)) {
|
|
727
|
+
if (projVal !== 1)
|
|
728
|
+
continue;
|
|
729
|
+
if (!projKey.includes('[') || !projKey.endsWith(']'))
|
|
730
|
+
continue;
|
|
731
|
+
const parts = projKey.split('[');
|
|
732
|
+
const modelHint = parts[0];
|
|
733
|
+
const op = parts[1].slice(0, -1);
|
|
734
|
+
if (op !== 'count')
|
|
735
|
+
continue;
|
|
736
|
+
const relation = this.discoverInverseRelation(schema.modelName, modelHint);
|
|
737
|
+
if (!relation) {
|
|
738
|
+
Logger_1.default.warn('NMAdapter', `No inverse relation found for ${modelHint} pointing to ${schema.modelName}`);
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
promises.push((async () => {
|
|
742
|
+
try {
|
|
743
|
+
const sourceSchema = this.registeredSchemas[relation.sourceModel.toLowerCase()];
|
|
744
|
+
const counts = await this.countBy(relation.sourceModel, relation.foreignField, { [relation.foreignField]: { $in: idsToFetch } }, sourceSchema);
|
|
745
|
+
const targetKey = `${modelHint}Count`;
|
|
746
|
+
results.forEach(r => {
|
|
747
|
+
const lid = this.idToString(r.id);
|
|
748
|
+
r[targetKey] = counts[lid] || 0;
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
catch (err) {
|
|
752
|
+
Logger_1.default.warn('NMAdapter', `Failed to fetch dynamic aggregate ${projKey} for ${schema.modelName}: ${err}`);
|
|
753
|
+
}
|
|
754
|
+
})());
|
|
755
|
+
}
|
|
756
|
+
if (promises.length > 0) {
|
|
757
|
+
await Promise.all(promises);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
async hydrateRelations(results, schema, depth = 0, options) {
|
|
371
761
|
if (!results.length || depth > 2)
|
|
372
762
|
return;
|
|
763
|
+
const projection = options?.projection;
|
|
764
|
+
// --- Dynamic Aggregates (e.g. model[count]) ---
|
|
765
|
+
if (projection) {
|
|
766
|
+
await this.hydrateDynamicAggregates(results, schema, projection);
|
|
767
|
+
}
|
|
373
768
|
// 1) Handle inheritance: if schema extends another, fetch base records
|
|
374
769
|
if (schema.extends) {
|
|
375
770
|
const baseLc = schema.extends.toLowerCase();
|
|
@@ -436,13 +831,59 @@ class NMAdapter {
|
|
|
436
831
|
}
|
|
437
832
|
// 2) Handle standard ObjectId hydration & 3) json-group in parallel
|
|
438
833
|
const hydrationPromises = [];
|
|
834
|
+
// --- Virtual fields (Inverse relationship counts) ---
|
|
835
|
+
const virtualFields = Object.entries(schema.attributes).filter(([key, attr]) => {
|
|
836
|
+
const a = Array.isArray(attr) ? attr[0] : attr;
|
|
837
|
+
return a?.type === 'virtual' && a?.op === 'count';
|
|
838
|
+
});
|
|
839
|
+
for (const [key, attr] of virtualFields) {
|
|
840
|
+
hydrationPromises.push((async () => {
|
|
841
|
+
const a = Array.isArray(attr) ? attr[0] : attr;
|
|
842
|
+
const refModel = a.ref;
|
|
843
|
+
const foreignField = a.foreignField;
|
|
844
|
+
const localField = a.localField || 'id';
|
|
845
|
+
if (!refModel || !foreignField)
|
|
846
|
+
return;
|
|
847
|
+
const idsToFetch = results.map(r => r[localField]).filter(v => v != null);
|
|
848
|
+
if (idsToFetch.length === 0)
|
|
849
|
+
return;
|
|
850
|
+
const counts = await this.countBy(refModel, foreignField, { [foreignField]: { $in: idsToFetch } });
|
|
851
|
+
results.forEach(r => {
|
|
852
|
+
const lid = this.idToString(r[localField]);
|
|
853
|
+
r[key] = counts[lid] || 0;
|
|
854
|
+
});
|
|
855
|
+
})());
|
|
856
|
+
}
|
|
439
857
|
// --- Standard Relations ---
|
|
440
858
|
const relFields = Object.entries(schema.attributes).filter(([key, attr]) => {
|
|
441
859
|
if (key === 'baseId')
|
|
442
860
|
return false;
|
|
443
861
|
const a = Array.isArray(attr) ? attr[0] : attr;
|
|
444
|
-
|
|
862
|
+
const t = String(a?.type || '').toLowerCase();
|
|
863
|
+
return (t === 'objectid' || t === 'ref') && (a?.ref || a?.refPath);
|
|
445
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
|
+
};
|
|
446
887
|
for (const [key, attr] of relFields) {
|
|
447
888
|
hydrationPromises.push((async () => {
|
|
448
889
|
const hasRootProj = this.isExclusiveProjection(projection, schema);
|
|
@@ -450,58 +891,140 @@ class NMAdapter {
|
|
|
450
891
|
if (projection && hasRootProj && !isTargeted)
|
|
451
892
|
return;
|
|
452
893
|
const isArray = Array.isArray(attr);
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
+
};
|
|
461
920
|
for (const item of results) {
|
|
462
|
-
|
|
921
|
+
let val = item[key];
|
|
463
922
|
if (val == null)
|
|
464
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);
|
|
465
946
|
if (isArray && Array.isArray(val)) {
|
|
466
|
-
val.
|
|
467
|
-
|
|
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
|
+
});
|
|
468
967
|
}
|
|
469
968
|
else {
|
|
470
|
-
|
|
969
|
+
if (!isHydratedObject(val)) {
|
|
970
|
+
group.push({ item, idVal: val });
|
|
971
|
+
}
|
|
471
972
|
}
|
|
472
973
|
}
|
|
473
|
-
if (
|
|
974
|
+
if (itemsByModel.size === 0)
|
|
474
975
|
return;
|
|
475
|
-
let subProjection =
|
|
476
|
-
if (
|
|
976
|
+
let subProjection = options?._populate?.[key];
|
|
977
|
+
if (subProjection) {
|
|
978
|
+
subProjection = { ...subProjection, id: 1 };
|
|
979
|
+
}
|
|
980
|
+
if (!subProjection && projection) {
|
|
477
981
|
const prefix = `${key}.`;
|
|
478
982
|
for (const [pk, pv] of Object.entries(projection)) {
|
|
479
983
|
if (pk.startsWith(prefix)) {
|
|
480
984
|
if (!subProjection)
|
|
481
|
-
subProjection = {};
|
|
985
|
+
subProjection = { id: 1 };
|
|
482
986
|
subProjection[pk.slice(prefix.length)] = pv;
|
|
483
987
|
}
|
|
484
988
|
}
|
|
485
989
|
}
|
|
486
|
-
const
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
const docMap = new Map();
|
|
491
|
-
fetchedDocs.forEach(d => {
|
|
492
|
-
const sid = this.idToString(d.id);
|
|
493
|
-
if (sid)
|
|
494
|
-
docMap.set(sid, d);
|
|
495
|
-
});
|
|
496
|
-
for (const item of results) {
|
|
497
|
-
const val = item[key];
|
|
498
|
-
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)
|
|
499
994
|
continue;
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
+
}
|
|
505
1028
|
}
|
|
506
1029
|
}
|
|
507
1030
|
})());
|
|
@@ -543,6 +1066,7 @@ class NMAdapter {
|
|
|
543
1066
|
await Promise.all(hydrationPromises);
|
|
544
1067
|
}
|
|
545
1068
|
async update(collection, targetId, data, schemaDefinition) {
|
|
1069
|
+
await this.queryCache.invalidateCollection(collection);
|
|
546
1070
|
if (targetId == null || targetId === '' || targetId === 'undefined' || targetId === 'null') {
|
|
547
1071
|
throw new Error(`[NMAdapter] Cannot perform update on '${collection}': targetId is required and cannot be null/undefined. This guard prevents accidental bulk updates.`);
|
|
548
1072
|
}
|
|
@@ -551,17 +1075,20 @@ class NMAdapter {
|
|
|
551
1075
|
throw new Error(`Repository for ${collection} not found.`);
|
|
552
1076
|
const isMongo = this.dataSource.options.type === 'mongodb';
|
|
553
1077
|
const resolvedTargetId = isMongo ? this.convertToObjectId(targetId) : targetId;
|
|
554
|
-
const
|
|
1078
|
+
const cleanedData = this.cleanPayloadForStorage(data);
|
|
1079
|
+
const coerced = this.coercePayloadForStorage(cleanedData, schemaDefinition);
|
|
555
1080
|
// Strip ID fields from data to prevent primary key conflicts
|
|
556
|
-
const { id: _unusedId, _id: _unusedRawId, ...updateData } = coerced;
|
|
1081
|
+
const { id: _unusedId, _id: _unusedRawId, exId: _unusedExId, ...updateData } = coerced;
|
|
1082
|
+
const now = new Date();
|
|
1083
|
+
updateData.updatedAt = now;
|
|
557
1084
|
if (isMongo) {
|
|
558
1085
|
// Using explicit getMongoRepository and updateOne prevents accidental bulk updates
|
|
559
1086
|
// that targetBy filter {} could cause in standard TypeORM .update()
|
|
560
1087
|
const mongoRepo = this.dataSource.getMongoRepository(repo.metadata.name);
|
|
561
1088
|
const setObj = {};
|
|
562
1089
|
const unsetObj = {};
|
|
563
|
-
// We look at the original data for nulls because coercePayloadForStorage strips them
|
|
564
|
-
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)) {
|
|
565
1092
|
if (k === 'id' || k === '_id')
|
|
566
1093
|
continue;
|
|
567
1094
|
if (v === null) {
|
|
@@ -572,6 +1099,8 @@ class NMAdapter {
|
|
|
572
1099
|
setObj[k] = updateData[k] !== undefined ? updateData[k] : v;
|
|
573
1100
|
}
|
|
574
1101
|
}
|
|
1102
|
+
// Always update the updatedAt field
|
|
1103
|
+
setObj.updatedAt = now;
|
|
575
1104
|
const updatePayload = {};
|
|
576
1105
|
if (Object.keys(setObj).length > 0)
|
|
577
1106
|
updatePayload.$set = setObj;
|
|
@@ -588,6 +1117,7 @@ class NMAdapter {
|
|
|
588
1117
|
return updated[0];
|
|
589
1118
|
}
|
|
590
1119
|
async delete(collection, targetId, schemaDefinition) {
|
|
1120
|
+
await this.queryCache.invalidateCollection(collection);
|
|
591
1121
|
if (targetId == null || targetId === '' || targetId === 'undefined' || targetId === 'null') {
|
|
592
1122
|
throw new Error(`[NMAdapter] Cannot perform delete on '${collection}': targetId is required and cannot be null/undefined. This guard prevents accidental bulk deletions.`);
|
|
593
1123
|
}
|
|
@@ -610,6 +1140,11 @@ class NMAdapter {
|
|
|
610
1140
|
return existing[0] || null;
|
|
611
1141
|
}
|
|
612
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
|
+
}
|
|
613
1148
|
const repo = this.getRepository(collection);
|
|
614
1149
|
const resolvedQuery = await this.resolveNestedSearches(query);
|
|
615
1150
|
const where = this.transformQuery(resolvedQuery, schemaDefinition);
|
|
@@ -620,8 +1155,45 @@ class NMAdapter {
|
|
|
620
1155
|
else {
|
|
621
1156
|
total = await repo.count({ where });
|
|
622
1157
|
}
|
|
1158
|
+
await this.queryCache.set(cacheKey, total);
|
|
623
1159
|
return total;
|
|
624
1160
|
}
|
|
1161
|
+
async countBy(collection, field, query, schema) {
|
|
1162
|
+
const repo = this.getRepository(collection);
|
|
1163
|
+
const where = this.transformQuery(query, schema);
|
|
1164
|
+
const counts = {};
|
|
1165
|
+
if (this.dataSource.options.type === 'mongodb') {
|
|
1166
|
+
const mongoRepo = repo.manager.getMongoRepository(repo.metadata.name);
|
|
1167
|
+
// Determine if field is an array from schema to use $unwind
|
|
1168
|
+
const isArray = Array.isArray(schema?.attributes?.[field]);
|
|
1169
|
+
const pipeline = [{ $match: where }];
|
|
1170
|
+
if (isArray) {
|
|
1171
|
+
pipeline.push({ $unwind: `$${field}` });
|
|
1172
|
+
}
|
|
1173
|
+
pipeline.push({ $group: { _id: `$${field}`, count: { $sum: 1 } } });
|
|
1174
|
+
const results = await mongoRepo.aggregate(pipeline).toArray();
|
|
1175
|
+
results.forEach((r) => {
|
|
1176
|
+
if (r._id != null)
|
|
1177
|
+
counts[this.idToString(r._id)] = r.count;
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
else {
|
|
1181
|
+
const results = await repo
|
|
1182
|
+
.createQueryBuilder('entity')
|
|
1183
|
+
.select(`entity.${field}`, 'key')
|
|
1184
|
+
.addSelect('COUNT(*)', 'count')
|
|
1185
|
+
.where(where)
|
|
1186
|
+
.groupBy(`entity.${field}`)
|
|
1187
|
+
.getRawMany();
|
|
1188
|
+
results.forEach((r) => {
|
|
1189
|
+
const k = r.key || r.KEY || r[`entity_${field}`];
|
|
1190
|
+
if (k != null) {
|
|
1191
|
+
counts[this.idToString(k)] = parseInt(r.count || r.COUNT || 0, 10);
|
|
1192
|
+
}
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
return counts;
|
|
1196
|
+
}
|
|
625
1197
|
async findFirstRelatedIds(collection, field, ids) {
|
|
626
1198
|
const repo = this.getRepository(collection);
|
|
627
1199
|
const map = new Map();
|
|
@@ -779,6 +1351,16 @@ class NMAdapter {
|
|
|
779
1351
|
continue;
|
|
780
1352
|
}
|
|
781
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
|
+
}
|
|
782
1364
|
const keys = Object.keys(value);
|
|
783
1365
|
if (keys.some(k => k.startsWith('$'))) {
|
|
784
1366
|
transformed[targetKey] = this.mapOperator(value, isMongo && isObjectIdField);
|
|
@@ -798,24 +1380,47 @@ class NMAdapter {
|
|
|
798
1380
|
}
|
|
799
1381
|
}
|
|
800
1382
|
else {
|
|
801
|
-
//
|
|
802
|
-
if (Array.isArray(
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
+
}
|
|
807
1396
|
}
|
|
808
1397
|
else {
|
|
809
|
-
|
|
810
|
-
|
|
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
|
+
}
|
|
811
1409
|
}
|
|
812
1410
|
}
|
|
813
1411
|
}
|
|
814
1412
|
else {
|
|
815
1413
|
// Coerce string numeric values for SQL, but keep as-is for Mongo (integrity for string fields)
|
|
816
1414
|
if (!isMongo) {
|
|
817
|
-
const
|
|
818
|
-
|
|
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
|
+
}
|
|
819
1424
|
}
|
|
820
1425
|
else {
|
|
821
1426
|
transformed[key] = value;
|
|
@@ -823,6 +1428,16 @@ class NMAdapter {
|
|
|
823
1428
|
}
|
|
824
1429
|
}
|
|
825
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
|
+
}
|
|
826
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)
|
|
827
1442
|
if (!isMongo && transformed.$or && Array.isArray(transformed.$or)) {
|
|
828
1443
|
const orPart = transformed.$or;
|