@drax/crud-back 3.17.0 → 3.17.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.
@@ -9,6 +9,9 @@ class AbstractMongoRepository {
9
9
  this._populateFields = [];
10
10
  this._lean = true;
11
11
  }
12
+ getNestedValue(source, path) {
13
+ return path.split('.').reduce((value, key) => value == null ? undefined : value[key], source);
14
+ }
12
15
  assertId(id) {
13
16
  if (!mongoose.Types.ObjectId.isValid(id)) {
14
17
  console.log(`Invalid ID: ${id} is not a valid ObjectId.`);
@@ -228,17 +231,29 @@ class AbstractMongoRepository {
228
231
  const schema = this._model.schema;
229
232
  // Construir el objeto de agrupación dinámicamente
230
233
  const groupId = {};
231
- const lookupStages = [];
234
+ const preGroupStages = [];
235
+ const postGroupStages = [];
232
236
  const finalProjectFields = { count: 1, _id: 0 };
233
237
  const refFields = new Set();
234
238
  const dateFields = new Set();
235
239
  const numericFields = new Set();
236
240
  const groupFields = [];
241
+ const groupFieldAliases = new Map();
242
+ const preGroupLookupAliases = new Map();
237
243
  const numericInstances = new Set(['Number', 'Decimal128', 'Double', 'Int32', 'Long', 'BigInt']);
238
244
  const totalGroupFields = fields.filter(field => {
239
245
  const schemaPath = schema.path(field);
240
246
  return !(schemaPath && numericInstances.has(schemaPath.instance));
241
247
  }).length;
248
+ const getGroupFieldAlias = (field) => {
249
+ const existingAlias = groupFieldAliases.get(field);
250
+ if (existingAlias) {
251
+ return existingAlias;
252
+ }
253
+ const newAlias = `field_${groupFieldAliases.size}`;
254
+ groupFieldAliases.set(field, newAlias);
255
+ return newAlias;
256
+ };
242
257
  // Función para obtener el formato de fecha según el nivel de granularidad
243
258
  const getDateFormat = (field, format) => {
244
259
  const formats = {
@@ -293,8 +308,38 @@ class AbstractMongoRepository {
293
308
  };
294
309
  return formats[format] || formats['day'];
295
310
  };
311
+ const ensureLookupForReferencedField = (field, refModel) => {
312
+ const existingAlias = preGroupLookupAliases.get(field);
313
+ if (existingAlias) {
314
+ return existingAlias;
315
+ }
316
+ const refModelInstance = mongoose.model(refModel);
317
+ const collectionName = refModelInstance.collection.name;
318
+ const lookupAlias = `${field}_groupByRef`;
319
+ preGroupStages.push({
320
+ $lookup: {
321
+ from: collectionName,
322
+ localField: field,
323
+ foreignField: '_id',
324
+ as: lookupAlias
325
+ }
326
+ });
327
+ preGroupStages.push({
328
+ $unwind: {
329
+ path: `$${lookupAlias}`,
330
+ preserveNullAndEmptyArrays: true
331
+ }
332
+ });
333
+ preGroupLookupAliases.set(field, lookupAlias);
334
+ return lookupAlias;
335
+ };
296
336
  fields.forEach(field => {
297
337
  const schemaPath = schema.path(field);
338
+ const fieldAlias = getGroupFieldAlias(field);
339
+ const fieldParts = field.split('.');
340
+ const rootField = fieldParts[0];
341
+ const nestedFieldPath = fieldParts.slice(1).join('.');
342
+ const rootSchemaPath = rootField === field ? schemaPath : schema.path(rootField);
298
343
  // Verificar si el campo es numérico: se agregará con $sum y no formará parte de la clave de agrupación
299
344
  if (schemaPath && numericInstances.has(schemaPath.instance)) {
300
345
  numericFields.add(field);
@@ -305,50 +350,54 @@ class AbstractMongoRepository {
305
350
  // Verificar si el campo es de tipo Date
306
351
  if (schemaPath && schemaPath.instance === 'Date') {
307
352
  dateFields.add(field);
308
- groupId[field] = getDateFormat(field, dateFormat);
353
+ groupId[fieldAlias] = getDateFormat(field, dateFormat);
309
354
  }
310
355
  // Verificar si el campo es una referencia
311
356
  else if (schemaPath && schemaPath.options && schemaPath.options.ref) {
312
357
  const refModel = schemaPath.options.ref;
313
- const fieldName = field;
314
358
  refFields.add(field);
315
359
  // Obtener el modelo referenciado y su nombre de colección real
316
360
  const refModelInstance = mongoose.model(refModel);
317
361
  const collectionName = refModelInstance.collection.name;
318
362
  // Determinar el campo local correcto según si es un solo campo o múltiples
319
- const localField = totalGroupFields === 1 ? '_id' : `_id.${fieldName}`;
320
- lookupStages.push({
363
+ const localField = totalGroupFields === 1 ? '_id' : `_id.${fieldAlias}`;
364
+ postGroupStages.push({
321
365
  $lookup: {
322
366
  from: collectionName,
323
367
  localField: localField,
324
368
  foreignField: '_id',
325
- as: `${fieldName}_populated`
369
+ as: `${fieldAlias}_populated`
326
370
  }
327
371
  });
328
372
  // Unwind para convertir el array en objeto único
329
- lookupStages.push({
373
+ postGroupStages.push({
330
374
  $unwind: {
331
- path: `$${fieldName}_populated`,
375
+ path: `$${fieldAlias}_populated`,
332
376
  preserveNullAndEmptyArrays: true
333
377
  }
334
378
  });
335
379
  // En la proyección final, usar el objeto poblado
336
- finalProjectFields[field] = `$${fieldName}_populated`;
337
- groupId[field] = `$${field}`;
380
+ finalProjectFields[field] = `$${fieldAlias}_populated`;
381
+ groupId[fieldAlias] = `$${field}`;
382
+ }
383
+ else if (nestedFieldPath && rootSchemaPath && rootSchemaPath.options && rootSchemaPath.options.ref) {
384
+ const lookupAlias = ensureLookupForReferencedField(rootField, rootSchemaPath.options.ref);
385
+ groupId[fieldAlias] = `$${lookupAlias}.${nestedFieldPath}`;
338
386
  }
339
387
  else {
340
388
  // Si no es una referencia ni fecha, usar el valor directo
341
- groupId[field] = `$${field}`;
389
+ groupId[fieldAlias] = `$${field}`;
342
390
  }
343
391
  });
344
392
  // Construir la proyección final para campos de fecha
345
393
  groupFields.forEach(field => {
394
+ const fieldAlias = groupFieldAliases.get(field);
346
395
  if (dateFields.has(field)) {
347
396
  if (groupFields.length === 1) {
348
397
  finalProjectFields[field] = `$_id`;
349
398
  }
350
399
  else {
351
- finalProjectFields[field] = `$_id.${field}`;
400
+ finalProjectFields[field] = `$_id.${fieldAlias}`;
352
401
  }
353
402
  }
354
403
  else if (!refFields.has(field)) {
@@ -356,7 +405,7 @@ class AbstractMongoRepository {
356
405
  finalProjectFields[field] = `$_id`;
357
406
  }
358
407
  else {
359
- finalProjectFields[field] = `$_id.${field}`;
408
+ finalProjectFields[field] = `$_id.${fieldAlias}`;
360
409
  }
361
410
  }
362
411
  });
@@ -369,27 +418,41 @@ class AbstractMongoRepository {
369
418
  });
370
419
  if (groupFields.length === 1) {
371
420
  const field = groupFields[0];
372
- groupStage._id = dateFields.has(field) ? getDateFormat(field, dateFormat) : groupId[field];
421
+ const fieldAlias = groupFieldAliases.get(field);
422
+ groupStage._id = dateFields.has(field) ? getDateFormat(field, dateFormat) : groupId[fieldAlias];
373
423
  }
374
424
  else if (groupFields.length > 1) {
375
425
  groupStage._id = groupId;
376
426
  }
377
427
  const pipeline = [
378
428
  { $match: query },
429
+ ...preGroupStages,
379
430
  {
380
431
  $group: groupStage
381
432
  }
382
433
  ];
383
434
  // Solo agregar lookups si hay campos de referencia
384
- if (lookupStages.length > 0) {
385
- pipeline.push(...lookupStages);
435
+ if (postGroupStages.length > 0) {
436
+ pipeline.push(...postGroupStages);
386
437
  }
387
438
  pipeline.push({
388
439
  $project: finalProjectFields
389
440
  }, { $sort: { count: -1 } });
390
441
  // console.log("pipeline", JSON.stringify(pipeline, null, 2))
391
442
  const result = await this._model.aggregate(pipeline).exec();
392
- return result;
443
+ return result.map((item) => {
444
+ const normalizedItem = { ...item };
445
+ groupFields.forEach(field => {
446
+ if (!field.includes('.') || normalizedItem[field] !== undefined) {
447
+ return;
448
+ }
449
+ const nestedValue = this.getNestedValue(normalizedItem, field);
450
+ if (nestedValue !== undefined) {
451
+ normalizedItem[field] = nestedValue;
452
+ }
453
+ });
454
+ return normalizedItem;
455
+ });
393
456
  }
394
457
  }
395
458
  export default AbstractMongoRepository;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "3.17.0",
6
+ "version": "3.17.3",
7
7
  "description": "Crud utils across modules",
8
8
  "main": "dist/index.js",
9
9
  "types": "types/index.d.ts",
@@ -25,7 +25,7 @@
25
25
  "@drax/common-back": "^3.14.0",
26
26
  "@drax/common-share": "^3.0.0",
27
27
  "@drax/identity-share": "^3.15.0",
28
- "@drax/media-back": "^3.17.0",
28
+ "@drax/media-back": "^3.17.3",
29
29
  "@graphql-tools/load-files": "^7.0.0",
30
30
  "@graphql-tools/merge": "^9.0.4",
31
31
  "mongoose": "^8.23.0",
@@ -47,5 +47,5 @@
47
47
  "typescript": "^5.9.3",
48
48
  "vitest": "^3.2.4"
49
49
  },
50
- "gitHead": "d8a056b087d67157bc8c2e684b7e6696399d06a4"
50
+ "gitHead": "f3cc4a70c5f96cb4a5de6da037f7dd0d3eed6202"
51
51
  }
@@ -30,6 +30,10 @@ class AbstractMongoRepository<T, C, U> implements IDraxCrud<T, C, U> {
30
30
  protected _populateFields: string[] | PopulateOptions[] = []
31
31
  protected _lean: boolean = true
32
32
 
33
+ private getNestedValue(source: any, path: string): any {
34
+ return path.split('.').reduce((value, key) => value == null ? undefined : value[key], source)
35
+ }
36
+
33
37
  assertId(id: string): void {
34
38
  if (!mongoose.Types.ObjectId.isValid(id)) {
35
39
  console.log(`Invalid ID: ${id} is not a valid ObjectId.`)
@@ -337,18 +341,32 @@ class AbstractMongoRepository<T, C, U> implements IDraxCrud<T, C, U> {
337
341
 
338
342
  // Construir el objeto de agrupación dinámicamente
339
343
  const groupId: any = {}
340
- const lookupStages: any[] = []
344
+ const preGroupStages: any[] = []
345
+ const postGroupStages: any[] = []
341
346
  const finalProjectFields: any = {count: 1, _id: 0}
342
347
  const refFields = new Set<string>()
343
348
  const dateFields = new Set<string>()
344
349
  const numericFields = new Set<string>()
345
350
  const groupFields: string[] = []
351
+ const groupFieldAliases = new Map<string, string>()
352
+ const preGroupLookupAliases = new Map<string, string>()
346
353
  const numericInstances = new Set(['Number', 'Decimal128', 'Double', 'Int32', 'Long', 'BigInt'])
347
354
  const totalGroupFields = fields.filter(field => {
348
355
  const schemaPath = schema.path(field)
349
356
  return !(schemaPath && numericInstances.has(schemaPath.instance))
350
357
  }).length
351
358
 
359
+ const getGroupFieldAlias = (field: string): string => {
360
+ const existingAlias = groupFieldAliases.get(field)
361
+ if (existingAlias) {
362
+ return existingAlias
363
+ }
364
+
365
+ const newAlias = `field_${groupFieldAliases.size}`
366
+ groupFieldAliases.set(field, newAlias)
367
+ return newAlias
368
+ }
369
+
352
370
  // Función para obtener el formato de fecha según el nivel de granularidad
353
371
  const getDateFormat = (field: string, format: string) => {
354
372
  const formats = {
@@ -404,8 +422,43 @@ class AbstractMongoRepository<T, C, U> implements IDraxCrud<T, C, U> {
404
422
  return formats[format] || formats['day']
405
423
  }
406
424
 
425
+ const ensureLookupForReferencedField = (field: string, refModel: string): string => {
426
+ const existingAlias = preGroupLookupAliases.get(field)
427
+ if (existingAlias) {
428
+ return existingAlias
429
+ }
430
+
431
+ const refModelInstance = mongoose.model(refModel)
432
+ const collectionName = refModelInstance.collection.name
433
+ const lookupAlias = `${field}_groupByRef`
434
+
435
+ preGroupStages.push({
436
+ $lookup: {
437
+ from: collectionName,
438
+ localField: field,
439
+ foreignField: '_id',
440
+ as: lookupAlias
441
+ }
442
+ })
443
+
444
+ preGroupStages.push({
445
+ $unwind: {
446
+ path: `$${lookupAlias}`,
447
+ preserveNullAndEmptyArrays: true
448
+ }
449
+ })
450
+
451
+ preGroupLookupAliases.set(field, lookupAlias)
452
+ return lookupAlias
453
+ }
454
+
407
455
  fields.forEach(field => {
408
456
  const schemaPath = schema.path(field)
457
+ const fieldAlias = getGroupFieldAlias(field)
458
+ const fieldParts = field.split('.')
459
+ const rootField = fieldParts[0]
460
+ const nestedFieldPath = fieldParts.slice(1).join('.')
461
+ const rootSchemaPath = rootField === field ? schemaPath : schema.path(rootField)
409
462
 
410
463
  // Verificar si el campo es numérico: se agregará con $sum y no formará parte de la clave de agrupación
411
464
  if (schemaPath && numericInstances.has(schemaPath.instance)) {
@@ -419,12 +472,11 @@ class AbstractMongoRepository<T, C, U> implements IDraxCrud<T, C, U> {
419
472
  // Verificar si el campo es de tipo Date
420
473
  if (schemaPath && schemaPath.instance === 'Date') {
421
474
  dateFields.add(field)
422
- groupId[field] = getDateFormat(field, dateFormat)
475
+ groupId[fieldAlias] = getDateFormat(field, dateFormat)
423
476
  }
424
477
  // Verificar si el campo es una referencia
425
478
  else if (schemaPath && schemaPath.options && schemaPath.options.ref) {
426
479
  const refModel = schemaPath.options.ref
427
- const fieldName = field
428
480
 
429
481
  refFields.add(field)
430
482
 
@@ -433,47 +485,51 @@ class AbstractMongoRepository<T, C, U> implements IDraxCrud<T, C, U> {
433
485
  const collectionName = refModelInstance.collection.name
434
486
 
435
487
  // Determinar el campo local correcto según si es un solo campo o múltiples
436
- const localField = totalGroupFields === 1 ? '_id' : `_id.${fieldName}`
488
+ const localField = totalGroupFields === 1 ? '_id' : `_id.${fieldAlias}`
437
489
 
438
- lookupStages.push({
490
+ postGroupStages.push({
439
491
  $lookup: {
440
492
  from: collectionName,
441
493
  localField: localField,
442
494
  foreignField: '_id',
443
- as: `${fieldName}_populated`
495
+ as: `${fieldAlias}_populated`
444
496
  }
445
497
  })
446
498
 
447
499
  // Unwind para convertir el array en objeto único
448
- lookupStages.push({
500
+ postGroupStages.push({
449
501
  $unwind: {
450
- path: `$${fieldName}_populated`,
502
+ path: `$${fieldAlias}_populated`,
451
503
  preserveNullAndEmptyArrays: true
452
504
  }
453
505
  })
454
506
 
455
507
  // En la proyección final, usar el objeto poblado
456
- finalProjectFields[field] = `$${fieldName}_populated`
457
- groupId[field] = `$${field}`
508
+ finalProjectFields[field] = `$${fieldAlias}_populated`
509
+ groupId[fieldAlias] = `$${field}`
510
+ } else if (nestedFieldPath && rootSchemaPath && rootSchemaPath.options && rootSchemaPath.options.ref) {
511
+ const lookupAlias = ensureLookupForReferencedField(rootField, rootSchemaPath.options.ref)
512
+ groupId[fieldAlias] = `$${lookupAlias}.${nestedFieldPath}`
458
513
  } else {
459
514
  // Si no es una referencia ni fecha, usar el valor directo
460
- groupId[field] = `$${field}`
515
+ groupId[fieldAlias] = `$${field}`
461
516
  }
462
517
  })
463
518
 
464
519
  // Construir la proyección final para campos de fecha
465
520
  groupFields.forEach(field => {
521
+ const fieldAlias = groupFieldAliases.get(field) as string
466
522
  if (dateFields.has(field)) {
467
523
  if (groupFields.length === 1) {
468
524
  finalProjectFields[field] = `$_id`
469
525
  } else {
470
- finalProjectFields[field] = `$_id.${field}`
526
+ finalProjectFields[field] = `$_id.${fieldAlias}`
471
527
  }
472
528
  } else if (!refFields.has(field)) {
473
529
  if (groupFields.length === 1) {
474
530
  finalProjectFields[field] = `$_id`
475
531
  } else {
476
- finalProjectFields[field] = `$_id.${field}`
532
+ finalProjectFields[field] = `$_id.${fieldAlias}`
477
533
  }
478
534
  }
479
535
  })
@@ -489,21 +545,23 @@ class AbstractMongoRepository<T, C, U> implements IDraxCrud<T, C, U> {
489
545
 
490
546
  if (groupFields.length === 1) {
491
547
  const field = groupFields[0]
492
- groupStage._id = dateFields.has(field) ? getDateFormat(field, dateFormat) : groupId[field]
548
+ const fieldAlias = groupFieldAliases.get(field) as string
549
+ groupStage._id = dateFields.has(field) ? getDateFormat(field, dateFormat) : groupId[fieldAlias]
493
550
  } else if (groupFields.length > 1) {
494
551
  groupStage._id = groupId
495
552
  }
496
553
 
497
554
  const pipeline: any[] = [
498
555
  {$match: query},
556
+ ...preGroupStages,
499
557
  {
500
558
  $group: groupStage
501
559
  }
502
560
  ]
503
561
 
504
562
  // Solo agregar lookups si hay campos de referencia
505
- if (lookupStages.length > 0) {
506
- pipeline.push(...lookupStages)
563
+ if (postGroupStages.length > 0) {
564
+ pipeline.push(...postGroupStages)
507
565
  }
508
566
 
509
567
  pipeline.push(
@@ -514,7 +572,23 @@ class AbstractMongoRepository<T, C, U> implements IDraxCrud<T, C, U> {
514
572
  )
515
573
  // console.log("pipeline", JSON.stringify(pipeline, null, 2))
516
574
  const result = await this._model.aggregate(pipeline).exec()
517
- return result
575
+
576
+ return result.map((item: any) => {
577
+ const normalizedItem = {...item}
578
+
579
+ groupFields.forEach(field => {
580
+ if (!field.includes('.') || normalizedItem[field] !== undefined) {
581
+ return
582
+ }
583
+
584
+ const nestedValue = this.getNestedValue(normalizedItem, field)
585
+ if (nestedValue !== undefined) {
586
+ normalizedItem[field] = nestedValue
587
+ }
588
+ })
589
+
590
+ return normalizedItem
591
+ })
518
592
  }
519
593
  }
520
594
 
@@ -561,6 +561,122 @@ describe("Person Controller Test", function () {
561
561
  expect(groupResult.length).toBeGreaterThan(0)
562
562
  })
563
563
 
564
+ it("should groupBy nested reference and embedded fields together", async () => {
565
+ const { accessToken } = await testSetup.rootUserLogin()
566
+ await testSetup.dropCollection('Person')
567
+ await testSetup.dropCollection('Country')
568
+
569
+ const argentina = await CountryModel.create({
570
+ name: "Argentina",
571
+ createdBy: testSetup.rootUser._id
572
+ })
573
+
574
+ const chile = await CountryModel.create({
575
+ name: "Chile",
576
+ createdBy: testSetup.rootUser._id
577
+ })
578
+
579
+ const entityData: IPersonBase[] = [
580
+ {
581
+ fullname: "Buenos Aires One",
582
+ nationality: argentina._id.toString(),
583
+ address: { street: "Main St", city: "Buenos Aires" }
584
+ },
585
+ {
586
+ fullname: "Buenos Aires Two",
587
+ nationality: argentina._id.toString(),
588
+ address: { street: "Second St", city: "Buenos Aires" }
589
+ },
590
+ {
591
+ fullname: "Santiago One",
592
+ nationality: chile._id.toString(),
593
+ address: { street: "Main St", city: "Santiago" }
594
+ }
595
+ ]
596
+
597
+ for (const data of entityData) {
598
+ const createResp = await testSetup.fastifyInstance.inject({
599
+ method: 'POST',
600
+ url: '/api/person',
601
+ payload: data,
602
+ headers: { Authorization: `Bearer ${accessToken}` }
603
+ })
604
+
605
+ expect(createResp.statusCode).toBe(200)
606
+ }
607
+
608
+ const groupResp = await testSetup.fastifyInstance.inject({
609
+ method: 'GET',
610
+ url: '/api/person/group-by?fields=nationality.name,address.city',
611
+ headers: { Authorization: `Bearer ${accessToken}` }
612
+ })
613
+
614
+ const groupResult = await groupResp.json()
615
+ expect(groupResp.statusCode).toBe(200)
616
+ expect(groupResult).toEqual(expect.arrayContaining([
617
+ expect.objectContaining({
618
+ 'nationality.name': 'Argentina',
619
+ 'address.city': 'Buenos Aires',
620
+ count: 2
621
+ }),
622
+ expect.objectContaining({
623
+ 'nationality.name': 'Chile',
624
+ 'address.city': 'Santiago',
625
+ count: 1
626
+ })
627
+ ]))
628
+ })
629
+
630
+ it("should groupBy a single embedded nested field", async () => {
631
+ const { accessToken } = await testSetup.rootUserLogin()
632
+ await testSetup.dropCollection('Person')
633
+
634
+ const entityData: IPersonBase[] = [
635
+ {
636
+ fullname: "Argentina One",
637
+ address: { street: "Main St", country: "Argentina", city: "Buenos Aires" }
638
+ },
639
+ {
640
+ fullname: "Argentina Two",
641
+ address: { street: "Second St", country: "Argentina", city: "Cordoba" }
642
+ },
643
+ {
644
+ fullname: "Chile One",
645
+ address: { street: "Third St", country: "Chile", city: "Santiago" }
646
+ }
647
+ ]
648
+
649
+ for (const data of entityData) {
650
+ const createResp = await testSetup.fastifyInstance.inject({
651
+ method: 'POST',
652
+ url: '/api/person',
653
+ payload: data,
654
+ headers: { Authorization: `Bearer ${accessToken}` }
655
+ })
656
+
657
+ expect(createResp.statusCode).toBe(200)
658
+ }
659
+
660
+ const groupResp = await testSetup.fastifyInstance.inject({
661
+ method: 'GET',
662
+ url: '/api/person/group-by?fields=address.country',
663
+ headers: { Authorization: `Bearer ${accessToken}` }
664
+ })
665
+
666
+ const groupResult = await groupResp.json()
667
+ expect(groupResp.statusCode).toBe(200)
668
+ expect(groupResult).toEqual(expect.arrayContaining([
669
+ expect.objectContaining({
670
+ 'address.country': 'Argentina',
671
+ count: 2
672
+ }),
673
+ expect.objectContaining({
674
+ 'address.country': 'Chile',
675
+ count: 1
676
+ })
677
+ ]))
678
+ })
679
+
564
680
  // 9. Error Handling - Not Found
565
681
  it("should handle error responses correctly when person is not found", async () => {
566
682
  const { accessToken } = await testSetup.rootUserLogin()