MongoDB aggregation framework query: trasformare i documenti applicando regole prese da un’altra collection

Su StackOverflow un utente ha postato questa domanda in cui chiedeva supporto per creare, con l’aggregation framework di MongoDB, la query che restituisse il risultato atteso.

Lo scenario è quello in cui ci sono due collection: una che contiene i documenti relativi ad alcune statistiche e un’altra che contiene una serie di regole che devono essere applicate alla prima collection al fine di modificare opportunamente i suoi documenti.

I documenti della collection delle statistiche, che chiamiamo “mystats” sono composti semplicemente da un “id” e da un valore, e rispettano quindi la struttura seguente:

mystats = [
  {
    _id: 1,
    value: 3.0
  },
  {
    _id: 2,
    value: 0.5
  },
  {
    _id: 3,
    value: 10.0
  }
]

I documenti della collection delle regole, che chiamiamo “myrules”, presentano invece una struttura di questo tipo:

myrules = [
  {
    coll: 'mystats',  
    source: '_id',  
    eq: 1,          
    ratio: 2.0
  },
  {
    coll: 'mystats',
    source: '_id',
    eq: 2,
    disable: true
  },
    {
    coll: 'anothercoll',
    source: '_id',
    eq: 3,
	ratio: 10.0 
  }
]

dove:

  • coll: indica la collection alla quale la regola si applica; in questo caso ci interessano sono quelli con valore “mystats”
  • source: indica il campo della collection delle statistiche da utilizzare per il “join”; in questo caso in realtà non ci serve
  • eq: è il campo della collection delle regole da usare per il “join” con i documenti delle statistiche, matchando il suo valore con quello del campo “_id”
  • ratio: è il valore per cui moltiplicare il campo “value” dei documenti della collection mystats che matchano
  • disable: se presente è valorizzato a true e indica che il documento delle statistiche con id uguale a quell’eq non deve essere visualizzato

La richiesta, inoltre, era quella di mostrare in output soltanto soltanto i campi “id” e “value”.

Quindi, dati gli input di esempio indicati, il risultato da mostrare deve essere il seguente:

{ 
    "_id" : 1.0, 
    "value" : 6.0
}

In quanto:

  • Il documento di mystats con id = 1 matcha la regola con eq = 1, che prevede di moltiplicare il value (3.0) per un ratio di 2.0, dando quindi 6.0
  • Il documento di mystats con id = 2 matcha la regola con eq = 2, che presenta il campo disable valorizzato a true e quindi NON deve essere mostrato
  • Il documento di mystats con id = 3 matcha la regola con eq = 3, che però presenta un valore della proprietà “coll” diverso da “mystats” per cui viene scartato dalla selezione

Vediamo quindi come costruire passo per passo con l’aggregation framework di MongoDB la query che restituisce quanto desiderato.
Gli step che dobbiamo eseguire e quindi definire come stage della pipeline di aggregazione sono:

  • Effettuare la “join” tra le due collection tramite uno stage $lookup
  • Splittare il risultato ottenuto in singoli documenti tramite uno stage $unwind
  • Filtrare con uno stage $match solo quelli che non hanno la proprietà “disable” e che hanno la proprietà “coll” uguale a “mystats”
  • Moltiplicare il valore “value” per il “ratio” disponibile nel relativo documento “myrules” tramite l’operatore $multiply
  • Ridurre i campi da visualizzare ai soli “id” e “value” tramite uno stage $project

Procediamo per gradi e iniziamo con il $lookup per mettere in relazione le due collections con l’equivalente di una join tra tabelle in un classico approccio SQL:

db.mystats.aggregate([
   { $lookup:
     {
       from: "myrules",
       localField: "_id",
       foreignField: "eq",
       as: "docs"
     }
   }
])

In questo modo otteniamo, per ogni documento della collection “mystats”, un array di documenti della collection “myrules” che soddisfano la condizione “_id” = “eq”. Chiamiamo la proprietà che contiene questo nuovo array “docs”. In questo caso in realtà, visti i documenti di input di esempio, avremo un solo oggetto nell’array “docs” per ogni documento.

Il risultato di questa prima esecuzione della query parziale sarà il seguente:

{ 
    "_id" : 1.0, 
    "value" : 3.0, 
    "docs" : [
        {
            "_id" : ObjectId("595b87d483051080fed7bcd3"), 
            "coll" : "mystats", 
            "source" : "_id", 
            "eq" : 1.0, 
            "ratio" : 2.0
        }
    ]
}
{ 
    "_id" : 2.0, 
    "value" : 0.5, 
    "docs" : [
        {
            "_id" : ObjectId("595b87d483051080fed7bcd4"), 
            "coll" : "mystats", 
            "source" : "_id", 
            "eq" : 2.0, 
            "disable" : true
        }
    ]
}
{ 
    "_id" : 3.0, 
    "value" : 10.0, 
    "docs" : [
        {
            "_id" : ObjectId("595b87d483051080fed7bcd5"), 
            "coll" : "anothercoll", 
            "source" : "_id", 
            "eq" : 3.0, 
            "ratio" : 5.0
        }
    ]
}

Il tutto è illustrato nello screenshot seguente:
MongoDB aggregation query Lookup stage
Come secondo passo, dobbiamo fare in modo di ottenere un documento “mystats” diverso per ciascuno dei documenti “rules” presenti nell’array “docs”. Questo lo otteniamo grazie ad uno stage $unwind della pipeline di aggregazione di MongoDB. Per aggiungere tale stage modifichiamo la nostra query in questo modo:

db.mystats.aggregate([
   {$lookup:
     {
       from: "myrules",
       localField: "_id",
       foreignField: "eq",
       as: "docs"
     }
   },
   {$unwind:"$docs"}
]
)

