@drax/crud-back 3.17.0 → 3.17.2

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.
@@ -228,17 +228,29 @@ class AbstractMongoRepository {
228
228
  const schema = this._model.schema;
229
229
  // Construir el objeto de agrupación dinámicamente
230
230
  const groupId = {};
231
- const lookupStages = [];
231
+ const preGroupStages = [];
232
+ const postGroupStages = [];
232
233
  const finalProjectFields = { count: 1, _id: 0 };
233
234
  const refFields = new Set();
234
235
  const dateFields = new Set();
235
236
  const numericFields = new Set();
236
237
  const groupFields = [];
238
+ const groupFieldAliases = new Map();
239
+ const preGroupLookupAliases = new Map();
237
240
  const numericInstances = new Set(['Number', 'Decimal128', 'Double', 'Int32', 'Long', 'BigInt']);
238
241
  const totalGroupFields = fields.filter(field => {
239
242
  const schemaPath = schema.path(field);
240
243
  return !(schemaPath && numericInstances.has(schemaPath.instance));
241
244
  }).length;
245
+ const getGroupFieldAlias = (field) => {
246
+ const existingAlias = groupFieldAliases.get(field);
247
+ if (existingAlias) {
248
+ return existingAlias;
249
+ }
250
+ const newAlias = `field_${groupFieldAliases.size}`;
251
+ groupFieldAliases.set(field, newAlias);
252
+ return newAlias;
253
+ };
242
254
  // Función para obtener el formato de fecha según el nivel de granularidad
243
255
  const getDateFormat = (field, format) => {
244
256
  const formats = {
@@ -293,8 +305,38 @@ class AbstractMongoRepository {
293
305
  };
294
306
  return formats[format] || formats['day'];
295
307
  };
308
+ const ensureLookupForReferencedField = (field, refModel) => {
309
+ const existingAlias = preGroupLookupAliases.get(field);
310
+ if (existingAlias) {
311
+ return existingAlias;
312
+ }
313
+ const refModelInstance = mongoose.model(refModel);
314
+ const collectionName = refModelInstance.collection.name;
315
+ const lookupAlias = `${field}_groupByRef`;
316
+ preGroupStages.push({
317
+ $lookup: {
318
+ from: collectionName,
319
+ localField: field,
320
+ foreignField: '_id',
321
+ as: lookupAlias
322
+ }
323
+ });
324
+ preGroupStages.push({
325
+ $unwind: {
326
+ path: `$${lookupAlias}`,
327
+ preserveNullAndEmptyArrays: true
328
+ }
329
+ });
330
+ preGroupLookupAliases.set(field, lookupAlias);
331
+ return lookupAlias;
332
+ };
296
333
  fields.forEach(field => {
297
334
  const schemaPath = schema.path(field);
335
+ const fieldAlias = getGroupFieldAlias(field);
336
+ const fieldParts = field.split('.');
337
+ const rootField = fieldParts[0];
338
+ const nestedFieldPath = fieldParts.slice(1).join('.');
339
+ const rootSchemaPath = rootField === field ? schemaPath : schema.path(rootField);
298
340
  // Verificar si el campo es numérico: se agregará con $sum y no formará parte de la clave de agrupación
299
341
  if (schemaPath && numericInstances.has(schemaPath.instance)) {
300
342
  numericFields.add(field);
@@ -305,50 +347,54 @@ class AbstractMongoRepository {
305
347
  // Verificar si el campo es de tipo Date
306
348
  if (schemaPath && schemaPath.instance === 'Date') {
307
349
  dateFields.add(field);
308
- groupId[field] = getDateFormat(field, dateFormat);
350
+ groupId[fieldAlias] = getDateFormat(field, dateFormat);
309
351
  }
310
352
  // Verificar si el campo es una referencia
311
353
  else if (schemaPath && schemaPath.options && schemaPath.options.ref) {
312
354
  const refModel = schemaPath.options.ref;
313
- const fieldName = field;
314
355
  refFields.add(field);
315
356
  // Obtener el modelo referenciado y su nombre de colección real
316
357
  const refModelInstance = mongoose.model(refModel);
317
358
  const collectionName = refModelInstance.collection.name;
318
359
  // 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({
360
+ const localField = totalGroupFields === 1 ? '_id' : `_id.${fieldAlias}`;
361
+ postGroupStages.push({
321
362
  $lookup: {
322
363
  from: collectionName,
323
364
  localField: localField,
324
365
  foreignField: '_id',
325
- as: `${fieldName}_populated`
366
+ as: `${fieldAlias}_populated`
326
367
  }
327
368
  });
328
369
  // Unwind para convertir el array en objeto único
329
- lookupStages.push({
370
+ postGroupStages.push({
330
371
  $unwind: {
331
- path: `$${fieldName}_populated`,
372
+ path: `$${fieldAlias}_populated`,
332
373
  preserveNullAndEmptyArrays: true
333
374
  }
334
375
  });
335
376
  // En la proyección final, usar el objeto poblado
336
- finalProjectFields[field] = `$${fieldName}_populated`;
337
- groupId[field] = `$${field}`;
377
+ finalProjectFields[field] = `$${fieldAlias}_populated`;
378
+ groupId[fieldAlias] = `$${field}`;
379
+ }
380
+ else if (nestedFieldPath && rootSchemaPath && rootSchemaPath.options && rootSchemaPath.options.ref) {
381
+ const lookupAlias = ensureLookupForReferencedField(rootField, rootSchemaPath.options.ref);
382
+ groupId[fieldAlias] = `$${lookupAlias}.${nestedFieldPath}`;
338
383
  }
339
384
  else {
340
385
  // Si no es una referencia ni fecha, usar el valor directo
341
- groupId[field] = `$${field}`;
386
+ groupId[fieldAlias] = `$${field}`;
342
387
  }
343
388
  });
344
389
  // Construir la proyección final para campos de fecha
345
390
  groupFields.forEach(field => {
391
+ const fieldAlias = groupFieldAliases.get(field);
346
392
  if (dateFields.has(field)) {
347
393
  if (groupFields.length === 1) {
348
394
  finalProjectFields[field] = `$_id`;
349
395
  }
350
396
  else {
351
- finalProjectFields[field] = `$_id.${field}`;
397
+ finalProjectFields[field] = `$_id.${fieldAlias}`;
352
398
  }
353
399
  }
354
400
  else if (!refFields.has(field)) {
@@ -356,7 +402,7 @@ class AbstractMongoRepository {
356
402
  finalProjectFields[field] = `$_id`;
357
403
  }
358
404
  else {
359
- finalProjectFields[field] = `$_id.${field}`;
405
+ finalProjectFields[field] = `$_id.${fieldAlias}`;
360
406
  }
361
407
  }
362
408
  });
@@ -376,13 +422,14 @@ class AbstractMongoRepository {
376
422
  }
377
423
  const pipeline = [
378
424
  { $match: query },
425
+ ...preGroupStages,
379
426
  {
380
427
  $group: groupStage
381
428
  }
382
429
  ];
383
430
  // Solo agregar lookups si hay campos de referencia
384
- if (lookupStages.length > 0) {
385
- pipeline.push(...lookupStages);
431
+ if (postGroupStages.length > 0) {
432
+ pipeline.push(...postGroupStages);
386
433
  }
387
434
  pipeline.push({
388
435
  $project: finalProjectFields
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.2",
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.2",
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": "832f9362009d55b03e0a51c3f558c439f158d9e6"
51
51
  }
@@ -337,18 +337,32 @@ class AbstractMongoRepository<T, C, U> implements IDraxCrud<T, C, U> {
337
337
 
338
338
  // Construir el objeto de agrupación dinámicamente
339
339
  const groupId: any = {}
340
- const lookupStages: any[] = []
340
+ const preGroupStages: any[] = []
341
+ const postGroupStages: any[] = []
341
342
  const finalProjectFields: any = {count: 1, _id: 0}
342
343
  const refFields = new Set<string>()
343
344
  const dateFields = new Set<string>()
344
345
  const numericFields = new Set<string>()
345
346
  const groupFields: string[] = []
347
+ const groupFieldAliases = new Map<string, string>()
348
+ const preGroupLookupAliases = new Map<string, string>()
346
349
  const numericInstances = new Set(['Number', 'Decimal128', 'Double', 'Int32', 'Long', 'BigInt'])
347
350
  const totalGroupFields = fields.filter(field => {
348
351
  const schemaPath = schema.path(field)
349
352
  return !(schemaPath && numericInstances.has(schemaPath.instance))
350
353
  }).length
351
354
 
355
+ const getGroupFieldAlias = (field: string): string => {
356
+ const existingAlias = groupFieldAliases.get(field)
357
+ if (existingAlias) {
358
+ return existingAlias
359
+ }
360
+
361
+ const newAlias = `field_${groupFieldAliases.size}`
362
+ groupFieldAliases.set(field, newAlias)
363
+ return newAlias
364
+ }
365
+
352
366
  // Función para obtener el formato de fecha según el nivel de granularidad
353
367
  const getDateFormat = (field: string, format: string) => {
354
368
  const formats = {
@@ -404,8 +418,43 @@ class AbstractMongoRepository<T, C, U> implements IDraxCrud<T, C, U> {
404
418
  return formats[format] || formats['day']
405
419
  }
406
420
 
421
+ const ensureLookupForReferencedField = (field: string, refModel: string): string => {
422
+ const existingAlias = preGroupLookupAliases.get(field)
423
+ if (existingAlias) {
424
+ return existingAlias
425
+ }
426
+
427
+ const refModelInstance = mongoose.model(refModel)
428
+ const collectionName = refModelInstance.collection.name
429
+ const lookupAlias = `${field}_groupByRef`
430
+
431
+ preGroupStages.push({
432
+ $lookup: {
433
+ from: collectionName,
434
+ localField: field,
435
+ foreignField: '_id',
436
+ as: lookupAlias
437
+ }
438
+ })
439
+
440
+ preGroupStages.push({
441
+ $unwind: {
442
+ path: `$${lookupAlias}`,
443
+ preserveNullAndEmptyArrays: true
444
+ }
445
+ })
446
+
447
+ preGroupLookupAliases.set(field, lookupAlias)
448
+ return lookupAlias
449
+ }
450
+
407
451
  fields.forEach(field => {
408
452
  const schemaPath = schema.path(field)
453
+ const fieldAlias = getGroupFieldAlias(field)
454
+ const fieldParts = field.split('.')
455
+ const rootField = fieldParts[0]
456
+ const nestedFieldPath = fieldParts.slice(1).join('.')
457
+ const rootSchemaPath = rootField === field ? schemaPath : schema.path(rootField)
409
458
 
410
459
  // Verificar si el campo es numérico: se agregará con $sum y no formará parte de la clave de agrupación
411
460
  if (schemaPath && numericInstances.has(schemaPath.instance)) {
@@ -419,12 +468,11 @@ class AbstractMongoRepository<T, C, U> implements IDraxCrud<T, C, U> {
419
468
  // Verificar si el campo es de tipo Date
420
469
  if (schemaPath && schemaPath.instance === 'Date') {
421
470
  dateFields.add(field)
422
- groupId[field] = getDateFormat(field, dateFormat)
471
+ groupId[fieldAlias] = getDateFormat(field, dateFormat)
423
472
  }
424
473
  // Verificar si el campo es una referencia
425
474
  else if (schemaPath && schemaPath.options && schemaPath.options.ref) {
426
475
  const refModel = schemaPath.options.ref
427
- const fieldName = field
428
476
 
429
477
  refFields.add(field)
430
478
 
@@ -433,47 +481,51 @@ class AbstractMongoRepository<T, C, U> implements IDraxCrud<T, C, U> {
433
481
  const collectionName = refModelInstance.collection.name
434
482
 
435
483
  // Determinar el campo local correcto según si es un solo campo o múltiples
436
- const localField = totalGroupFields === 1 ? '_id' : `_id.${fieldName}`
484
+ const localField = totalGroupFields === 1 ? '_id' : `_id.${fieldAlias}`
437
485
 
438
- lookupStages.push({
486
+ postGroupStages.push({
439
487
  $lookup: {
440
488
  from: collectionName,
441
489
  localField: localField,
442
490
  foreignField: '_id',
443
- as: `${fieldName}_populated`
491
+ as: `${fieldAlias}_populated`
444
492
  }
445
493
  })
446
494
 
447
495
  // Unwind para convertir el array en objeto único
448
- lookupStages.push({
496
+ postGroupStages.push({
449
497
  $unwind: {
450
- path: `$${fieldName}_populated`,
498
+ path: `$${fieldAlias}_populated`,
451
499
  preserveNullAndEmptyArrays: true
452
500
  }
453
501
  })
454
502
 
455
503
  // En la proyección final, usar el objeto poblado
456
- finalProjectFields[field] = `$${fieldName}_populated`
457
- groupId[field] = `$${field}`
504
+ finalProjectFields[field] = `$${fieldAlias}_populated`
505
+ groupId[fieldAlias] = `$${field}`
506
+ } else if (nestedFieldPath && rootSchemaPath && rootSchemaPath.options && rootSchemaPath.options.ref) {
507
+ const lookupAlias = ensureLookupForReferencedField(rootField, rootSchemaPath.options.ref)
508
+ groupId[fieldAlias] = `$${lookupAlias}.${nestedFieldPath}`
458
509
  } else {
459
510
  // Si no es una referencia ni fecha, usar el valor directo
460
- groupId[field] = `$${field}`
511
+ groupId[fieldAlias] = `$${field}`
461
512
  }
462
513
  })
463
514
 
464
515
  // Construir la proyección final para campos de fecha
465
516
  groupFields.forEach(field => {
517
+ const fieldAlias = groupFieldAliases.get(field) as string
466
518
  if (dateFields.has(field)) {
467
519
  if (groupFields.length === 1) {
468
520
  finalProjectFields[field] = `$_id`
469
521
  } else {
470
- finalProjectFields[field] = `$_id.${field}`
522
+ finalProjectFields[field] = `$_id.${fieldAlias}`
471
523
  }
472
524
  } else if (!refFields.has(field)) {
473
525
  if (groupFields.length === 1) {
474
526
  finalProjectFields[field] = `$_id`
475
527
  } else {
476
- finalProjectFields[field] = `$_id.${field}`
528
+ finalProjectFields[field] = `$_id.${fieldAlias}`
477
529
  }
478
530
  }
479
531
  })
@@ -496,14 +548,15 @@ class AbstractMongoRepository<T, C, U> implements IDraxCrud<T, C, U> {
496
548
 
497
549
  const pipeline: any[] = [
498
550
  {$match: query},
551
+ ...preGroupStages,
499
552
  {
500
553
  $group: groupStage
501
554
  }
502
555
  ]
503
556
 
504
557
  // Solo agregar lookups si hay campos de referencia
505
- if (lookupStages.length > 0) {
506
- pipeline.push(...lookupStages)
558
+ if (postGroupStages.length > 0) {
559
+ pipeline.push(...postGroupStages)
507
560
  }
508
561
 
509
562
  pipeline.push(
@@ -561,6 +561,72 @@ 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
+
564
630
  // 9. Error Handling - Not Found
565
631
  it("should handle error responses correctly when person is not found", async () => {
566
632
  const { accessToken } = await testSetup.rootUserLogin()