MongoDB
 sql >> Database >  >> NoSQL >> MongoDB

Ottieni il sottodocumento più recente da Array

Potresti affrontarlo in un paio di modi diversi. Variano in base all'approccio e alle prestazioni, ovviamente, e penso che ci siano alcune considerazioni più grandi che devi fare al tuo design. In particolare, qui c'è la "necessità" di dati "revisioni" nel modello di utilizzo della tua applicazione effettiva.

Query tramite aggregato

Per quanto riguarda il punto principale per ottenere "l'ultimo elemento dall'array interno", dovresti davvero usare un .aggregate() operazione per fare questo:

function getProject(req,projectId) {

  return new Promise((resolve,reject) => {
    Project.aggregate([
      { "$match": { "project_id": projectId } },
      { "$addFields": {
        "uploaded_files": {
          "$map": {
            "input": "$uploaded_files",
            "as": "f",
            "in": {
              "latest": {
                "$arrayElemAt": [
                  "$$f.history",
                  -1
                ]
              },
              "_id": "$$f._id",
              "display_name": "$$f.display_name"
            }
          }
        }
      }},
      { "$lookup": {
        "from": "owner_collection",
        "localField": "owner",
        "foreignField": "_id",
        "as": "owner"
      }},
      { "$unwind": "$uploaded_files" },
      { "$lookup": {
         "from": "files_collection",
         "localField": "uploaded_files.latest.file",
         "foreignField": "_id",
         "as": "uploaded_files.latest.file"
      }},
      { "$group": {
        "_id": "$_id",
        "project_id": { "$first": "$project_id" },
        "updated_at": { "$first": "$updated_at" },
        "created_at": { "$first": "$created_at" },
        "owner" : { "$first": { "$arrayElemAt": [ "$owner", 0 ] } },
        "name":  { "$first": "$name" },
        "uploaded_files": {
          "$push": {
            "latest": { "$arrayElemAt": [ "$$uploaded_files", 0 ] },
            "_id": "$$uploaded_files._id",
            "display_name": "$$uploaded_files.display_name"
          }
        }
      }}
    ])
    .then(result => {
      if (result.length === 0)
        reject(new createError.NotFound(req.path));
      resolve(result[0])
    })
    .catch(reject)
  })
}

Poiché questa è un'istruzione di aggregazione in cui possiamo anche fare i "join" sul "server" invece di fare richieste aggiuntive (che è ciò che .populate() effettivamente qui ) utilizzando $lookup , mi sto prendendo una certa libertà con i nomi delle raccolte effettive poiché il tuo schema non è incluso nella domanda. Va bene, dal momento che non ti sei reso conto che in effetti potresti farlo in questo modo.

Ovviamente i nomi di raccolta "effettivi" sono richiesti dal server, che non ha il concetto di schema definito "lato applicazione". Ci sono cose che puoi fare per comodità qui, ma ne parleremo più avanti.

Dovresti anche notare che a seconda di dove projectId in realtà viene da, quindi a differenza dei normali metodi mangusta come .find() il $match richiederà effettivamente il "casting" su un ObjectId se il valore di input è infatti una "stringa". Mongoose non può applicare "tipi di schema" in una pipeline di aggregazione, quindi potrebbe essere necessario farlo da solo, soprattutto se projectId proveniva da un parametro di richiesta:

  { "$match": { "project_id": Schema.Types.ObjectId(projectId) } },

La parte fondamentale qui è dove utilizziamo $map per scorrere tutti i "uploaded_files" voci, quindi estrai semplicemente l'"ultimo" da "history" array con $arrayElemAt utilizzando l'"ultimo" indice, che è -1 .

Ciò dovrebbe essere ragionevole poiché è molto probabile che la "revisione più recente" sia in realtà l'"ultima" voce dell'array. Potremmo adattarlo per cercare il "più grande", applicando $max come condizione per $filter . Quindi quella fase della pipeline diventa:

     { "$addFields": {
        "uploaded_files": {
          "$map": {
            "input": "$uploaded_files",
            "as": "f",
            "in": {
              "latest": {
                "$arrayElemAt": [
                   { "$filter": {
                     "input": "$$f.history.revision",
                     "as": "h",
                     "cond": {
                       "$eq": [
                         "$$h",
                         { "$max": "$$f.history.revision" }
                       ]
                     }
                   }},
                   0
                 ]
              },
              "_id": "$$f._id",
              "display_name": "$$f.display_name"
            }
          }
        }
      }},

