1

Is there a way to use project or group in mongo to convert an array of documents or subdocuments into an object with documents ObjectId as key and document/subdocument as value?

For example:

const book = Schema({
    name: {type: String},
    authors: [{type: mongoose.Schema.Types.ObjectId, ref: 'Author'}]
})

const author = Schema({name: {type: String}})

If you query for all books:

Book.find({}).populate('authors').lean().exec()

then you get:

[{
    id: 10,
    name: 'Book 1',
    authors: [{
        id: 1, 
        name: 'Author1'
    }]

},...]

but I would like to have this:

[{
    id: 10,    
    name: 'Book 1',
    authors: {
        1: {id: 1, name: 'Author 1'}
    }
},...]

I know that iterating over the objects after querying from mongo will do it but I guess that running the query at mongo is more efficient.

1

1 Answer 1

2

The main consideration here is that the "keys" you want are actually ObjectId values as defined in your schema and not really a "string", which is actually a requirement for a JavaScript Object since all "keys" must be a "string". For JavaScript this really is not much of an issue since JavScript will "stringify" any argument specified as a "key", but it does matter for BSON, which is what MongoDB actually "speaks"

So you can do this with MongoDB, but you need MongoDB 4.x at least in order to support the $convert aggregation operator or it's shortcut method $toString. This also means that rather than populate(), you actually use MongoDB $lookup:

let results = await Books.aggregate([
  { "$lookup": {
    "from": Author.collection.name,
    "localField": "authors",
    "foreignField": "_id",
    "as": "authors"
  }},
  { "$addFields": {
    "authors": {
      "$arrayToObject": {
         "$map": {
           "input": "$authors",
           "in": { "k": { "$toString": "$$this._id" }, "v": "$$this" }
         }
       }
     }
  }}
])

Or if you prefer the alternate syntax:

let results = await Books.aggregate([
  { "$lookup": {
    "from": "authors",
    "let": { "authors": "$authors" },
    "pipeline": [
      { "$match": { "$expr": { "$in": [ "$_id", "$$authors" ] } } },
      { "$project": {
        "_id": 0,
        "k": { "$toString": "$_id" },
        "v": "$$ROOT"
      }}
    ],
    "as": "authors"
  }},
  { "$addFields": {
    "authors": { "$arrayToObject": "$authors" }
  }}
])

Which would return something like:

{
        "_id" : ObjectId("5c7f046a7cefb8bff9304af8"),
        "name" : "Book 1",
        "authors" : {
                "5c7f042e7cefb8bff9304af7" : {
                        "_id" : ObjectId("5c7f042e7cefb8bff9304af7"),
                        "name" : "Author 1"
                }
        }
}

So the $arrayToObject does the actual "Object" output where you supply it an array of objects with k and v properties corresponding to key and value. But it must have a valid "string" in "k" which is why you $map over the array content to reformat it first.

Or as the alternate, you can $project within the pipeline argument of $lookup instead of using $map later for exactly the same thing.

With client side JavaScript, the translation is a similar process:

let results = await Books.aggregate([
  { "$lookup": {
    "from": Author.collection.name,
    "localField": "authors",
    "foreignField": "_id",
    "as": "authors"
  }},
  /*
  { "$addFields": {
    "authors": {
      "$arrayToObject": {
         "$map": {
           "input": "$authors",
           "in": { "k": { "$toString": "$$this._id" }, "v": "$$this" }
         }
       }
     }
  }}
  */
])

results = results.map(({ authors, ...rest }) => 
  ({
    ...rest, 
    "authors": d.authors.reduce((o,e) => ({ ...o, [e._id.valueOf()]: e }),{})
  })
 )

Or with populate()

let results = await Book.find({}).populate("authors");
results = results.map(({ authors, ...rest }) => 
  ({
    ...rest, 
    "authors": (!authors) ? {} : authors.reduce((o,e) => ({ ...o, [e._id.toString()]: e }),{})
  })
 )

NOTE however that populate() and $lookup are really quite different. MongoDB $lookup is a single request to the server that returns one response. Using populate() actually invokes multiple queries and does the "joining" in client side JavaScript code even if it hides what it is doing from you.

Sign up to request clarification or add additional context in comments.

2 Comments

Neil, you are like a secret ninja taking care for us mere mortals!
@DiegoGallegos There's nothing happening that would cause a problem here if empty i.e []. If there is "no property at all" for authors then you would need $ifNull i.e let: { authors: { $ifNull: [ "$authors", [] ] } } or the same in an intitial $project for the legacy form. Same sort of thing applies for JavaScript array properties as well. (!authors) ? {} : authors.reduce(...

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.