#100DaysOfMERN - Day 50

Subscribe to my newsletter and never miss my upcoming articles

✏ MongoDB Aggregation: $project stage

This stage takes a collection of documents, and projects each document on a (usually) smaller document. You can include/exclude, rename and restructure fields in this stage. Typically, this stage comes after $match and $group, and is meant to re-format the output documents.

In my understanding, $project is closest to Array.prototype.map, because it doesn't filter out any documents, and doesn't restructure the collection (array), but restructures each document (array item).

In that regard, it's very close to .find({}) with a chained .select(). I've shown yesterday how you can "mimic" the same behaviour with $group if you use it without any accumulators, but that's not really the purpose of $group.

Syntax

The syntax is the following:

<collection>.aggregate([
    { $project: {
        <field1> : 0,
        <field2>: 1,
        <newField>: <expression>
    }
}
]);

The values can be either 0, 1 or an expression:

  • 0 excludes the field
  • 1 includes the field
  • <expression> creates a new field

Some additional rules that apply:

  • the _id field is always implicitly included, unless explicitly excluded
  • if only exclusions are specified, all other fields are implicitly included
  • if only inclusions are specified, all other fields are implicitly excluded
  • if you exclude any field other than _id, you cannot explicitly include any other field

Might sound complicated but it's not, let's see some examples. Here's the model of the collection again that I'm working on:

const IngredientSchema = new Schema(
    {
        name: String,
        amount: Number,
        unit: String
    }
);

const RecipeSchema = new Schema(
    {
        title: String,
        ingredients: [ IngredientSchema ],
        instructions: String,
        info: {
            category: String
            time: {
                preparation: Number,
                cooking: Number,
            }
        }
    }
);

✏ Including/excluding fields

If I'm only interested in a list of recipes with their titles and ingredients, I'd explicitly include those fields:

Recipe.aggregate([
    { $project: { _id: 0, title: 1, ingredients: 1 } }
}
]);

// result
[
  { title: 'Soup', ingredients: [ [Object], [Object], [Object] ] },
  { title: 'Pasta', ingredients: [ [Object], [Object], [Object] ] },
  { title: 'Salad', ingredients: [ [Object], [Object], [Object] ] },
  { title: 'Dessert', ingredients: [ [Object] ] }
]

I could've achieved the same result by explicitly excluding the fields for instructions and info:

Recipe.aggregate([
    { $project: { _id: 0, info: 0, instructions: 0 } }
}
]);

✏ Renaming fields

If I'm only interested in the recipe titles and a list of the ingredient names:

Recipe.aggregate([
    { $project: {
        _id: 0,
        title: 1,
        ingredientsList: '$ingredients.name'
        }
    }
}
]);

// result
[
  { title: 'Soup', ingredientsList: [ 'tomatos', 'salt', 'pepper' ] },
  { title: 'Pasta', ingredientsList: [ 'spaghetti', 'salt', 'pesto' ] },
  { title: 'Salad', ingredientsList: [ 'tomatos', 'oil', 'pepper' ] },
  { title: 'Dessert', ingredientsList: [ 'tomatos' ] }
]

✏ Operators in $project stage

Typical operators in this stage are arithmetic, array and type operators.

$add

To get a list of recipe titles together with the total time (the sum of preparation and cooking time):

Recipe.aggregate([
    { $project: {
        _id: 0,
        title: 1,
        totalTime: { $add: ['$info.time.preparation', '$info.time.cooking'] }
        }
    }
}
]);

// result
[
  { title: 'Soup', totalTime: 17 },
  { title: 'Pasta', totalTime: 9 },
  { title: 'Salad', totalTime: 10 },
  { title: 'Dessert', totalTime: 0 }
]

$size

To get a list of recipe titles along with the number of ingredients for each:

Recipe.aggregate([
    { $project: {
        _id: 0,
        title: 1,
        numIngredients: { $size: '$ingredients' }
        }
    }
}
]);

// result
[
  { title: 'Soup', numIngredients: 3 },
  { title: 'Pasta', numIngredients: 3 },
  { title: 'Salad', numIngredients: 3 },
  { title: 'Dessert', numIngredients: 1 }
]

$toBool

This is a bit of a contrived example, but to list all recipes along with a Boolean to indicate whether they have a preparation time of 0 or > 0:

Recipe.aggregate([
    { $project: {
        _id: 0,
        title: 1,
        prepTime: { $toBool: '$info.time.preparation' } 
        }
    }
}
]);

// result
[
  { title: 'Soup', prepTime: true },
  { title: 'Pasta', prepTime: true },
  { title: 'Salad', prepTime: true },
  { title: 'Dessert', prepTime: false }
]

That's it for the $project stage. Next: handling array fields with $unwind.


✏ Recap

This post covered:

  • MongoDB aggregation: $project stage

✏ Thanks for reading!

I do my best to thoroughly research the things I learn, but if you find any errors or have additions, please leave a comment below, or @ me on Twitter. If you liked this post, I invite you to subsribe to my newsletter. Until next time 👋


✏ Previous Posts

You can find an overview of all previous posts with tags and tag search here:

#100DaysOfMERN - The App

Comments (2)

Pratik Zinjurde's photo

hi can you tell me why this code is not working let user=await User.find({ _id: { $ne: userid } }, {friendsList : {$ne:userid}}).limit(3) im getting this error in postman { "ok": 0, "code": 2, "codeName": "BadValue", "name": "MongoError" }

jsdisco's photo

I'm a little confused about what you're trying to achieve with that query.

The first object passed to .find specifies some search criteria, so you're fetching all users except the one with _id === userid.

The second object specifies which fields to include in the output, equivalent to a $project stage. You're creating a new field "friendsList", and pass in an expression {$ne:userid}.

First thing is, you'd have to pass an array to $ne, specifying which values to compare, something like: { $ne: [ '$friendsList._id', userid ] }

However, I'm not sure if that'll do what you expect, because that will return documents with a "friendsList" field and a Boolean as value.

Generally, I don't think it's possible to filter with $project. Assuming that you do want to filter, put it into the first object. Maybe you're looking for something like this? User.find( { _id: { $ne: userid }, friendsList: { $ne: userid } } )

That would return a list of users where

  • _id !== userid (-> all users except one)
  • userid is not present in the friendsList

So basically a list of people who aren't friends with Mr. Userid.

Hope that answered your question.