Che è più o meno la stessa cosa, tranne per il fatto che facciamo il confronto con $max valore e restituisci solo "uno" voce dall'array facendo in modo che l'indice restituisca dall'array "filtrato" la posizione "prima" o 0 indice.

Come per altre tecniche generali sull'utilizzo di $lookup al posto di .populate() , vedi la mia voce su "Query dopo il popolamento in Mongoose" che parla un po' di più di cose che possono essere ottimizzate quando si adotta questo approccio.

Query tramite popola

Inoltre ovviamente possiamo fare (anche se non in modo altrettanto efficiente) lo stesso tipo di operazione usando .populate() chiama e manipola gli array risultanti:

Project.findOne({ "project_id": projectId })
  .populate(populateQuery)
  .lean()
  .then(project => {
    if (project === null) 
      reject(new createError.NotFound(req.path));

      project.uploaded_files = project.uploaded_files.map( f => ({
        latest: f.history.slice(-1)[0],
        _id: f._id,
        display_name: f.display_name
      }));

     resolve(project);
  })
  .catch(reject)

Dove ovviamente stai effettivamente restituendo "tutti" gli articoli da "history" , ma applichiamo semplicemente un .map() per invocare .slice() su quegli elementi per ottenere di nuovo l'ultimo elemento dell'array per ciascuno.

Un po' più di sovraccarico poiché viene restituita tutta la cronologia e .populate() le chiamate sono richieste aggiuntive, ma ottengono gli stessi risultati finali.

Un punto di progettazione

Il problema principale che vedo qui però è che hai anche un array di "storia" all'interno del contenuto. Questa non è davvero una buona idea poiché devi fare cose come sopra per restituire solo l'articolo pertinente che desideri.

Quindi, come "punto di progettazione", non lo farei. Ma invece "separerei" la cronologia dagli elementi in tutti i casi. Mantenendo i documenti "incorporati", manterrei la "storia" in un array separato e manterrei solo la revisione "ultima" con il contenuto effettivo:

{
    "_id" : ObjectId("5935a41f12f3fac949a5f925"),
    "project_id" : 13,
    "updated_at" : ISODate("2017-07-02T22:11:43.426Z"),
    "created_at" : ISODate("2017-06-05T18:34:07.150Z"),
    "owner" : ObjectId("591eea4439e1ce33b47e73c3"),
    "name" : "Demo project",
    "uploaded_files" : [ 
        {
            "latest" : { 
                {
                    "file" : ObjectId("59596f9fb6c89a031019bcae"),
                    "revision" : 1
                }
            },
            "_id" : ObjectId("59596f9fb6c89a031019bcaf"),
            "display_name" : "Example filename.txt"
        }
    ]
    "file_history": [
      { 
        "_id": ObjectId("59596f9fb6c89a031019bcaf"),
        "file": ObjectId("59596f9fb6c89a031019bcae"),
        "revision": 0
    },
    { 
        "_id": ObjectId("59596f9fb6c89a031019bcaf"),
        "file": ObjectId("59596f9fb6c89a031019bcae"),
        "revision": 1
    }

}

Puoi mantenerlo semplicemente impostando $set la voce pertinente e utilizzando $push sulla "storia" nell'unica operazione:

.update(
  { "project_id": projectId, "uploaded_files._id": fileId }
  { 
    "$set": {
      "uploaded_files.$.latest": { 
        "file": revisionId,
        "revision": revisionNum
      }
    },
    "$push": {
      "file_history": {
        "_id": fileId,
        "file": revisionId,
        "revision": revisionNum
      }
    }
  }
)

Con l'array separato, puoi semplicemente interrogare e ottenere sempre l'ultimo e scartare la "storia" fino al momento in cui desideri effettivamente effettuare quella richiesta:

Project.findOne({ "project_id": projectId })
  .select('-file_history')      // The '-' here removes the field from results
  .populate(populateQuery)

Come caso generale, però, semplicemente non mi preoccuperei affatto del numero di "revisione". Mantenendo gran parte della stessa struttura, non ne hai davvero bisogno quando "aggiungi" a un array poiché l'"ultimo" è sempre l'"ultimo". Questo vale anche per la modifica della struttura, dove ancora una volta "l'ultimo" sarà sempre l'ultima voce per il dato file caricato.

Cercare di mantenere un tale indice "artificiale" è pieno di problemi e per lo più rovina qualsiasi modifica delle operazioni "atomiche" come mostrato nel .update() esempio qui, poiché è necessario conoscere un valore di "contatore" per fornire l'ultimo numero di revisione e quindi è necessario "leggerlo" da qualche parte.