Formation MongoDB M101N: semaine 5 – Aggregation framework

Permet de faire des requêtes proches de $group.

La fonction "aggregate" fonctionne en utilisant des “pipes” comme une ligne unix. Chaque étape du pipeline correspond à des étapes ou “stage”. Il n’y a pas d’ordre imposé pour les étapes, elles peuvent se succéder dans n’importe quel ordre.

Par exemple, les étapes peuvent être $match, $sort, $group etc. Ainsi le pipeline est indiqué par [ ] et les étapes sont séparées par des “,”:

db,collection.aggregate( [  
    { $match : { ... } }, 
    { $group : { ... } }, .... 
] )

Quelques exemples d’étapes:
$project: permet de sélectionner des clés en particulier et de modifier l'aspect d'un champ. La cardinalité est 1:1
$match<: permet de filtrer. Cardinalité n:1
$group: permet d’effectuer des agrégations. Cardinalité n:1
$sort: permet d’ordonner, cardinalité 1:1
$skip: pour sauter plusieurs documents dans les résultats, cardinalité n:1
$limit: pour limiter le nombre de documents dans le résultat, cardinalité n:1
$unwind: permet de convertir un tableau en plusieurs documents, cardinalité 1:n.

Étape $group

Explication du fonctionnement d’une requête d’agrégation:

Par exemple:
Si on prend un exemple avec $group:

db.products.aggregate( [  
{
    $group : 
    { 
        _id: "$manufacturer", 
        num_products: { $sum: 1} 
    } 
} 
] )

Cette requête permet de grouper des produits puis de compter en utilisant la fonction "$sum" par manufacturier.
Pour le champ: _id on utilise un "$" avant le nom du champ “manufacturer” parce qu’on veut que le champ soit valué quand tous les documents seront parcourus.
Le résultat de la requête sera un autre document avec les deux champs "_id" et "num_products".

Pour construire les résultats, la fonction va parcourir tous les documents et construire de nouveaux documents en y ajoutant les champs et en effectuant des "upsert" successifs pour additionner tous les produits.

$group avec _id:null

Il est possible d’effectuer une étape “$group” sans éléments à grouper en utilisant "_id:null".

Ainsi si on veut compter le nombre de documents dans une collection:

db.tables.aggregate([ 
    { $group: { _id: null, count: { $sum: 1 } }} 
])

Si on veut sommer un champ dans des documents:

db.tables.aggregate([ 
    { $group: { _id: null, total: { $sum: "$value" } }} 
])

Agrégation avec plusieurs clés

Pour avoir un clé composée de plusieurs champs c'est-à-dire grouper par plusieurs champs:

db.products.aggregate( [  
{ 
    $group : 
    { 
        _id: { "maker": "$manufacturer", "cat": "$category" }, 
        num_products: { $sum: 1} 
    } 
} 
] )

Dans les documents du résultat, le clé sera formée de 2 valeurs "maker" et "cat". Ces 2 valeurs ne sont pas présentes dans la collection d’origine, ils sont justes utilisés pour former le document contenant les résultats. En revanche, "$manufacturer" et "$category" sont bien des champs valués de collection d’origine.

Attention

Comme pour toutes les collections dans MongoDB, la collection contenant les résultats doit comporter une clé "_id" qui est unique à chaque document.

Autres fonctions possibles de l’étape $group:
$sum: pour additionner ou compter tous les documents,
$avg: pour effectuer une moyenne de valeurs dans les documents,
$min: pour obtenir le minimum,
$max: pour obtenir le maximum,
$push: permet de créer un tableau dans lequel les valeurs seront rajoutées systématiquement à la fin,
$addToSet: permet de créer un tableau dont les valeurs seront uniques,
$first: permet d’indiquer la première valeur parmi une liste de valeurs. Il faut utiliser cette fonction avec “$sort” pour que le résultat soit pertinent.
$last: permet d’indiquer la dernière valeur parmi une liste de valeurs. Il faut utiliser cette fonction avec “$sort” pour que le résultat soit pertinent.

Précision sur la fonction $sum:

Si on souhaite compter les différentes valeurs, on utilisera { $sum: 1 }, par exemple:

db.products.aggregate( [  
{ 
    $group : 
    { 
        _id: { "maker": "$manufacturer", "cat": "$category" }, num_products: { $sum: 1} 
    } 
} 
] )

En revanche, si on veut sommer les valeurs, on utilisera: { $sum: "$price" }, "price" étant le champ à sommer dans la collection d’origine. Par exemple:

db.products.aggregate( [  
{ 
    $group : 
    { 
        _id: { "maker": "$manufacturer", "cat": "$category" }, total: { $sum: "$price" } 
    } 
} 
] )

Précision sur la fonction $avg

La fonction $avg possède une syntaxe similaire à la fonction $sum:

Par exemple pour avoir la moyenne des prix:

db.products.aggregate( [  
{ 
    $group : 
    { 
        _id: { "maker": "$manufacturer", "cat": "$category" }, avg_price: { $avg: "$price" } 
    } 
} 
] )

Précision sur la fonction $addToSet

La syntaxe est similaire aux fonctions précédentes mais le champ dans les documents résultats sera un tableau au lieu d’être une valeur.

Double grouping

On peut effectuer 2 agrégations l’une à la suite de l’autre. Par exemple: si on a une liste de notes indiquant l’élève, la note, la classe et le type de devoir. Si on veut la moyenne par classe, il faut:
– effectuer la moyenne par élève et par classe
– effectuer la moyenne par classe
Pour effectuer la moyenne par élève et par classe:

db.grades.aggregate( [ 
{  
    $group: { _id : { "studentId" : "$student_id", "classId": "$class_id" }, "average" : { $avg : "$score" }} 
} 
])

Pour effectuer la 2ème moyenne en utilisant les résultats de la première requête:

db.grades.aggregate( [ 
{  
    $group: { _id : { "studentId" : "$student_id", "classId": "$class_id" }, "average" : { $avg : "$score" }} 
}, 
{ 
    $group: { _id : "$_id.class_id" , "average" : { $avg : "$average" }} 
} 
] )

Précision sur les fonctions "$first" et "$last"

Exemple:

db.cities.aggregate( [  
{ 
    $group : { _id : { state: "$state", city : "$city" } } 
    { population : { $sum: "$pop" } } 
}
{ 
    $sort : { "_id.state" : 1, "population" : -1 } 
} 
{ 
    $group : { 
        _id : "$_id.state", 
        city : { $first : "$_id.city"}, 
        population : { $first : "$population" } 
} 
} 
] )

Étape $project

Cette fonction permet de :
– supprimer des clés,
– ajouter de nouvelles clés,
– utiliser des fonctions simples comme $toLower, $toUpper, $add ou $multiply.
La cardinalité de cette fonction est 1:1.

Par exemple:

