Ya sabemos mucho acerca de mongo, hemos insertado datos, añadido indices para hacerlo realmente rápido, y creado nuestras consultas para utilizarlo en nuestra aplicación… pero en algún momento podríamos querer analizar los datos y entender qué tenemos ahí dentro, para sacar provecho de todas esa información y hacer las cosas aún mejor.

Para el análisis de datos, agrupación, ordenaciones, proyecciones, y un montón de otros tratamientos, MongoDB nos entrega una herramienta realmente poderosa: Aggregation Framework. Hoy vamos a ver rápidamente un montón de operaciones que podríamos hacer con él.

Anteriormente

Guía 6: Crear, manejar y entender los índices

Análisis de datos con el Framework de Agregación en MongoDB

Un ejemplo rápido de uso del Aggregation Framework

Cargamos un conjunto de datos (codigos postales) en nuestra base de datos (la base de datos del ejemplo está disponible en la documentación oficial), para ello ejecutamos en una terminal:

> curl http://media.mongodb.org/zips.json > zips.json
> mongoimport -d test -c zips zips.json

connected to: 127.0.0.1  
2015-06-08T19:24:31.236+0200 check 9 29353  
2015-06-08T19:24:31.454+0200 imported 29353 objects

Vemos que pinta tienen los datos, en la shell de mongo consultamos alguno de ellos:

> db.zips.findOne()

{  
"_id": "01001",  
"city": "AGAWAM",  
"loc": [-72.622739, 42.070206],  
"pop": 15338,  
"state": "MA"  
}

Vemos que son códigos postales, con su codigo de ciudad, las coordenadas, la población en ese código postal y el estado al que pertenecen.

Ahora, sobre estos datos, podríamos lanzar una operación de agregación, por ejemplo para ver cuantos códigos postales hay en nuestra base de datos (que debería coincidir con el resultado de la operación count.

> db.zips.aggregate([ {$group: { _id : 0, population:{$sum : 1} }} ])

{ "_id" : 0, "population" : 29353 }

> db.zips.count()

29353

Pipes en Aggregation

Si prestamos atención, nos daremos cuenta de que la operación aggregate recibe un array de operaciones, esto se debe a que el framework de agregación funciona como un pipe de Unix, es decir, se admite la construcción de transformaciones y agrupaciones en distintas etapas que se ejecutarán de forma serializada.

Estas son las etapas admitidas:

Etapa

Descripción

Multiplicidad

$project

Cambia la forma del documento

1:1

$match

Filtra los resultados

n:1

$group

agregación (agrupación)

n:1

$sort

ordenación

1:1

$skip

salta N elementos

n:1

$limit

elige N elementos

n:1

$unwind

normaliza arrays

1:n

$out

cambia nombres, etc

1:1

La multiplicidad se refiere a cuántos documentos obtenemos como resultado despues de aplicar la etapa a N documentos. Por ejemplo, en una ordenación, como tan solo cambia el orden de N documentos, el resultado siguen siendo N documentos pero ordenados de otro modo.

Vamos a conocer los detalles de cada una de estas etapas.

Proyección ($project 1:1)

La proyección permite modificar la representación de los datos, por lo que en general se emplea para darles una nueva forma con la que nos resulte más cómodo trabajar. Por ejemplo, permite elimiar claves, añadir nuevas claves, cambiar su forma, o usar algunas funciones simples de transformación como $toUpper (convertir en mayusculas), $toLower (convertir en minúsculas), $add (sumar) y $multiply (multiplicar).

Se utiliza tambien en las primeras etapas para filtrar atributos y liberar memoria evitando procesar aquella información que no se va a necesitar.

db.zips.aggregate([{  
$project: {  
_id: 0,  
'ciudad': {  
$toLower: "$city"  
},  
'poblacion': {  
'unidades': "$pop",  
'miles': {  
"$multiply": ["$pop", 0.001]  
}  
}  
}  
}])

Resultado:

{  
"ciudad": "cushman",  
"poblacion": {  
"unidades": 36963,  
"miles": 36.963  
}  
}  
{  
"ciudad": "barre",  
"poblacion": {  
"unidades": 4546,  
"miles": 4.546  
}  
}  
...

Nótese que poner un 0 en un valor de campo (como en el caso del _id), hace que el atributo se ignore en el resultado. Si se pone un 1, se utilizará exactamente el mismo valor que tiene en el documento original.

Filtrado ($match n:1)

La etapa match permite filtrar los documentos para que en el resultado de la etapa solo esten aquellos que cumplen ciertos criterios. Permite filtrar antes o despues de agregar los resultados, en función del orden en que definamos esta etapa.

Por ejemplo, devolver unicamente los codigos postales del estado de California (CA)

db.zips.aggregate([{  
$match: {  
state: "CA"  
}  
}])

Agrupación ($group n:1)

La agrupación permite agrupar distintos documentos según compartan el valor de uno o varios de sus atributos, y realizar operaciones de agregación sobre los elementos de cada uno de los grupos. Esta etapa es rica en operadores, vamos a ver algunos ejemplos.

Operador $sum

Suma el valor de los atributos de los documentos del mismo grupo. Por ejemplo, para obtener, la población total de cada estado, y el número de ciudades que hay en el estado, para cada uno de los estados, basta por agrupar por estado y aplicar la clausula sum convenientemente.

db.zips.aggregate([{  
$group: {  
_id: "$state",  
state_pop: {  
$sum: "$pop"  
},  
state_cities: {  
$sum: 1  
}  
}  
}])

Resultado:

{  
"_id": "WA",  
"state_pop": 4866692,  
"state_cities": 484  
}  
{  
"_id": "HI",  
"state_pop": 1108229,  
"state_cities": 80  
}  

Operador $avg

Obtiene la media de los valores de un atributo en el grupo. Por ejemplo, para calcular la población media de las ciudades de un estado.

db.zips.aggregate([{  
$group: {  
_id: "$state",  
avg_pop: {  
$avg: "$pop"  
}  
}  
}])

Resultado:

{  
"_id": "WA",  
"avg_pop": 10055.148760330578  
} {  
"_id": "HI",  
"avg_pop": 13852.8625  
} {  
"_id": "CA",  
"avg_pop": 19627.236147757256  
}
Operador $addToSet

Es un operador un tanto raro, y no tiene equivalencia en SQL. Su objetivo crear un array con todos los valores distintos que toma un atributo en un grupo de documentos. Por ejemplo, la siguiente operación daria como resultado un documento que tendría un atributo categorías con todas las ciudades de cada estado.

db.zips.aggregate([{  
$group: {  
_id: "$state",  
cities: {  
$addToSet: "$city"  
}  
}  
}])
Operador $push

Funciona igual que addToSet pero no garantiza que el elemento no vaya aparecer más de una vez si es que hay dos documentos con el mismo valor en el grupo.

Operadores $max y $min

Permiten obtener el valor máximo o el mínimo del grupo de documentos para alguno de sus atributos. Por ejemplo, para obtener la mayor población para cada estado.

db.zips.aggregate([{  
$group: {  
_id: "$state",  
max_pop: {  
$max: "$pop"  
}  
}  
}])
Agrupación compuesta

Nos permite agrupar por más de un atributo, esto es, como si agrupamos por subcategorías. Por ejemplo, para obtener la población de cada una de las ciudades de un estado.

db.zips.aggregate([{  
$group: {  
_id: {  
"state": "$state",  
"city": "$city"  
},  
state_pop: {  
$sum: "$pop"  
},  
state_cities: {  
$sum: 1  
}  
}  
}])

Resultado:

{  
"_id": {  
"state": "AK",  
"city": "CRAIG"  
},  
"state_pop": 1398,  
"state_cities": 1  
} {  
"_id": {  
"state": "AK",  
"city": "KETCHIKAN"  
},  
"state_pop": 14308,  
"state_cities": 2  
}
Agrupación múltiple

Es algo muy llamativo para los que vienen de SQL, ya que no existe y a veces es muy bienvenido. La agrupación múltiple nos permite ejecutar la fase de agregación más de una vez, es decir, la salida de la primera agregación es la entrada de la siguiente.

Por ejemplo, si quisieramos, podríamos obtener la media de la población de cada ciudad, y luego obtener la media de las media para cada estado.

db.zips.aggregate([{  
"$group": {  
_id: {  
state: "$state",  
city: "$city"  
},  
"average": {  
"$avg": "$pop"  
}  
}  
}, {  
"$group": {  
_id: "$_id.state",  
"average": {  
"$avg": "$average"  
}  
}  
}])

Resultado:

{  
"_id": "RI",  
"average": 12835.16868131868  
} {  
"_id": "FL",  
"average": 13097.2471511727  
}

Ordenación ($sort 1:1)

Ordena los documentos resultantes. Intentará ordenar en memoria (con un límite de 100mb por etapa del pipeline) y en caso de no tener memoria suficiente, realizará una ordenación en disco.

En el ejemplo se muestra como podríamos obtener los documentos ordenados por población (en orden descendente) y estado (ascendente)

db.zips.aggregate([{  
$sort: {  
pop: -1,  
state: 1  
}  
}])

Paginación ($skip y $limit)

En éste caso, a diferencia de cuando se utilizan en clausulas find, sí importará el orden en el que aparece la etapa en el pipeline. El resultado es que podremos ignorar los primero N elementos (skip) y obtener solo los N primero (limit).

Por ejemplo, podemos ignorar los 10 primeros y devolver los 5 siguientes (es decir, obtendríamos los elementos del 11 al 15.

db.zips.aggregate([{  
$skip: 10  
}, {  
$limit: 5  
}])

Primero y último ($first y $last)

Nos permiten obtener el primer y el último elemento de un grupo. Para que esta operación tenga sentido debe combinarse con la ordenación… (de otro modo sería impredecible).

Por ejemplo, a continuación usamos el pipeline para obtener la ciudad con mayor población de cada estado:

1 - Obtenemos la población de cada ciudad de cada estado

2 - Ordenamos por estado (ascendente) y población (descendente, la ciudad con mayor población quedará arriba en cada bloque de estados)

3 - Agrupamos por estado, obteniendo el primero de cada grupo.

db.zips.aggregate([  
//obtiene la poblacion de cada ciudad de cada estado  
{  
$group: {  
_id: {  
state: "$state",  
city: "$city"  
},  
population: {  
$sum: "$pop"  
}  
}  
},  
//ordenamos por estado, poblacion  
{  
$sort: {  
"_id.state": 1,  
"population": -1  
}  
},  
//agrupamos por estado, obtenemos el primero de cada grupo  
{  
$group: {  
_id: "$_id.state",  
city: {  
$first: "$_id.city"  
},  
population: {  
$first: "$population"  
}  
}  
}  
])

Resultado:

{  
"_id": "WV",  
"city": "HUNTINGTON",  
"population": 75343  
} {  
"_id": "WA",  
"city": "SEATTLE",  
"population": 520096  
}

Operación Unwind ($unwind)

Permite agrupar sobre elementos de un array, para “aplanarlo”. Por ejemplo, para el siguiente documento:

{ a:1, b:2, c:[pera, manzana]}

Después de aplicar unwind quedarían los siguientes documentos resultantes:

{a:1, b:2, c:pera}
{a:1, b:2, c:manzana}

Dado que esta etapa es la única cuya salida puede tener un mayor número de documentos que la entrada proporcionada, hay que usarla con cuidado para no sobrecargar la memoria, e intentar aplicar todos los filtros posibles antes a nuestros documentos para reducir los resultados potenciales. La sintaxis es la siguiente:

{“$unwind”;:”;$c”;}

Después es posible recuperar el array empleando $push como se ha visto más arriba. También es posible hacer un doble $unwind (en pipelines consecutivos) para aplanar datos que tengan dos arrays, por ejemplo (haciendo el producto cartesiano de todos ellos)

Limitaciones de Aggregation framework

Existen algunas características que hay que tener en consideración.

  • Hay un límite de 100mb de tamaño de pipeline para cada etapa durante el procesado, a menos que establezcamos el atributo allowDiskUse en la ejecución, lo que lo hará más lento.
  • Hay 16mb de límite de tamaño para la salida de cada pipeline, ya que se maneja como un único documento. Se puede evitar obteniendo un cursor y recorriendo el cursor elemento a elemento.
  • Sharding: cuando se usa group o sort, todos los datos se reunen en un solo shard (el pricipal) antes de continuar procesando los pipes. Para eso se puede importar el mongo en Hadoop (con Hadoop connector), ya que con mapreduce tiene mejor escalabilidad.
  • Otros ejemplos

    Para comprender mejor el Aggregation Framework lo mejor es realizar algunos ejercicios, o analizar ejemplos para intentar averiguar como hacen lo que hacen. A continuación planteo algunos escenarios.

    Ejemplo 1

    Obtener la población total de las ciudades cuyo nombre empieza por A o B

    db.zips.aggregate([{  
    $project: {  
    _id: 1,  
    first_char: {  
    $substr: ["$city", 0, 1]  
    },  
    pop: 1  
    }  
    }, {  
    $match: {  
    "first_char": {  
    $in: ["A", "B"]  
    }  
    }  
    }, {  
    $group: {  
    _id: 0,  
    total: {  
    $sum: "$pop"  
    }  
    }  
    }])
    

    Ejemplo 2

    Obtenemos la media de población de entre todas las ciudades de Nueva York y California cuya población supera los 25000 ciudadanos.

    db.zips.aggregate([{  
    $match: {  
    "state": {  
    $in: ["CA", "NY"]  
    }  
    }  
    }, {  
    $group: {  
    _id: {  
    "state": "$state",  
    "city": "$city"  
    },  
    sum_pop: {  
    $sum: "$pop"  
    }  
    }  
    }, {  
    $match: {  
    "sum_pop": {  
    $gt: 25000  
    }  
    }  
    }, {  
    $group: {  
    _id: null,  
    avg: {  
    $avg: "$sum_pop"  
    }  
    }  
    }, ])
    

    Ejemplo 3

    Obtenemos los usuarios que más y menos comentarios han hecho de entre todos los posts de un blog. Ojo al uso de la clausula unwind para crear un documento por cada comentario del cada post (que inicialmente estarían en un array dentro de el post correspondiente).

    db.posts.aggregate([{  
    $unwind: "$comments"  
    }, {  
    $group: {  
    _id: "$comments.author",  
    count: {  
    $sum: 1  
    }  
    }  
    }, {  
    $sort: {  
    "count": 1  
    }  
    }, {  
    $group: {  
    _id: null,  
    name_mas: {  
    $last: "$_id"  
    },  
    number_mas: {  
    $last: "$count"  
    },  
    name_menos: {  
    $first: "$_id"  
    },  
    number_menos: {  
    $first: "$count"  
    }  
    }  
    }])
    

    Enlaces a la guía completa