Introduction
In this blog, we will explore how to update both primitive and non-primitive data in a MongoDB document using Mongoose. We will specifically focus on updating arrays within documents. Our approach will leverage transactions and rollbacks to ensure data integrity during the update process. We will walk through defining the data types, creating Mongoose schemas, implementing validation with Zod, and finally, updating the data with transaction handling in the service layer.
This is the thirteenth blog of my series where I am writing how to write code for an industry-grade project so that you can manage and scale the project.
The first twelve blogs of the series were about "How to set up eslint and prettier in an express and typescript project", "Folder structure in an industry-standard project", "How to create API in an industry-standard app", "Setting up global error handler using next function provided by express", "How to handle not found route in express app", "Creating a Custom Send Response Utility Function in Express", "How to Set Up Routes in an Express App: A Step-by-Step Guide", "Simplifying Error Handling in Express Controllers: Introducing catchAsync Utility Function", "Understanding Populating Referencing Fields in Mongoose", "Creating a Custom Error Class in an express app", "Understanding Transactions and Rollbacks in MongoDB", "Updating Non-Primitive Data Dynamically in Mongoose", "How to Handle Errors in an Industry-Grade Node.js Application" and "Creating Query Builders for Mongoose: Searching, Filtering, Sorting, Limiting, Pagination, and Field Selection". You can check them in the following link.
https://dev.to/md_enayeturrahman_2560e3/how-to-set-up-eslint-and-prettier-1nk6
https://dev.to/md_enayeturrahman_2560e3/folder-structure-in-an-industry-standard-project-271b
https://dev.to/md_enayeturrahman_2560e3/how-to-create-api-in-an-industry-standard-app-44ck
https://dev.to/md_enayeturrahman_2560e3/how-to-handle-not-found-route-in-express-app-1d26
https://dev.to/md_enayeturrahman_2560e3/understanding-populating-referencing-fields-in-mongoose-jhg
https://dev.to/md_enayeturrahman_2560e3/creating-a-custom-error-class-in-an-express-app-515a
https://dev.to/md_enayeturrahman_2560e3/understanding-transactions-and-rollbacks-in-mongodb-2on6
https://dev.to/md_enayeturrahman_2560e3/updating-non-primitive-data-dynamically-in-mongoose-17h2
Defining Data Types
We begin by defining the data types for our course documents
import { Types } from 'mongoose';
export type TPreRequisiteCourses = { // Type for PreRequisiteCourses that will be inside an array
course: Types.ObjectId;
isDeleted: boolean;
};
// Data structure of our document with one non-primitive and five primitive values
export type TCourse = {
title: string;
prefix: string;
code: number;
credits: number;
isDeleted?: boolean;
preRequisiteCourses: [TPreRequisiteCourses];
};
Mongoose Schema and Model
Next, we create the Mongoose schemas and models for our course data.
import { Schema, model } from 'mongoose';
import {
TCourse,
TPreRequisiteCourses,
} from './course.interface';
const preRequisiteCoursesSchema = new Schema<TPreRequisiteCourses>(
{
course: {
type: Schema.Types.ObjectId,
ref: 'Course',
},
isDeleted: {
type: Boolean,
default: false,
},
},
{
_id: false,
},
);
const courseSchema = new Schema<TCourse>({
title: {
type: String,
unique: true,
trim: true,
required: true,
},
prefix: {
type: String,
trim: true,
required: true,
},
code: {
type: Number,
trim: true,
required: true,
},
credits: {
type: Number,
trim: true,
required: true,
},
preRequisiteCourses: [preRequisiteCoursesSchema],
isDeleted: {
type: Boolean,
default: false,
},
});
export const Course = model<TCourse>('Course', courseSchema);
Zod Validation
We use Zod for validation to ensure the data being created or updated adheres to the expected schema.
import { z } from 'zod';
const PreRequisiteCourseValidationSchema = z.object({
course: z.string(),
isDeleted: z.boolean().optional(),
});
const createCourseValidationSchema = z.object({
body: z.object({
title: z.string(),
prefix: z.string(),
code: z.number(),
credits: z.number(),
preRequisiteCourses: z.array(PreRequisiteCourseValidationSchema).optional(),
isDeleted: z.boolean().optional(),
}),
});
const updatePreRequisiteCourseValidationSchema = z.object({
course: z.string(),
isDeleted: z.boolean().optional(),
});
const updateCourseValidationSchema = z.object({
body: z.object({
title: z.string().optional(),
prefix: z.string().optional(),
code: z.number().optional(),
credits: z.number().optional(),
preRequisiteCourses: z
.array(updatePreRequisiteCourseValidationSchema)
.optional(),
isDeleted: z.boolean().optional(),
}),
});
export const CourseValidations = {
createCourseValidationSchema,
updateCourseValidationSchema
};
- Handling Validation for Updates
The issue we should focus on is that for createCourseValidationSchema, all fields but one are required. If we use it for updating by using partial (because not all fields require an update), it will not work. The required fields will not become optional by using the partial method. So, I created a new validation schema for the update and made all fields optional. This way, from the front end, users can update any field.
Service Layer
I will skip the content of the route and controller files and directly move to the service file
import httpStatus from 'http-status';
import mongoose from 'mongoose';
import AppError from '../../errors/AppError';
import { TCourse } from './course.interface';
import { Course } from './course.model';
const updateCourseIntoDB = async (id: string, payload: Partial<TCourse>) => {
const { preRequisiteCourses, ...courseRemainingData } = payload; // Separate primitive and non-primitive data
const session = await mongoose.startSession(); // Initiate session for transaction
try {
session.startTransaction(); // Start transaction
// Step 1: Update primitive course info
const updatedBasicCourseInfo = await Course.findByIdAndUpdate(
id,
courseRemainingData, // Pass primitive data
{
new: true,
runValidators: true, // Run validators
session, // Pass session
},
);
// Throw error if update fails
if (!updatedBasicCourseInfo) {
throw new AppError(httpStatus.BAD_REQUEST, 'Failed to update course');
}
// Check if there are any prerequisite courses to update
if (preRequisiteCourses && preRequisiteCourses.length > 0) {
// Filter out the deleted fields
const deletedPreRequisites = preRequisiteCourses
.filter((el) => el.course && el.isDeleted) // Fields with isDeleted property value true
.map((el) => el.course); // Only take id for deletion
const deletedPreRequisiteCourses = await Course.findByIdAndUpdate(
id,
{
$pull: { // Use $pull operator to remove objects with matching ids in deletedPreRequisites
preRequisiteCourses: { course: { $in: deletedPreRequisites } },
},
},
{
new: true,
runValidators: true, // Run validators
session, // Pass session
},
);
// Throw error if deletion fails
if (!deletedPreRequisiteCourses) {
throw new AppError(httpStatus.BAD_REQUEST, 'Failed to update course');
}
// Filter out courses that need to be added (isDeleted property value is false)
const newPreRequisites = preRequisiteCourses?.filter(
(el) => el.course && !el.isDeleted,
);
// Perform write operation to update newly added fields
const newPreRequisiteCourses = await Course.findByIdAndUpdate(
id,
{
$addToSet: { preRequisiteCourses: { $each: newPreRequisites } },
}, // Use $addToSet operator to avoid duplication
{
new: true,
runValidators: true,
session,
},
);
if (!newPreRequisiteCourses) {
throw new AppError(httpStatus.BAD_REQUEST, 'Failed to update course');
}
}
// Commit and end session for successful operation
await session.commitTransaction();
await session.endSession();
// Perform find operation to return the data
const result = await Course.findById(id).populate(
'preRequisiteCourses.course',
);
return result;
} catch (err) {
console.log(err);
await session.abortTransaction(); // Abort transaction on error
await session.endSession(); // End session
throw new AppError(httpStatus.BAD_REQUEST, 'Failed to update course'); // Throw error
}
};
export const CourseServices = {
updateCourseIntoDB
};
Conclusion
In this blog, we have walked through the process of updating primitive and non-primitive data in a MongoDB document using Mongoose. By utilizing transactions and rollbacks, we ensure the integrity of our data during complex update operations. This approach allows for robust handling of both primitive and non-primitive updates, making our application more resilient and reliable