db.prices.aggregate( [ 
{ 
    $project : { 
        _id: 0, 
        "maker" : { $toLower : "$manufacturer" }, 
        "details" : { "category" : "$category" , "price" : { $multiply : [ "$price" : 10 ] }, 
        "item" : "$name", 
        "name" : 1 
    } 
} 
] )

"_id: 0" permet d’indiquer qu’on ne souhaite pas afficher le champ "_id".
Les champs "maker", "details" et “item” définissent les champs qui seront présents dans les documents du résultat mais ils ne sont pas présents dans les documents d’origine.

"name" :1 permet d’indiquer qu’on veut le champ "name" sans modifications.

Étape "$match"

Cette fonction permet de filtrer une collection dans la fonction d’agrégation. La cardinalité est n:1.

Par exemple, si on souhaite filtrer une collection contenant des adresses en ne gardant que les adresses dans l’état de New York:

db.addresses.aggregate( [  
    { $match : { state : "NY" } } 
] )

Pour avoir les codes postaux dont la population est supérieure à 100000:

db.zips.aggregate( [  
    { $match : { pop : { $gt:100000} } } 
] )

Étape "$sort"

Permet d’effectuer un tri. On peut l’utiliser avant ou après l’utilisation de "$group".

ATTENTION

Le tri s’effectue intégralement en mémoire

Exemple:

db.cities.aggregate( [  
{ 
    $group : 
    { _id : { state: "$state", city : "$city" } } 
    { population : { $sum: "$pop" } } 
}, 
{ 
    $sort : { "_id.state" : 1, "population" : -1 } } 
] )

"_id.state" ne comporte pas de "$" puisqu’on ne veut pas une valuation du champ.
"1" permet d’indiquer qu’on souhaite le tri dans le sens croissant. Pour avoir le sens décroissant, on utilise "-1".

Étapes "$skip" et "$limit"

On peut utiliser les 2 fonctions séparément mais la plupart du temps elles sont utilisées toutes les 2 avec la fonction "$sort" et dans l’ordre "$skip" puis "$limit". Sachant que ce sont des fonctions séparées, ce sont des étapes distinctes dans le pipeline.

Par exemple:

db.addresses.aggregate( [  
    { $sort : { state : 1} } 
    { $skip: 10 } 
    { $limit: 5 } 
] )

"$skip" permet de sauter 10 valeurs et "$limit" permet de limiter le nombre de résultat à 5.

Étape "$unwind"

Permet de convertir les éléments dans un tableau en plusieurs documents en reprenant les mêmes valeurs pour les autres champs.
Ainsi si on a un document:

{ a:1, b:1, c: [ "c1", "c2", "c3" ] }

Le résultat sera:

{ a:1, b:1, c: "c1" } 
{ a:1, b:1, c: "c2" } 
{ a:1, b:1, c: "c3" } 

Exemple:

db.posts.aggregate( [ 
    { $unwind: "tags" } 
] )

Double unwind

Si on utilise 2 étapes "$unwind", on effectue le produit cartésien entre toutes les valeurs des 2 tableaux.

Ainsi si on a: { a:1, b:1, c: [ "c1", "c2" ], d: [ "d1", "d2" ] }

Alors:

db.posts.aggregate( [ 
    { $unwind: "c" } 
    { $unwind: "d" } 
] )

On aura:

{ a:1, b:1, c: "c1", d: "d1" } 
{ a:1, b:1, c: "c1", d: "d2" } 
{ a:1, b:1, c: "c2", d: "d1" } 
{ a:1, b:1, c: "c2", d: "d2" } 

Limitations de la fonction "aggregate"

– La limite par étape est de 100 MB. il est possible d’utiliser une option "allowDiskUse" par dépasser cette limite.
– Il existe une limite de 16 MB par document si on renvoie tous les résultats dans un document.
– Dans le cas du "sharding", si on effectue une requête utilisant "group by" ou "sort", tous les documents sont récupérés de tous les “shard” vers le premier “shard” pour être traités. Il existe une fonction “map/reduce” (non recommandé) pour aider dans ce cas.
Il est aussi possible d’utiliser "hadoop".

Agrégations avec le driver .NET

Si on souhaite effectuer la requète:

db.zips.aggregate( [ 
    { $group: { _id: "state", totalPop: { $sum: "$pop" } } }, 
    { $match: { totalPop: { $gte: 10*1000*1000 } } } 
]}

En .NET:
Avec des BsonDocument:

var list = await col.aggregate() 
    .Group(new BsonDocument("_id", "$state").Add("totalPop", new BsonDocument("$sum", "$pop"))) 
    .Match (new BsonDocument("totalPop", new BsonDocument("$gte", 10 * 1000 * 1000))) 
    .ToListAsync(); 

Avec des chaines de caractères directement:

var list = await col.Aggregate() 
    .Group("{ _id: '$state', totalPop: { $sum: '$pop'} }") 
    .Match("{totalPop: {$gte: 10000000}}") 
    .ToListAsync(); 

Avec POCO:

var list = col.Aggregate() 
    .Group(x => x.State, g => new { State = g.Key, TotalPop = g.Sum(x => x.Population) }) 
    .Match(x => x.TotalPop > 10*1000*1000);

Leave a Reply