Connecting the Database
DATABASE=mongodb+srv://hitarth:<PASSWORD>@cluster0.kytu52c.[](mongodb.net/natours?)retryWrites=true&w=majority&appName=Cluster0
natours
in the above is the name of the database that we want to connect to. See the image below as well.However, if we are using a local database instead of an online one then we can use this instead :
DATABASE_LOCAL=mongodb://localhost:27017
DATABASE_LOCAL=mongodb://localhost:27017/natours
Server.js
for Atlas / Local DatabaseFollowing things are required :
const mongoose = require('mongoose');
const DB = process.env.DATABASE.replace(
'<PASSWORD>',
process.env.DATABASE_PASSWORD,
);
/* ----------------- For Atlas Database ----------------- */
// the connect method returns a promise so we can use "then" to handle the promise
mongoose
.connect(DB, {
useNewUrlParser: true,
useCreateIndex: true,
useFindAndModify: false,
})
.then((con) => {
console.log(con.connections);
console.log('DB connection successful');
/* ------------------------------------------------------ */
});
/* ----------------- For Local Database ----------------- */
mongoose
.connect(process.env.DATABASE_LOCAL, {
useNewUrlParser: true,
useCreateIndex: true,
useFindAndModify: false,
})
.then((con) => {
console.log(con.connections);
console.log('DB connection successful');
});
/* ------------------------------------------------------ */
Server.js
file after configuration :const mongoose = require('mongoose');
const dotenv = require('dotenv');
const app = require('./app');
dotenv.config({ path: './config.env' }); // this will read all the config from the file and save them into node js enviornment variables
// console.log(process.env); // logs all the env variables to the console
console.log(process.env.NODE_ENV);
const DB = process.env.DATABASE.replace(
'<PASSWORD>',
process.env.DATABASE_PASSWORD,
);
/* ----------------- For Atlas Database ----------------- */
// the connect method returns a promise so we can use "then" to handle the promise
mongoose
.connect(DB, {
useNewUrlParser: true,
useCreateIndex: true,
useFindAndModify: false,
})
.then((con) => {
console.log(con.connections);
console.log('DB connection successful');
});
/* ------------------------------------------------------ */
/* ----------------- For Local Database ----------------- */
// mongoose
// .connect(process.env.DATABASE_LOCAL, {
// useNewUrlParser: true,
// useCreateIndex: true,
// useFindAndModify: false,
// })
// .then((con) => {
// console.log(con.connections);
// console.log('DB connection successful');
// });
/* ------------------------------------------------------ */
/* ------------------ Start the server ------------------ */
// const port = 3000;
// listen for requests
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`App is running on port ' ${port}`);
});
/* ------------------------------------------------------ */
Creating a schema :
const tourSchema = new mongoose.Schema({
name: {
type: String,
required: [true, 'Tour Must have a name'], // value and error message, required is a VALIDATOR
unique: true,
},
rating: {
type: Number,
default: 4.5,
},
price: {
type: Number,
required: [true, 'A tour must have a price'],
},
});
Creating a model :
model
is called a Document
const Tour = mongoose.model('Tour', tourSchema); // this will create a model named "Tour" from the schema
// it is a convention to start the model name with a capital letter
tours
(notice that this is a plural name) from the keyword Tour
above.const testTour = new Tour({
name: 'The Forest Hiker',
rating: 4.7,
price: 497,
});
testTour
.save()
.then((doc) => console.log(doc))
.catch((err) => {
console.log('ERROR 💥⚠️', err);
}); // this will save the document to the database
// this save method returns a promise so we can use "then" to handle the promise
tourSchema
and Tour
model in a separate folder (models
) in a file named tourModel.js
Tour
model.tourController
tourController.js
Creating and Saving Documents. Using Async Await for promises.
Inside the
toursController
:
exports.getTour = (req, res) => {
console.log(req.params);
// const id = req.params.id * 1;
// const tour = tours.find((el) => el.id === id);
// res.status(200).json({
// status: 'success',
// data: {
// tour,
// },
// });
};
// async below means that this is a special function which will keep running in the background while performing the code that is inside it while the rest of the code keeps running in the event loop. This async function will not block the event loop.
/* const data = await someFunctionPromise(); */ // → this await will stop the code at this point until the promise "someFunctionPromise" is resolved. Once the promise is returned then the code is allowed to run again and the data get's stored in the "data" variable.
exports.createTour = async (req, res) => {
try {
/* const newTours = new Tour({});
newTours.save(); */
// this save method returns a promise
/* OR */
const newTour = await Tour.create(req.body);
res.status(201).json({
status: 'success',
data: {
tour: newTour,
},
});
} catch (err) {
res.status(400).json({
status: 'failed',
message: err,
});
}
// catch has access to the error object. Now, what do we put here ? Remember that an error can occur because of validation error (not defining the required fields), it is one of the errors that will get catched here.
// If we tried to create a document without the defining the required fields then the promise "Tour.create(req.body)" will be rejected, then it will enter the catch block.
};
duration
and difficulty
are not added to the final document, this is because these two fields are not in the schema, therefore they are not put in the database.Tours.find();
returns a promise, so we'll have to use async await here as well.exports.getAllTours = async (req, res) => {
try {
const tours = await Tour.find();
res.status(200).json({
status: 'success',
results: tours.length,
data: {
tours,
},
});
} catch (err) {
res.status(404).json({
status: 'fail',
message: err,
});
}
};
_id
field hereNow this ID will come in the form as seen in the router below in tourRoutes.js
/:id
in the highlighted line in the image above.tourController.js
we'll use this id
param using req.params.id
: exports.getTour = async (req, res) => {
try {
const tour = await Tour.findById(req.params.id);
// Tour.findOne({ _id: req.params.id }); // this works the same as the above line. findById() is just a helper function.
res.status(200).json({
status: 'success',
data: {
tour,
},
});
} catch (err) {
res.status(404).json({
status: 'fail',
message: err,
});
}
};
exports.updateTour = async (req, res) => {
try {
// req.body contains the updated tour
const tour = await Tour.findByIdAndUpdate(req.params.id, req.body, {
new: true, // sends back the updated tour to back to the client
runValidators: true, // runs the validators again
});
res.status(200).json({
status: 'success',
data: {
tour,
},
});
} catch (err) {
res.status(404).json({
status: 'fail',
message: err,
});
}
};
exports.deleteTour = async (req, res) => {
try {
await Tour.findByIdAndDelete(req.params.id);
res.status(204).json({
status: 'success',
data: null,
});
} catch (err) {
res.status(404).json({
status: 'fail',
message: err,
});
}
};
json
file on to a mongo
server :This will be an independent script.
node .\dev-data\data\import-dev-data.js --import
returns the following when we doconsole.log(process.argv)
:
we can see that an array is logged and the--import
is at the index[2]
.
const mongoose = require('mongoose');
const dotenv = require('dotenv');
const fs = require('fs');
const Tour = require('../../models/tourModel');
dotenv.config({ path: './config.env' }); // we use . and not ./.. because we are in the root folder of the project and not in the dev-data folder
const DB = process.env.DATABASE.replace(
'<PASSWORD>',
process.env.DATABASE_PASSWORD,
);
mongoose
.connect(DB, {
useNewUrlParser: true,
useCreateIndex: true,
useFindAndModify: false,
})
.then((con) => {
console.log(con.connections);
console.log('DB "import" connection successful');
});
// Read JSON File
const tours = JSON.parse(
fs.readFileSync(`${__dirname}/tours-simple.json`, 'utf-8'), // we use __dirname to get the current directory of the file we are in
);
// Import data into DB
const importData = async () => {
try {
await Tour.create(tours); // .create method can also take in an array of objects to create multiple documents
console.log('Data successfully loaded');
process.exit();
} catch (err) {
console.log(err);
}
};
// Delete all data from DB
const deleteData = async () => {
try {
await Tour.deleteMany();
console.log('Data successfully deleted');
process.exit();
} catch (err) {
console.log(err);
}
};
// node .\dev-data\data\import-dev-data.js --import
if (process.argv[2] === '--import') {
importData();
} else if (process.argv[2] === '--delete') {
deleteData();
}
console.log(process.argv);
then we use node .\dev-data\data\import-dev-data.js --import
or node .\dev-data\data\import-dev-data.js --delete
to perform the required action.
semi code block at the end of this topic
This is made very easy because of express.
>, >=, <, <=
operators :Remember the syntax that we use for filtering :
{ difficulty: 'easy', duration: { $gte: 5 } }
The query string will be written like this :
127.0.0.1:3000/api/v1/tours?duration[gte]=5&difficulty=easy // [gte] is the operator
On using the above URL and doing console.log(req.query);
we get this :
$
symbol before gte
is missing.To add the
$
sign we do this :
let queryStr = JSON.stringify(queryObj);
queryStr = queryStr.replace(/\b(gte|gt|lte|lt)\b/g, (match) => `$${match}`); // → this will add a $ sign in front of the gte, gt, lte, lt // we are using 'b' to match the exact word and not the part of the word and 'g' to match all the occurences of the word
127.0.0.1:3000/api/v1/tours?sort=price
console.log(req.query)
// 3) Sorting
if (req.query.sort) {
query = query.sort(req.query.sort);
}
-
symbol before price
: 127.0.0.1:3000/api/v1/tours?sort=-price
Now, what if two or more documents have the same price ? We can then specify other values to sort them with, like this :
127.0.0.1:3000/api/v1/tours?sort=price,ratingsAverage
// 3) Sorting
if (req.query.sort) {
const sortBy = req.query.sort.split(',').join(' ');
console.log(sortBy);
query = query.sort(sortBy);
}
Only displaying the data that the user requires.
127.0.0.1:3000/api/v1/tours?fields=name,duration,price
// 4) Field Limiting
if (req.query.fields) {
// console.log(req.query); // { fields: 'name,duration,price' }
// mongoose requires fields names separated by spaces
const fields = req.query.fields.split(',').join(' ');
// query = query.select('name duration price'); // this is called projecting
query = query.select(fields);
}
else {
query = query.select('-__v'); // excluding the __v field // we add the - sign to exclude the "__v" field
}
Suppose we have fields containing sensitive information like passwords then we can prevent them from being displayed in the JSON API using select: false
property in the schema Model
(here tourModel
).
127.0.0.1:3000/api/v1/tours?page=2&limit=10
limit
basically means the amount of results that we want per page.skip
: it the amount of documents that we want to skip before we start reading the documents./* ------------------------------------------------------ */
// 5) Pagination
const page = req.query.page * 1 || 1; // default value is 1, '*1' is used to convert the string to a number
const limit = req.query.limit * 1 || 100; // default value is 100
const skip = (page - 1) * limit;
// skip: is the amount of documents that we want to skip before we start reading the documents
// page=2&limit=10 → 1-10, page 1; 11-20, page 2; 21-30, page 3
// query = query.skip(2).limit(2);
query = query.skip(skip).limit(limit);
if (req.query.page) {
const numTours = await Tour.countDocuments(); // This returns the number of documents (it actually returns a promise)
if (skip >= numTours) throw new Error('This page does not exist');
// we can throw an error here because we are already in the "try" block, so, if an error is thrown we'll immediately reach the catch block where the error message will be processed.
}
/* ------------------------------------------------------ */
// Execute Query
const tours = await query;
Another feature that we can add to our API is to provide an alias route to a request that might be very popular and gets requested a lot of times.
URL for the "5 best cheapest tours" : 127.0.0.1:3000/api/v1/tours?limit=5&sort=-ratingsAverage, price
router.route('/top-5-cheap').get(tourController.getAllTours);
handler.(full tourRoutes.js
) below :
const express = require('express');
const fs = require('fs');
const tourController = require('../controllers/tourController'); // all of the functions that we have in the tourController.js file are now in the tourController object
// we can also use destructuring to import the functions like this :
// const { getAllTours, getTour, createTour, updateTour, deleteTour } = require('./../controllers/tourController'); and then we can use them directly in the router.route() method
const router = express.Router(); // we can use tourRouter as a middleware
// here val is the value of the parameter in question
// router.param('id', tourController.checkID);
router
.route('/top-5-cheap')
.get(tourController.aliasTopTours, tourController.getAllTours);
router
.route('/') // here we don't have to specify the full path, because we already did that when we mounted the router
.get(tourController.getAllTours)
// .post(tourController.checkBody, tourController.createTour);
.post(tourController.createTour);
router
.route('/:id')
.get(tourController.getTour)
.patch(tourController.updateTour)
.delete(tourController.deleteTour);
// exporting the router
module.exports = router;
In tourRoutes.js
we'll do :
router
.route('/top-5-cheap')
.get(tourController.aliasTopTours, tourController.getAllTours);
aliasTopTours
is responsible for what data is going to be passed out to .getAllTours
Might refer to 2. Express > Multiple Middleware functions as well.
As soon as we hit the /top-5-cheap
route, the first middleware that's going to be run is the aliasTopTours
.
So what this is going to do is that it will set the following properties of the query object to these values that we specified here. Basically prefilling parts of the query object before we then reach the getAllTours
handler.
Therefore, as soon as we reach the getAllTours
function, the query object is already prefilled even if the user didn't put any of these parameters in the query string.
exports.aliasTopTours = (req, res, next) => {
req.query.limit = '5';
req.query.sort = '-ratingsAverage, price';
req.query.fields = 'name, price, ratingsAverage, summary, difficulty';
next();
};
tourController.js
const fs = require('fs');
const Tour = require('../models/tourModel');
/* ------------------------------------------------------ */
// const tours = JSON.parse(
// fs.readFileSync(`${__dirname}./../dev-data/data/tours-simple.json`),
// );
// exports.checkID = (req, res, next, val) => {
// if (req.params.id * 1 > tours.length) {
// console.log(`Tour id is : ${val}`);
// // if we do not have this return here, then express would send this response back (res.status wala response), then it will hit the next() function and then it will send the response again, which will result in an error
// // hence if the return statement is executed the next() function will not be executed
// return res.status(404).json({
// status: 'fail',
// message: 'Invalid ID',
// });
// }
// next();
// };
/* ------------------------------------------------------ */
// exports.checkBody = (req, res, next) => {
// if (!req.body.name || !req.body.price) {
// return res.status(400).json({
// status: 'fail',
// message: 'Missing name or price',
// });
// }
// next();
// };
class APIFeatures {
constructor(query, queryString) {
this.query = query;
this.queryString = queryString;
}
filter() {
}
}
exports.aliasTopTours = (req, res, next) => {
req.query.limit = '5';
req.query.sort = '-ratingsAverage, price';
req.query.fields = 'name, price, ratingsAverage, summary, difficulty';
next();
};
exports.getAllTours = async (req, res) => {
try {
console.log(req.query);
/* ------------------------------------------------------ */
// Method 1 of filtering the data
/* const tours = await Tour.find({
duration: 5,
difficulty: 'easy',
}); */
// Method 1.1 of filtering the data : using the query string
// const tours = await Tour.find(queryObj); // → this will return all the tours that match the query string
// Method 2 of filtering the data :
/* const tours = await Tour.find()
.where('duration')
.equals(5)
.where('difficulty')
.equals('easy'); */
/* ------------------------------------------------------ */
// BUILD QUERY
// 1) Filtering
const queryObj = { ...req.query };
const excludedFields = ['page', 'sort', 'limit', 'fields'];
excludedFields.forEach((el) => delete queryObj[el]);
/* ------------------------------------------------------ */
// 2) Advanced Filtering
let queryStr = JSON.stringify(queryObj);
queryStr = queryStr.replace(/\b(gte|gt|lte|lt)\b/g, (match) => `$${match}`); // → this will add a $ sign in front of the gte, gt, lte, lt // we are using 'b' to match the exact word and not the part of the word and 'g' to match all the occurrences of the word
let query = Tour.find(JSON.parse(queryStr));
/* ------------------------------------------------------ */
// 3) Sorting
if (req.query.sort) {
const sortBy = req.query.sort.split(',').join(' ');
console.log(sortBy);
query = query.sort(sortBy);
}
// else block below is commented as it messes up pagination for some reason ("createdAt" same hai har document me)
// else {
// query = query.sort('-createdAt');
// }
/* ------------------------------------------------------ */
// 4) Field Limiting
if (req.query.fields) {
// console.log(req.query); // { fields: 'name,duration,price' }
// mongoose requires fields names separated by spaces
const fields = req.query.fields.split(',').join(' ');
// query = query.select('name duration price'); // this is called projecting
query = query.select(fields);
} else {
query = query.select('-__v'); // excluding the __v field // we add the - sign to exclude the "__v" field
}
/* ------------------------------------------------------ */
// 5) Pagination
const page = req.query.page * 1 || 1; // default value is 1, '*1' is used to convert the string to a number
const limit = req.query.limit * 1 || 100; // default value is 100
const skip = (page - 1) * limit;
// skip: is the amount of documents that we want to skip before we start reading the documents
// page=2&limit=10 → 1-10, page 1; 11-20, page 2; 21-30, page 3
// query = query.skip(2).limit(2);
query = query.skip(skip).limit(limit);
if (req.query.page) {
const numTours = await Tour.countDocuments(); // This returns the number of documents (it actually returns a promise)
if (skip >= numTours) throw new Error('This page does not exist');
// we can throw an error here because we are already in the "try" block, so, if an error is thrown we'll immediately reach the catch block where the error message will be processed.
}
/* ------------------------------------------------------ */
// Execute Query
const tours = await query;
// const tours = await Tour.find();
res.status(200).json({
status: 'success',
results: tours.length,
data: {
tours,
},
});
} catch (err) {
console.log(err);
res.status(404).json({
status: 'fail',
message: err,
});
}
};
exports.getTour = async (req, res) => {
try {
const tour = await Tour.findById(req.params.id);
// Tour.findOne({ _id: req.params.id }); // this works the same as the above line. findById() is just a helper function.
res.status(200).json({
status: 'success',
data: {
tour,
},
});
} catch (err) {
res.status(404).json({
status: 'fail',
message: err,
});
}
};
// async below means that this is a special function which will keep running in the background while performing the code that is inside it while the rest of the code keeps running in the event loop. This async function will not block the event loop.
/* const data = await someFunctionPromise(); */ // → this await will stop the code at this point until the promise "someFunctionPromise" is resolved. Once the promise is returned then the code is allowed to run again and the data get's stored in the "data" variable.
exports.createTour = async (req, res) => {
try {
/* const newTours = new Tour({});
newTours.save(); */
// this save method returns a promise
/* OR */
const newTour = await Tour.create(req.body);
res.status(201).json({
status: 'success',
data: {
tour: newTour,
},
});
} catch (err) {
res.status(400).json({
status: 'fail',
message: 'Invalid data sent!',
error: err,
});
}
// catch has access to the error object. Now, what do we put here ? Remember that an error can occur because of validation error (not defining the required fields), it is one of the errors that will get catched here.
// If we tried to create a document without the defining the required fields then the promise "Tour.create(req.body)" will be rejected, then it will enter the catch block.
};
exports.updateTour = async (req, res) => {
try {
// req.body contains the updated tour
const tour = await Tour.findByIdAndUpdate(req.params.id, req.body, {
new: true, // sends back the updated tour to back to the client
runValidators: true, // runs the validators again
});
res.status(200).json({
status: 'success',
data: {
tour,
},
});
} catch (err) {
res.status(404).json({
status: 'fail',
message: err,
});
}
};
exports.deleteTour = async (req, res) => {
try {
await Tour.findByIdAndDelete(req.params.id);
res.status(204).json({
status: 'success',
data: null,
});
} catch (err) {
res.status(404).json({
status: 'fail',
message: err,
});
}
};
tourRoutes.js :
const express = require('express');
const fs = require('fs');
const tourController = require('../controllers/tourController'); // all of the functions that we have in the tourController.js file are now in the tourController object
// we can also use destructuring to import the functions like this :
// const { getAllTours, getTour, createTour, updateTour, deleteTour } = require('./../controllers/tourController'); and then we can use them directly in the router.route() method
const router = express.Router(); // we can use tourRouter as a middleware
// here val is the value of the parameter in question
// router.param('id', tourController.checkID);
router
.route('/top-5-cheap')
.get(tourController.aliasTopTours, tourController.getAllTours);
router
.route('/') // here we don't have to specify the full path, because we already did that when we mounted the router
.get(tourController.getAllTours)
// .post(tourController.checkBody, tourController.createTour);
.post(tourController.createTour);
router
.route('/:id')
.get(tourController.getTour)
.patch(tourController.updateTour)
.delete(tourController.deleteTour);
// exporting the router
module.exports = router;
utils/apiFeatuers.js :
class APIFeatures {
constructor(query, queryString) {
console.log(`query777 : ${query}`);
console.log(`queryString777 : ${queryString}`);
this.query = query;
this.queryString = queryString;
}
filter() {
/* ------------------------------------------------------ */
// BUILD QUERY
// 1) Filtering
const queryObj = { ...this.queryString };
const excludedFields = ['page', 'sort', 'limit', 'fields'];
excludedFields.forEach((el) => delete queryObj[el]);
/* ------------------------------------------------------ */
// 2) Advanced Filtering
let queryStr = JSON.stringify(queryObj);
queryStr = queryStr.replace(/\b(gte|gt|lte|lt)\b/g, (match) => `$${match}`);
this.query = this.query.find(JSON.parse(queryStr));
// let query = Tour.find(JSON.parse(queryStr));
/* ------------------------------------------------------ */
return this; // this will return the entire object so that we can chain other methods like 'sort' and 'limit' to it
}
sort() {
/* ------------------------------------------------------ */
// 3) Sorting
if (this.queryString.sort) {
const sortBy = this.queryString.sort.split(',').join(' ');
// console.log(sortBy);
this.query = this.query.sort(sortBy);
}
return this;
}
limitFields() {
// 4) Field Limiting
if (this.queryString.fields) {
const fields = this.queryString.fields.split(',').join(' ');
this.query = this.query.select(fields);
} else {
this.query = this.query.select('-__v');
}
return this;
}
pagination() {
/* ------------------------------------------------------ */
// 5) Pagination
const page = this.queryString.page * 1 || 1;
const limit = this.queryString.limit * 1 || 100;
const skip = (page - 1) * limit;
this.query = this.query.skip(skip).limit(limit);
/* ------------------------------------------------------ */
return this;
}
}
module.exports = APIFeatures;
tourController.js
const fs = require('fs');
const Tour = require('../models/tourModel');
const APIFeatures = require('../utils/apiFeatures');
exports.aliasTopTours = (req, res, next) => {
req.query.limit = '5';
req.query.sort = '-ratingsAverage, price';
req.query.fields = 'name, price, ratingsAverage, summary, difficulty';
next();
};
exports.getAllTours = async (req, res) => {
try {
// console.log(req.query);
// Execute Query
const features = new APIFeatures(Tour.find(), req.query)
.filter()
.sort()
.limitFields()
.pagination(); // this 'features' will have access to all the methods that we have defined in the 'APIFeatures' class
// All of this chaining works only because all of these methods are returning 'this'
// On chaining these methods we keep adding stuff to the query here until the end, by the end we simply await the query so that it can come back with all the documents.
const tours = await features.query;
// const tours = await Tour.find();
res.status(200).json({
status: 'success',
results: tours.length,
data: {
tours,
},
});
} catch (err) {
res.status(404).json({
status: 'fail',
message: err,
});
}
};
exports.getTour = async (req, res) => {
try {
const tour = await Tour.findById(req.params.id);
res.status(200).json({
status: 'success',
data: {
tour,
},
});
} catch (err) {
res.status(404).json({
status: 'fail',
message: err,
});
}
};
exports.createTour = async (req, res) => {
try {
const newTour = await Tour.create(req.body);
res.status(201).json({
status: 'success',
data: {
tour: newTour,
},
});
} catch (err) {
res.status(400).json({
status: 'fail',
message: 'Invalid data sent!',
error: err,
});
}
};
exports.updateTour = async (req, res) => {
try {
const tour = await Tour.findByIdAndUpdate(req.params.id, req.body, {
new: true,
runValidators: true,
});
res.status(200).json({
status: 'success',
data: {
tour,
},
});
} catch (err) {
res.status(404).json({
status: 'fail',
message: err,
});
}
};
exports.deleteTour = async (req, res) => {
try {
await Tour.findByIdAndDelete(req.params.id);
res.status(204).json({
status: 'success',
data: null,
});
} catch (err) {
res.status(404).json({
status: 'fail',
message: err,
});
}
};
const features = new APIFeatures(Tour.find(), req.query)
.filter()
.sort()
.limitFields()
.pagination(); // this 'features' will have access to all the methods that we have defined in the 'APIFeatures' class
// All of this chaining works only because all of these methods are returning 'this'
// On chaining these methods we keep adding stuff to the query here until the end, by the end we simply await the query so that it can come back with all the documents.
const tours = await features.query;
This is a feature of MongoDB. Mongoose gives us access to it so we can use it in the Mongoose Driver.
https://www.mongodb.com/docs/manual/aggregation/
https://www.mongodb.com/docs/v3.4/reference/operator/aggregation/
$group:
allows us to group documents together, basically using accumulators, and an accumulator is for example calculating average. So if we have five tours, each of them has a rating we can then calculate the average rating using group._id: null
tourController.js
exports.getTourStats = async (req, res) => {
try {
const stats = await Tour.aggregate([
{
$match: { ratingsAverage: { $gte: 4.5 } }, // match is used to filter out documents
},
{
$group: {
_id: null, // id is null because we want to calculate the stats on all the tours together in one big group
numTours: { $sum: 1 }, // $sum is an accumulator operator, 1 is added to the accumulator for each document
numRatings: { $sum: '$ratingsQuantity' },
averageRating: { $avg: '$ratingsAverage' }, // $avg is an accumulator operator
averagePrice: { $avg: '$price' },
minPrice: { $min: '$price' },
maxPrice: { $max: '$price' },
},
},
]);
res.status(200).json({
status: 'success',
data: {
stats,
},
});
} catch (err) {
res.status(404).json({
status: 'fail',
message: err,
});
}
};
tourRoutes.js
router.route('/tour-stats').get(tourController.getTourStats);
postman :
_id != NULL
: Grouping Documents by a property + Multiple match
ingexports.getTourStats = async (req, res) => {
try {
const stats = await Tour.aggregate([
{
$match: { ratingsAverage: { $gte: 4.5 } }, // match is used to filter out documents
},
{
$group: {
// _id: null, // id is null because we want to calculate the stats on all the tours together in one big group
_id: { $toUpper: '$difficulty' }, // we can group the documents based on some fields, here we are grouping the documents based on the difficulty of the tours
// "toUpper" is a built-in function in MongoDB to convert the difficulty to uppercase
numTours: { $sum: 1 }, // $sum is an accumulator operator, 1 is added to the accumulator for each document
numRatings: { $sum: '$ratingsQuantity' },
avgRating: { $avg: '$ratingsAverage' }, // $avg is an accumulator operator
avgPrice: { $avg: '$price' },
minPrice: { $min: '$price' },
maxPrice: { $max: '$price' },
},
},
{
$sort: { avgPrice: 1 }, // 1 for ascending order
},
{
$match: { _id: { $ne: 'EASY' } }, // we can use match again to filter out the documents
},
]);
res.status(200).json({
status: 'success',
data: {
stats,
},
});
} catch (err) {
res.status(404).json({
status: 'fail',
message: err,
});
}
};
Output :
{
"status": "success",
"data": {
"stats": [
{
"_id": "MEDIUM",
"numTours": 3,
"numRatings": 70,
"avgRating": 4.8,
"avgPrice": 1663.6666666666667,
"minPrice": 497,
"maxPrice": 2997
},
{
"_id": "DIFFICULT",
"numTours": 2,
"numRatings": 41,
"avgRating": 4.6,
"avgPrice": 1997,
"minPrice": 997,
"maxPrice": 2997
}
]
}
}
getMonthlyPlan
:$unwind
: what unwind is going to do is basically deconstruct an array field from the info documents and then output one document for each element of the array
startDates
exports.getMonthlyPlan = async (req, res) => {
try {
const year = req.params.year * 1; // 2021
const plan = await Tour.aggregate([
{
$unwind: '$startDates',
},
{
$match: {
startDates: {
$gte: new Date(`${year}-01-01`),
$lte: new Date(`${year}-12-31`),
// this will give us all the tours that have start dates between the first and the last day of the year
},
},
},
{
$group: {
_id: { $month: '$startDates' }, // grouping criteria
numTourStarts: { $sum: 1 }, // info that we want to extract
tours: { $push: '$name' }, // to get the names of all the tours that start in a particular month
},
},
{
$addFields: { month: '$_id' }, // to add a new field to the document
},
{
$project: { _id: 0 }, // to hide the _id field (we yse 0 to hide the field and 1 to show the field)
},
{
$sort: { numTourStarts: -1 }, // to sort the documents based on the number of tours that start in a particular month
},
{
$limit: 12, // to limit the number of documents that are returned
},
]);
res.status(200).json({
status: 'success',
data: {
plan,
},
});
} catch (err) {
res.status(404).json({
status: 'fail',
message: err,
});
}
};
// this virtual property will be created every time we "get" data from the database
tourSchema.virtual('durationWeeks').get(function () {
return this.duration / 7; // this here refers to the current document
});
Full tourModel.js
:
const mongoose = require('mongoose');
const tourSchema = new mongoose.Schema(
{
name: {
type: String,
required: [true, 'Tour Must have a name'], // value and error message, required is a VALIDATOR
unique: true,
trim: true, // removes all the white spaces from the beginning and the end of the string
},
duration: {
type: Number,
required: [true, 'A tour must have a duration'],
},
maxGroupSize: {
type: Number,
required: [true, 'A tour must have a max group size'],
},
difficulty: {
type: String,
required: [true, 'A tour must have a difficulty'],
},
ratingsAverage: {
type: Number,
default: 4.5,
},
ratingsQuantity: {
type: Number,
default: 0,
},
price: {
type: Number,
required: [true, 'A tour must have a price'],
},
priceDiscount: Number,
summary: {
type: String,
trim: true, // removes all the white spaces from the beginning and the end of the string
},
description: {
type: String,
trim: true,
},
imageCover: {
type: String,
required: [true, 'A tour must have a cover image'],
},
images: [String], // this is an array of strings
createdAt: {
type: Date,
default: Date.now(),
select: false, // this will not show the createdAt field in the response
},
startDates: [Date], // this is an array of dates // different dates when the tour starts // mongo will convert/parse the string into a date
secretTour: {
type: Boolean,
default: false,
},
},
{
toJSON: { virtuals: true }, // this will include the virtual properties in the response
toObject: { virtuals: true }, // toObject is used when the data is outputted as an object
},
);
// this virtual property will be created every time we "get" data from the database
tourSchema.virtual('durationWeeks').get(function () {
return this.duration / 7; // this here refers to the current document
});
const Tour = mongoose.model('Tour', tourSchema); // this will create a model named "Tour" from the schema
// it is a convention to start the model name with a capital letter
module.exports = Tour;
Note that we cannot use virtual properties in a query since they are not a part of the database
Just like express mongoose also has the concept of middleware.
.pre()
runs before .save()
and .create()
, and it does not get invoked before other commands like .insertMany()
.'save'
in the example code below. in tourModel.js
tourSchema.pre('save', function () {
console.log(this); // this will show the document that is being saved/processed.
});
On creating a new tour :
We'll get the following output in the console before the document is saved/created :
npm i slugify
tourModel.js :
// .pre *runs before* `.save()` and `.create()`, and it does not get invoked before other commands like `.insertMany()`.
tourSchema.pre('save', function (next) {
// console.log(this); // this will show the document that is being saved/processed.
this.slug = slugify(this.name, { lower: true });
next(); // this is a middleware, so we need to call next() to move to the next middleware
});
// we can have multiple middlewares for the same hook (pre or post)
tourSchema.pre('save', (next) => {
console.log('Will save document...');
next();
});
// post middleware has access to the document that was just saved to the database and the next middleware
tourSchema.post('save', (doc, next) => {
console.log(doc);
next();
});
Full Code (tourModel.js
)
const mongoose = require('mongoose');
const slugify = require('slugify');
const tourSchema = new mongoose.Schema(
{
name: {
type: String,
required: [true, 'Tour Must have a name'], // value and error message, required is a VALIDATOR
unique: true,
trim: true, // removes all the white spaces from the beginning and the end of the string
},
slug: String,
duration: {
type: Number,
required: [true, 'A tour must have a duration'],
},
maxGroupSize: {
type: Number,
required: [true, 'A tour must have a max group size'],
},
difficulty: {
type: String,
required: [true, 'A tour must have a difficulty'],
},
ratingsAverage: {
type: Number,
default: 4.5,
},
ratingsQuantity: {
type: Number,
default: 0,
},
price: {
type: Number,
required: [true, 'A tour must have a price'],
},
priceDiscount: Number,
summary: {
type: String,
trim: true, // removes all the white spaces from the beginning and the end of the string
},
description: {
type: String,
trim: true,
},
imageCover: {
type: String,
required: [true, 'A tour must have a cover image'],
},
images: [String], // this is an array of strings
createdAt: {
type: Date,
default: Date.now(),
select: false, // this will not show the createdAt field in the response
},
startDates: [Date], // this is an array of dates // different dates when the tour starts // mongo will convert/parse the string into a date
secretTour: {
type: Boolean,
default: false,
},
},
{
toJSON: { virtuals: true }, // this will include the virtual properties in the response
toObject: { virtuals: true }, // toObject is used when the data is outputted as an object
},
);
// this virtual property will be created every time we "get" data from the database
// we are not using arrow functions here because we want to use "this" keyword
tourSchema.virtual('durationWeeks').get(function () {
return this.duration / 7; // this here refers to the current document
});
// .pre *runs before* `.save()` and `.create()`, and it does not get invoked before other commands like `.insertMany()`.
tourSchema.pre('save', function (next) {
// console.log(this); // this will show the document that is being saved/processed.
this.slug = slugify(this.name, { lower: true });
next(); // this is a middleware, so we need to call next() to move to the next middleware
});
// we can have multiple middlewares for the same hook (pre or post)
tourSchema.pre('save', (next) => {
console.log('Will save document...');
next();
});
// post middleware has access to the document that was just saved to the database and the next middleware
tourSchema.post('save', (doc, next) => {
console.log(doc);
next();
});
const Tour = mongoose.model('Tour', tourSchema); // this will create a model named "Tour" from the schema
// it is a convention to start the model name with a capital letter
module.exports = Tour;
They allow us to run a function before or after a certain query is executed.
/* ----------------- QUERY MIDDLEWARE : ----------------- */
// tourSchema.pre('find', function (next) { // the problem with 'find' alone it that we'll still be able to access the secret tour by ID as 'findById()' behind the scenes is 'findOne()' only. One way to fix this is by duplicating the query middleware but using 'findOne()' instead. Another method is by using REGULAR EXPRESSIONS :
// /^find/ : this is a regular expression that will match all the strings that start with 'find' like 'findOne', 'findOneAndDelete', 'findOneAndUpdate', etc.
tourSchema.pre(/^find/, function (next) {
this.find({ secretTour: { $ne: true } });
this.start = Date.now(); // this will show the time when the query was executed
next();
});
// since the post middleware has access to the documents that were returned from the database, we can use it to log the documents that were returned
tourSchema.post(/^find/, function (docs, next) {
console.log(docs);
console.log(`Query took ${Date.now() - this.start} milliseconds`);
next();
});
/* ------------------------------------------------------ */