Learnwizy Technologies Logo

Learnwizy Technologies

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


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.


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:

// 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.