Buscar documentos repetidos en MongoDB con aggregation pipeline

Mongo DB logo

Dejando de lado momentáneamente Angular, vamos a buscar documentos repetidos en MongoDB por un campo en una colección con aggregation pipeline. Yendo más allá del mero hecho de obtener los documentos repetidos, obtendremos el resultado como si fuera una consulta estándar, es decir, misma estructura y campos (db.products.find({})).

Utilizaremos el “aggregation pipeline” para ir añadiendo varios “stages” hasta llegar al resultado esperado.

Caso

Tenemos una colección con varios productos relativos a diferentes aparatos electrónicos/informáticos. Pero nos hemos dado cuenta que algunos están repetidos por el campo “code” que debe de ser único para cada producto – Ooops! Se nos olvidó crear un índice único por este campo :(. Así que antes de crear ese índice debemos averiguar cuáles son los repetidos para eliminarlos o cambiar el valor de su campo «code» o cualquier otra solución.

db.products.insertMany([
   {
      name: "Mouse",
      code: "08001",
      colour: "Black",
      price: 13
    },
    {
      name: "Monitor",
      code: "08002",
      colour: "White",
      price: 20
    },
    {
      name: "HardDisk",
      code: "08003",
      colour: "Silver",
      price: 100
    },
    {
      name: "PenDrive",
      code: "08001",
      colour: "Gray",
      price: 5
    },
     {
      name: "Tablet",
      code: "08002",
      colour: "Blue",
      price: 50
    },
     {
      name: "SmartPhone",
      code: "08002",
      colour: "Black",
      price: 80
    }
])

Así pues tenemos los siguientes repetidos:

  • code “08001”: Mouse, PenDrive
  • code “08002”: Monitor, Tablet, SmartPhone

Por lo tanto queremos que en una consulta aparezcan los artículos repetidos como si fuera una relación con todos sus datos en la consulta raíz.

Stage 1: Agrupación

Como en todas las consultas para saber cuales están repetidos debemos agrupar por el campo que nos interesa, en este caso el campo “code”. Aún así, hay dos cosas a tener en cuenta:

  1. Debemos saber el número de repeticiones ya que en otro stage debemos descartar las que están repetidas de las que no.
  2. Como resultado de la consulta final queremos saber qué productos están repetidos.

Con esas dos premisas, en la agrupación deberemos saber cuántas veces está repetida, es decir, llevar un contador así como arrastrar el identificador de dichos productos repetidos con ese código.

Este stage sería como:

{
    $group: {
        _id: { code: "$code" },
        uniqueIds: { $addToSet: "$_id" },
        count: { $sum: 1 }
    }
}

Vemos que se agrupa por el campo “code”, así como creamos un campo “uniqueIds” donde se irán almacenando los identificadores de cada producto usando “addToSet” para asegurar que los id son únicos. Se podría usar un “push” siempre que sepamos que en ese grupo nunca podrá darse el caso de repetirse. Finalmente, añadimos el contador que nos servirá después para filtrar por la cantidad.

Resultados:

/* 1 */
{
    "_id" : {
        "code" : "08001"
    },
    "uniqueIds" : [ 
        ObjectId("5fdf766f2248ecb270190c9b"), 
        ObjectId("5fdf766f2248ecb270190c9e")
    ],
    "count" : 2.0
}

/* 2 */
{
    "_id" : {
        "code" : "08003"
    },
    "uniqueIds" : [ 
        ObjectId("5fdf766f2248ecb270190c9d")
    ],
    "count" : 1.0
}

/* 3 */
{
    "_id" : {
        "code" : "08002"
    },
    "uniqueIds" : [ 
        ObjectId("5fdf766f2248ecb270190ca0"), 
        ObjectId("5fdf766f2248ecb270190c9c"), 
        ObjectId("5fdf766f2248ecb270190c9f")
    ],
    "count" : 3.0
}

Stage 2: Filtrado de repetidos

Si queremos ver los productos repetidos, se deben descartar aquellos que solo tienen un único producto y quedarnos con el resto, es decir, tenemos que obtener aquellos productos donde el número de repetidos sea mayor que 1. 

Este stage sería como:

{	
    $match: {
        count: { "$gt": 1 }
    }
}

Simplemente, del campo “count” seleccionamos los que sean mayores que 1. También podría ponerse que sea mayor o igual que 2 (count: { «$gte»: 2 }).

Resultados:

/* 1 */
{
    "_id" : {
        "code" : "08001"
    },
    "uniqueIds" : [ 
        ObjectId("5fdf766f2248ecb270190c9b"), 
        ObjectId("5fdf766f2248ecb270190c9e")
    ],
    "count" : 2.0
}

/* 2 */
{
    "_id" : {
        "code" : "08002"
    },
    "uniqueIds" : [ 
        ObjectId("5fdf766f2248ecb270190ca0"), 
        ObjectId("5fdf766f2248ecb270190c9c"), 
        ObjectId("5fdf766f2248ecb270190c9f")
    ],
    "count" : 3.0
}

En muchos blogs he podido ver que aquí se acaba la explicación de obtener los repetidos, pero no queremos tener solo los “id”!!!!, queremos obtener todos los datos de los productos con la misma estructura que  una consulta db.products.find({})

Stage 3: Unwind

Por lo tanto, la lógica nos dice que habría que hacer un “lookup” con su propia tabla, pero antes, convertiremos cada elemento del array en un documento mediante “unwind”:

 { $unwind: "$uniqueIds" }

Resultados:

/* 1 */
{
    "_id" : {
        "code" : "08001"
    },
    "uniqueIds" : ObjectId("5fdf766f2248ecb270190c9b"),
    "count" : 2.0
}

/* 2 */
{
    "_id" : {
        "code" : "08001"
    },
    "uniqueIds" : ObjectId("5fdf766f2248ecb270190c9e"),
    "count" : 2.0
}

/* 3 */
{
    "_id" : {
        "code" : "08002"
    },
    "uniqueIds" : ObjectId("5fdf766f2248ecb270190ca0"),
    "count" : 3.0
}

/* 4 */
{
    "_id" : {
        "code" : "08002"
    },
    "uniqueIds" : ObjectId("5fdf766f2248ecb270190c9c"),
    "count" : 3.0
}

/* 5 */
{
    "_id" : {
        "code" : "08002"
    },
    "uniqueIds" : ObjectId("5fdf766f2248ecb270190c9f"),
    "count" : 3.0
}

Stage 4: Lookup

Ahora podemos hacer un lookup sencillo para obtener todos los datos de los productos:

{ 
    "$lookup": {
        "from": "products",
        "localField": "uniqueIds",
        "foreignField": "_id",
        "as": "repeated"
    }
}

Con esto ya tenemos en la consulta todos los datos de los productos pero nos los devuelve dentro de un campo de tipo array llamado “repeated” y esta estructura no nos vale.

Aunque el campo «repeated» sea un array, en este caso siempre habrá solo 1 elemento.

Resultados:

/* 1 */
{
    "_id" : {
        "code" : "08001"
    },
    "uniqueIds" : ObjectId("5fdf766f2248ecb270190c9b"),
    "count" : 2.0,
    "repeated" : [ 
        {
            "_id" : ObjectId("5fdf766f2248ecb270190c9b"),
            "name" : "Mouse",
            "code" : "08001",
            "colour" : "Black",
            "price" : 13.0
        }
    ]
}

/* 2 */
{
    "_id" : {
        "code" : "08001"
    },
    "uniqueIds" : ObjectId("5fdf766f2248ecb270190c9e"),
    "count" : 2.0,
    "repeated" : [ 
        {
            "_id" : ObjectId("5fdf766f2248ecb270190c9e"),
            "name" : "PenDrive",
            "code" : "08001",
            "colour" : "Gray",
            "price" : 5.0
        }
    ]
}

/* 3 */
{
    "_id" : {
        "code" : "08002"
    },
    "uniqueIds" : ObjectId("5fdf766f2248ecb270190c9c"),
    "count" : 3.0,
    "repeated" : [ 
        {
            "_id" : ObjectId("5fdf766f2248ecb270190c9c"),
            "name" : "Monitor",
            "code" : "08002",
            "colour" : "White",
            "price" : 20.0
        }
    ]
}

/* 4 */
{
    "_id" : {
        "code" : "08002"
    },
    "uniqueIds" : ObjectId("5fdf766f2248ecb270190ca0"),
    "count" : 3.0,
    "repeated" : [ 
        {
            "_id" : ObjectId("5fdf766f2248ecb270190ca0"),
            "name" : "SmartPhone",
            "code" : "08002",
            "colour" : "Black",
            "price" : 80.0
        }
    ]
}

/* 5 */
{
    "_id" : {
        "code" : "08002"
    },
    "uniqueIds" : ObjectId("5fdf766f2248ecb270190c9f"),
    "count" : 3.0,
    "repeated" : [ 
        {
            "_id" : ObjectId("5fdf766f2248ecb270190c9f"),
            "name" : "Tablet",
            "code" : "08002",
            "colour" : "Blue",
            "price" : 50.0
        }
    ]
}

Stage 5: Estructura documento raíz

El último stage nos servirá para cambiar la estructura de respuesta de la consulta, es decir, que el contenido del array repeated esté en la raíz de la consulta.

{ 
    $replaceRoot: { 
        newRoot: { $first: "$repeated" } 
    }
}

También se podría poner en vez de utilizar «$first» usar «$elementAt» de la siguiente manera: $arrayElemAt: [ «$repeated», 0 ].

Resultados:

/* 1 */
{
    "_id" : ObjectId("5fdf766f2248ecb270190c9b"),
    "name" : "Mouse",
    "code" : "08001",
    "colour" : "Black",
    "price" : 13.0
}

/* 2 */
{
    "_id" : ObjectId("5fdf766f2248ecb270190c9e"),
    "name" : "PenDrive",
    "code" : "08001",
    "colour" : "Gray",
    "price" : 5.0
}

/* 3 */
{
    "_id" : ObjectId("5fdf766f2248ecb270190ca0"),
    "name" : "SmartPhone",
    "code" : "08002",
    "colour" : "Black",
    "price" : 80.0
}

/* 4 */
{
    "_id" : ObjectId("5fdf766f2248ecb270190c9c"),
    "name" : "Monitor",
    "code" : "08002",
    "colour" : "White",
    "price" : 20.0
}

/* 5 */
{
    "_id" : ObjectId("5fdf766f2248ecb270190c9f"),
    "name" : "Tablet",
    "code" : "08002",
    "colour" : "Blue",
    "price" : 50.0
}

Consulta final

Como consecuencia, uniendo los stages la consulta final quedaría de la siguiente forma:

db.products.aggregate([
{
    $group: {
        _id: { code: "$code" },
        uniqueIds: { $addToSet: "$_id" },
        count: { $sum: 1 }
    }
}
,
{
    $match: {
        count: { "$gt": 1 }
    }
}
,
{
    $unwind: "$uniqueIds" 
}
,
{
    "$lookup": {
        "from": "products",
        "localField": "uniqueIds",
        "foreignField": "_id",
        "as": "repeated"
    }
}
,
{
    $replaceRoot: { 
        newRoot: { $first: "$repeated" } 
    }
}
])

Esa sería la consulta principal de obtener los repetidos. Se han dejado fuera stages como filtrados (filtrado inicial o final, …) , ordenaciones (del resultado final) o paginaciones (skip, take), etc.

Conclusiones

Con esta consulta espero haber ampliado un poco más las miras para obtener más información de los resultados en una misma estructura y campos como si fuera una consulta estándar.

Qué te ha parecido? Modificarías alguna cosa? Danos tú opinión!

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *