Class 31: Interacting with MongoDB from Node.js/Express (Mongoose)
In the previous class, we explored the fundamentals of NoSQL databases, specifically MongoDB. Today, we'll learn how to connect our Node.js/Express application to MongoDB and perform CRUD operations. While you can use MongoDB's native Node.js driver, we'll primarily focus on Mongoose, an Object Data Modeling (ODM) library that provides a more robust and convenient way to interact with MongoDB.
Connecting Node.js to MongoDB
-
Native
mongodb
driver:The official Node.js driver for MongoDB provides direct access to MongoDB's API. It's powerful but can be verbose for common operations, and it doesn't offer schema validation or middleware.
-
Introduction to Mongoose:
Mongoose is an ODM (Object Data Modeling) library for MongoDB and Node.js. It provides a straightforward, schema-based solution to model your application data. It includes built-in type casting, validation, query building, and business logic hooks.
-
Benefits of Mongoose:
- Schema Validation: Define the structure and data types of your documents, ensuring consistency.
-
Middleware/Hooks: Allows you to
execute functions before or after certain operations
(e.g.,
pre-save
hooks for hashing passwords). - Query Building: Provides a rich API for building complex queries in a more readable and less error-prone way than raw driver commands.
- Populating: Simplifies handling relationships between documents.
Mongoose Setup and Connection
1. Installation:
npm install mongoose dotenv
We'll use dotenv
again for environment variables.
2. Environment Variables:
Update your .env
file to include your MongoDB
connection string.
# .env
MONGO_URI=mongodb://localhost:27017/book_api_db
PORT=3000
3. Connecting to MongoDB:
Create a dedicated file (e.g., config/db.js
) for
your database connection logic.
// config/db.js
const mongoose = require('mongoose');
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.MONGO_URI);
console.log(`MongoDB Connected: ${conn.connection.host}`);
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1); // Exit process with failure
}
};
module.exports = connectDB;
Then, call this function in your main
server.js
file.
// server.js
require('dotenv').config();
const express = require('express');
const connectDB = require('./config/db'); // Import the DB connection function
const app = express();
const port = process.env.PORT || 3000;
// Connect to database
connectDB();
// Middleware to parse JSON bodies
app.use(express.json());
// Basic route
app.get('/', (req, res) => {
res.send('Welcome to the Book API powered by MongoDB and Mongoose!');
});
// Start the server
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
Defining Mongoose Schemas
A Mongoose Schema defines the structure of the documents within a collection, default values, validators, and other options. It enforces a structured approach to your data, even though MongoDB itself is schema-less.
Create a file for your book schema (e.g.,
models/Book.js
):
// models/Book.js
const mongoose = require('mongoose');
const bookSchema = mongoose.Schema({
title: {
type: String,
required: [true, 'Please add a title'], // 'true' makes it required, custom message
trim: true // Removes whitespace from both ends of a string
},
author: {
type: String,
required: [true, 'Please add an author'],
trim: true
},
year: {
type: Number,
min: [1000, 'Year must be after 999'], // Minimum value validator
max: [new Date().getFullYear(), 'Year cannot be in the future'] // Maximum value validator
},
genres: [String], // Array of strings
isAvailable: {
type: Boolean,
default: true
}
}, {
timestamps: true // Mongoose automatically adds createdAt and updatedAt fields
});
module.exports = mongoose.model('Book', bookSchema);
Basic data types: String
,
Number
, Boolean
, Date
,
Array
, ObjectId
(for references).
Creating Mongoose Models
A Mongoose Model is a constructor compiled from a Schema. An instance of a model is called a document. Models are used to create, read, update, and delete documents from the database.
In models/Book.js
, the line
module.exports = mongoose.model('Book', bookSchema);
already creates and exports our Book
model. The
first argument 'Book' is the singular name of the collection
that Mongoose will use (it will automatically look for the
'books' collection).
Performing CRUD Operations with Mongoose
Now, let's refactor our Express routes to use our Mongoose
Book
model to interact with MongoDB.
// server.js (updated with Mongoose CRUD operations)
require('dotenv').config();
const express = require('express');
const connectDB = require('./config/db');
const Book = require('./models/Book'); // Import the Book model
const app = express();
const port = process.env.PORT || 3000;
connectDB();
app.use(express.json());
// --- CRUD Operations with Mongoose ---
// GET /api/books - Get all books
app.get('/api/books', async (req, res) => {
try {
const books = await Book.find(); // Find all documents in the 'books' collection
res.status(200).json(books);
} catch (error) {
console.error('Error fetching books:', error);
res.status(500).json({ message: 'Internal server error.' });
}
});
// GET /api/books/:id - Get a single book by ID
app.get('/api/books/:id', async (req, res) => {
try {
const book = await Book.findById(req.params.id); // Find document by its _id
if (book) {
res.status(200).json(book);
} else {
res.status(404).json({ message: `Book with ID ${req.params.id} not found.` });
}
} catch (error) {
// Handle invalid MongoDB ObjectId format (e.g., if ID is not 24 hex chars)
if (error.kind === 'ObjectId') {
return res.status(400).json({ message: 'Invalid Book ID format.' });
}
console.error('Error fetching book by ID:', error);
res.status(500).json({ message: 'Internal server error.' });
}
});
// POST /api/books - Create a new book
app.post('/api/books', async (req, res) => {
try {
// Create a new document based on the Book model
const newBook = await Book.create(req.body); // Mongoose handles validation based on schema
res.status(201).json(newBook);
} catch (error) {
// Handle Mongoose validation errors
if (error.name === 'ValidationError') {
const messages = Object.values(error.errors).map(val => val.message);
return res.status(400).json({ message: 'Validation failed', errors: messages });
}
console.error('Error creating book:', error);
res.status(500).json({ message: 'Internal server error.' });
}
});
// PUT /api/books/:id - Fully update a book
app.put('/api/books/:id', async (req, res) => {
try {
// findByIdAndUpdate finds by _id, updates, and returns the updated document
// { new: true } returns the document AFTER update
// { runValidators: true } runs schema validators on update
const updatedBook = await Book.findByIdAndUpdate(req.params.id, req.body, {
new: true,
runValidators: true
});
if (updatedBook) {
res.status(200).json(updatedBook);
} else {
res.status(404).json({ message: `Book with ID ${req.params.id} not found for update.` });
}
} catch (error) {
if (error.kind === 'ObjectId') {
return res.status(400).json({ message: 'Invalid Book ID format.' });
}
if (error.name === 'ValidationError') {
const messages = Object.values(error.errors).map(val => val.message);
return res.status(400).json({ message: 'Validation failed', errors: messages });
}
console.error('Error updating book:', error);
res.status(500).json({ message: 'Internal server error.' });
}
});
// DELETE /api/books/:id - Delete a book
app.delete('/api/books/:id', async (req, res) => {
try {
const deletedBook = await Book.findByIdAndDelete(req.params.id); // Finds by _id and deletes
if (deletedBook) {
res.status(204).send(); // 204 No Content
} else {
res.status(404).json({ message: `Book with ID ${req.params.id} not found for deletion.` });
}
} catch (error) {
if (error.kind === 'ObjectId') {
return res.status(400).json({ message: 'Invalid Book ID format.' });
}
console.error('Error deleting book:', error);
res.status(500).json({ message: 'Internal server error.' });
}
});
// Basic root route
app.get('/', (req, res) => {
res.send('Welcome to the Book API powered by MongoDB and Mongoose!');
});
// Start the server
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
Query Chaining:
Mongoose queries are chainable, allowing for flexible and powerful data retrieval.
// Example: Get books published after 1950, sorted by title, select only title and author
const recentBooks = await Book.find({ year: { $gte: 1950 } })
.select('title author -_id') // Include title, author, exclude _id
.sort('title') // Sort by title ascending
.limit(10) // Limit to 10 results
.skip(0); // Skip 0 results (for pagination)
Validation in Mongoose
Mongoose provides built-in validators at the schema level, making it easy to ensure data integrity before saving to the database.
-
Built-in validators:
-
required
: Ensures a field must have a value. min
/max
: For numbers.-
minlength
/maxlength
: For strings. -
enum
: Restricts a string to a list of allowed values. -
match
: For regular expressions (e.g., for email format).
-
-
Custom validators:
You can define your own validation functions.
const userSchema = mongoose.Schema({ email: { type: String, required: true, unique: true, // Ensures email is unique in the collection validate: { validator: function(v) { return /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/.test(v); // Simple email regex }, message: props => `${props.value} is not a valid email address!` } } });
-
Error handling for validation failures:
When a validation fails, Mongoose throws a
ValidationError
. You should catch this error and return a400 Bad Request
to the client with details about the validation errors.
Populating Referenced Documents (Basic)
While MongoDB encourages embedding related data, sometimes you
need to reference documents in other collections (similar to
foreign keys in SQL). Mongoose's populate()
method
simplifies fetching these referenced documents.
First, define a schema with a reference:
// models/Author.js
const authorSchema = mongoose.Schema({
name: String,
country: String
});
module.exports = mongoose.model('Author', authorSchema);
// models/Book.js (updated to reference Author)
const bookSchema = mongoose.Schema({
title: String,
// Reference to the Author model using ObjectId
author: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Author' // 'Author' refers to the model name
},
year: Number
});
module.exports = mongoose.model('Book', bookSchema);
Then, use .populate()
in your query:
// In your route handler
const Book = require('./models/Book');
const Author = require('./models/Author'); // Assuming you have an Author model
// First, create an author and a book referencing it
// const newAuthor = await Author.create({ name: 'Jane Doe', country: 'USA' });
// const newBook = await Book.create({ title: 'My New Book', author: newAuthor._id, year: 2023 });
// To get the book and its author's details:
app.get('/api/books-with-authors', async (req, res) => {
try {
const books = await Book.find().populate('author'); // 'author' is the field to populate
res.status(200).json(books);
} catch (error) {
console.error('Error fetching books with authors:', error);
res.status(500).json({ message: 'Internal server error.' });
}
});
Middleware/Hooks in Mongoose
Mongoose allows you to define pre and post hooks for your
schemas. These are functions that run before or after certain
Mongoose operations (like save
,
remove
, validate
, find
,
update
). They are powerful for adding custom logic,
such as:
- Hashing passwords before saving a user.
- Logging changes to documents.
- Performing cleanup after deletion.
// Example: Hashing password before saving a User (in User.js model)
const bcrypt = require('bcryptjs');
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) { // Only hash if password was modified
next();
}
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
});
You've now successfully integrated MongoDB with your Express.js API using Mongoose, enabling you to build powerful, scalable, and flexible data-driven applications. In the next class, we'll tackle a critical aspect of web development: user authentication and authorization.