Come abbiamo detto, in realtà l’array “docs” conteneva un solo oggetto per ciascun documento di “mystats”, quindi il risultato non cambia molto in questo caso: avremo sempre 3 documenti in output dove però, per ciascuno, la proprietà “docs” non sarà più un array ma un unico oggetto della collection “myrules”.

{ 
    "_id" : 1.0, 
    "value" : 3.0, 
    "docs" : {
        "_id" : ObjectId("595b87d483051080fed7bcd3"), 
        "coll" : "mystats", 
        "source" : "_id", 
        "eq" : 1.0, 
        "ratio" : 2.0
    }
}
{ 
    "_id" : 2.0, 
    "value" : 0.5, 
    "docs" : {
        "_id" : ObjectId("595b87d483051080fed7bcd4"), 
        "coll" : "mystats", 
        "source" : "_id", 
        "eq" : 2.0, 
        "disable" : true
    }
}
{ 
    "_id" : 3.0, 
    "value" : 10.0, 
    "docs" : {
        "_id" : ObjectId("595b87d483051080fed7bcd5"), 
        "coll" : "anothercoll", 
        "source" : "_id", 
        "eq" : 3.0, 
        "ratio" : 5.0
    }
}

MongoDB aggregation query Unwind stage
Ora dobbiamo iniziare ad applicare i filtri alla nostra query e per farlo utilizziamo lo stage della pipeline $match. Le condizioni di selezione che dobbiamo applicare sono due:

  • Il valore della proprietà “coll” del documento “docs” embeddato nel documento della collection “mystats” deve essere uguale a “mystats”
  • Non deve esistere nel documento embeddato la proprietà “disable” (che da specifica quando è presente è sempre valorizzato a true)

Partiamo con la prima condizione, molto semplice, modificando la query in:

db.mystats.aggregate([
   {$lookup:
     {
       from: "myrules",
       localField: "_id",
       foreignField: "eq",
       as: "docs"
     }
   },
   {$unwind:"$docs"},
   {$match: 
     {
       "docs.coll": "mystats"
     }
   }
])

L’output generato è il seguente, in cui possiamo vedere che il numero di documenti restituiti è sceso da 3 a 2 ed è stato rimosso il documento con “id” = 3 che matcha un documento della collection “myrules” che ha la proprietà “coll” valorizzata a “anothercoll” invece che a “mystats”.

{ 
    "_id" : 1.0, 
    "value" : 3.0, 
    "docs" : {
        "_id" : ObjectId("595b87d483051080fed7bcd3"), 
        "coll" : "mystats", 
        "source" : "_id", 
        "eq" : 1.0, 
        "ratio" : 2.0
    }
}
{ 
    "_id" : 2.0, 
    "value" : 0.5, 
    "docs" : {
        "_id" : ObjectId("595b87d483051080fed7bcd4"), 
        "coll" : "mystats", 
        "source" : "_id", 
        "eq" : 2.0, 
        "disable" : true
    }
}

Aggiungiamo ora la seconda condizione di filtro nello stage $match. Per farlo dobbiamo introdurre due operatori: $and e $exists. Il primo, ovviamente, per definire una condizione di AND logico tra le due clausole del filtro e il secondo per determinare se una determinata proprietà esiste o meno in un oggetto.

Raffiniamo quindi la nostra query, che diventa:

db.mystats.aggregate([
   {$lookup:
     {
       from: "myrules",
       localField: "_id",
       foreignField: "eq",
       as: "docs"
     }
   },
   {$unwind:"$docs"},
   {$match: 
     {  $and: 
       	[
       		{"docs.disable":{ "$exists": false}},
       		{"docs.coll": "mystats"}
       	]
     }
   }
])

Questa volta in output otteniamo un solo documento, il seguente:

{ 
    "_id" : 1.0, 
    "value" : 3.0, 
    "docs" : {
        "_id" : ObjectId("595b87d483051080fed7bcd3"), 
        "coll" : "mystats", 
        "source" : "_id", 
        "eq" : 1.0, 
        "ratio" : 2.0
    }
}

Questo perchè il documento con “id” = 2 presenta il campo “disable” e quindi viene eliminato.
MongoDB aggregation query Match stage
Il prossimo step consiste nel moltiplicare il valore della proprietà “value” per l’opportuno “ratio” presente nel documento “docs” embeddato. Questo lo possiamo fare, tramite l’operatore $multiply, direttamente nello stage $project della pipeline di aggregazione, quello cioè in cui selezioniamo i campi da mantenere nei documenti risultanti dalla query.

Modifichiamo la query, ottenendo quella che sarà la nostra pipeline finale di aggregation framework di MongoDB, in questo modo:

db.mystats.aggregate([
   {$lookup:
     {
       from: "myrules",
       localField: "_id",
       foreignField: "eq",
       as: "docs"
     }
   },
   {$unwind:"$docs"},
   {$match: 
     {  $and: 
       	[
       		{"docs.disable":{ "$exists": false}},
       		{"docs.coll": "mystats"}
       	]
     }
   },
   {$project: 
     {
        "_id":"$_id", 
        "value": { $multiply: [ "$value", "$docs.ratio" ] }
     }
    }
]
)

Eseguendola otteniamo, come si può vedere dallo screenshot seguente, il risultato atteso definito a inizio articolo:
MongoDB aggregation query Project stage
Su StackOverflow la risposta, in una versione ridotta e più sintetica di questa, è stata accettata dall’autore della domanda e commentata con “Great! Thank you!”.

Leave a Reply

Your email address will not be published. Required fields are marked *