37-Nodejs Course 2023: Break II: Splitting Model
Hasan Zohdy
Posted on November 5, 2022
Let's take a break of development, now lets refine our base code.
Creating Model Types
As we can see that we're adding the Model types inside the model file itself, which is fine but not the best practice. So, let's create a type file for the model and move our types there.
// src/core/database/model/types.ts
import { ObjectId } from "mongodb";
import Model from "./model";
/**
* Base model to be extended with Child Models
*/
export type ChildModel<T> = typeof Model & (new () => T);
/**
* The result of the paginate query
*/
export type PaginationListing<T> = {
/**
* Results of the query
*/
documents: T[];
/**
* The pagination results
*/
paginationInfo: {
/**
* Limit of the query
*/
limit: number;
/**
* Results of the query
*/
result: number;
/**
* Current page of the query
*/
page: number;
/**
* total results of the query
*/
total: number;
/**
* total pages of the query
*/
pages: number;
};
};
We moved here two types, the ChildModel
type which we use to extend the return type of the model to match the child model, and the PaginationListing
type which we use to return the pagination results.
Primary Id Type
As we can see that in the update
method we're adding the id type to be string | ObjectId | number
, which is not the best practice, so let's create a type for the id and use it in the model.
// src/core/database/model/types.ts
/**
* Primary id type
*/
export type PrimaryIdType = string | number | ObjectId;
Now the model will look like this at its final state:
// src/core/database/model/model.tsimport { Collection, ObjectId } from "mongodb";
import connection, { Connection } from "../connection";
import { Database } from "../database";
import masterMind from "./master-mind";
import { ChildModel, PaginationListing, PrimaryIdType } from "./types";
export default abstract class Model {
/**
* Collection Name
*/
public static collectionName = "";
/**
* Define the initial value of the id
*/
public static initialId = 1;
/**
* Define the amount to eb incremented by for the next generated id
*/
public static incrementIdBy = 1;
/**
* Primary id column
*/
public static primaryIdColumn = "id";
/**
* Connection instance
*/
public static connection: Connection = connection;
/**
* Constructor
*/
public constructor(public data: Record<string, any> = {}) {
//
}
/**
* Get collection query
*/
public static query() {
return this.connection.database.collection(this.collectionName);
}
/**
* Create a new record in the database for the current model (child class of this one)
* and return a new instance of it with the created data and the new generated id
*/
public static async create<T>(
this: ChildModel<T>,
data: Record<string, any>,
): Promise<T> {
// 1- get the query of the collection
const query = this.query();
const modelData = { ...data };
modelData.id = await this.generateNextId();
// perform the insertion
const result = await query.insertOne(modelData);
modelData._id = result.insertedId;
return this.self(modelData);
}
/**
* Update model by the given id
*/
public static async update<T>(
this: ChildModel<T>,
id: PrimaryIdType,
data: Record<string, any>,
): Promise<T> {
// get the query of the current collection
const query = this.query();
// execute the update operation
const filter = {
[this.primaryIdColumn]: id,
};
const result = await query.findOneAndUpdate(
filter,
{
$set: data,
},
{
returnDocument: "after",
},
);
return this.self(result.value as Record<string, any>);
}
/**
* Replace the entire document for the given document id with the given new data
*/
public static async replace<T>(
this: ChildModel<T>,
id: PrimaryIdType,
data: Record<string, any>,
): Promise<T> {
const query = this.query();
const filter = {
[this.primaryIdColumn]: id,
};
const result = await query.findOneAndReplace(filter, data, {
returnDocument: "after",
});
return this.self(result.value as Record<string, any>);
}
/**
* Find and update the document for the given filter with the given data or create a new document/record
* if filter has no matching
*/
public static async upsert<T>(
this: ChildModel<T>,
filter: Record<string, any>,
data: Record<string, any>,
): Promise<T> {
// get the query of the current collection
const query = this.query();
// execute the update operation
const result = await query.findOneAndUpdate(
filter,
{
$set: data,
},
{
returnDocument: "after",
upsert: true,
},
);
return this.self(result.value as Record<string, any>);
}
/**
* Find document by id
*/
public static async find<T>(this: ChildModel<T>, id: PrimaryIdType) {
return this.findBy(this.primaryIdColumn, id);
}
/**
* Find document by the given column and value
*/
public static async findBy<T>(
this: ChildModel<T>,
column: string,
value: any,
): Promise<T | null> {
const query = this.query();
const result = await query.findOne({
[column]: value,
});
return result ? this.self(result as Record<string, any>) : null;
}
/**
* List multiple documents based on the given filter
*/
public static async list<T>(
this: ChildModel<T>,
filter: Record<string, any> = {},
): Promise<T[]> {
const query = this.query();
const documents = await query.find(filter).toArray();
return documents.map(document => this.self(document));
}
/**
* Paginate records based on the given filter
*/
public static async paginate<T>(
this: ChildModel<T>,
filter: Record<string, any>,
page: number,
limit: number,
): Promise<PaginationListing<T>> {
const query = this.query();
const documents = await query
.find(filter)
.skip((page - 1) * limit)
.limit(limit)
.toArray();
const totalDocumentsOfFilter = await query.countDocuments(filter);
const result: PaginationListing<T> = {
documents: documents.map(document => this.self(document)),
paginationInfo: {
limit,
page,
result: documents.length,
total: totalDocumentsOfFilter,
pages: Math.ceil(totalDocumentsOfFilter / limit),
},
};
return result;
}
/**
* Delete single document if the given filter is an ObjectId of mongodb
* Otherwise, delete multiple documents based on the given filter object
*/
public static async delete<T>(
this: ChildModel<T>,
filter: PrimaryIdType | Record<string, any>,
): Promise<number> {
const query = this.query();
if (
filter instanceof ObjectId ||
typeof filter === "string" ||
typeof filter === "number"
) {
const result = await query.deleteOne({
[this.primaryIdColumn]: filter,
});
return result.deletedCount;
}
const result = await query.deleteMany(filter);
return result.deletedCount;
}
/**
* Generate next id
*/
public static async generateNextId() {
return await masterMind.generateNextId(
this.collectionName,
this.incrementIdBy,
this.initialId,
);
}
/**
* Get last id of current model
*/
public static async getLastId() {
return await masterMind.getLastId(this.collectionName);
}
/**
* Get an instance of child class
*/
protected static self(data: Record<string, any>) {
return new (this as any)(data);
}
/**
* Get collection name
*/
public getCollectionName(): string {
return this.getStaticProperty("collectionName");
}
/**
* Get collection query
*/
public getQuery(): Collection {
return this.getStaticProperty("query")();
}
/**
* Get connection instance
*/
public getConnection(): Connection {
return this.getStaticProperty("connection");
}
/**
* Get database instance
*/
public getDatabase(): Database {
return this.getConnection().database;
}
/**
* Get static property
*/
protected getStaticProperty(property: keyof typeof Model) {
return (this.constructor as any)[property];
}
}
Splitting Base Model
As you can see, the base model is getting bigger and bigger. So, we need to split it into multiple classes, we can do it by couple of ways, but let's use the simplest one, which is the inheritance.
Let's create the BaseModel
class.
Base Model
The base model will be the top class that will contain the basics methods and static properties for the model.
// src/core/database/models/base-model.ts
import { Collection } from "mongodb";
import connection, { Connection } from "../connection";
import { Database } from "../database";
import masterMind from "./master-mind";
import Model from "./model";
export default abstract class BaseModel {
/**
* Collection Name
*/
public static collectionName = "";
/**
* Define the initial value of the id
*/
public static initialId = 1;
/**
* Define the amount to eb incremented by for the next generated id
*/
public static incrementIdBy = 1;
/**
* Primary id column
*/
public static primaryIdColumn = "id";
/**
* Connection instance
*/
public static connection: Connection = connection;
/**
* Constructor
*/
public constructor(public data: Record<string, any> = {}) {
//
}
/**
* Get collection query
*/
public static query() {
return this.connection.database.collection(this.collectionName);
}
/**
* Generate next id
*/
public static async generateNextId() {
return await masterMind.generateNextId(
this.collectionName,
this.incrementIdBy,
this.initialId,
);
}
/**
* Get last id of current model
*/
public static async getLastId() {
return await masterMind.getLastId(this.collectionName);
}
/**
* Get an instance of child class
*/
protected static self(data: Record<string, any>) {
return new (this as any)(data);
}
/**
* Get static property
*/
protected getStaticProperty(property: keyof typeof Model) {
return (this.constructor as any)[property];
}
/**
* Get collection name
*/
public getCollectionName(): string {
return this.getStaticProperty("collectionName");
}
/**
* Get collection query
*/
public getQuery(): Collection {
return this.getStaticProperty("query")();
}
/**
* Get connection instance
*/
public getConnection(): Connection {
return this.getStaticProperty("connection");
}
/**
* Get database instance
*/
public getDatabase(): Database {
return this.getConnection().database;
}
}
Now let's update our model to extend that model.
// src/core/database/models/model.ts
import { ObjectId } from "mongodb";
import BaseModel from "./base-model";
import { ChildModel, PaginationListing, PrimaryIdType } from "./types";
export default abstract class Model extends BaseModel {
/**
* Create a new record in the database for the current model (child class of this one)
* and return a new instance of it with the created data and the new generated id
*/
public static async create<T>(
this: ChildModel<T>,
data: Record<string, any>,
): Promise<T> {
// 1- get the query of the collection
const query = this.query();
const modelData = { ...data };
modelData.id = await this.generateNextId();
// perform the insertion
const result = await query.insertOne(modelData);
modelData._id = result.insertedId;
return this.self(modelData);
}
/**
* Update model by the given id
*/
public static async update<T>(
this: ChildModel<T>,
id: PrimaryIdType,
data: Record<string, any>,
): Promise<T> {
// get the query of the current collection
const query = this.query();
// execute the update operation
const filter = {
[this.primaryIdColumn]: id,
};
const result = await query.findOneAndUpdate(
filter,
{
$set: data,
},
{
returnDocument: "after",
},
);
return this.self(result.value as Record<string, any>);
}
/**
* Replace the entire document for the given document id with the given new data
*/
public static async replace<T>(
this: ChildModel<T>,
id: PrimaryIdType,
data: Record<string, any>,
): Promise<T> {
const query = this.query();
const filter = {
[this.primaryIdColumn]: id,
};
const result = await query.findOneAndReplace(filter, data, {
returnDocument: "after",
});
return this.self(result.value as Record<string, any>);
}
/**
* Find and update the document for the given filter with the given data or create a new document/record
* if filter has no matching
*/
public static async upsert<T>(
this: ChildModel<T>,
filter: Record<string, any>,
data: Record<string, any>,
): Promise<T> {
// get the query of the current collection
const query = this.query();
// execute the update operation
const result = await query.findOneAndUpdate(
filter,
{
$set: data,
},
{
returnDocument: "after",
upsert: true,
},
);
return this.self(result.value as Record<string, any>);
}
/**
* Find document by id
*/
public static async find<T>(this: ChildModel<T>, id: PrimaryIdType) {
return this.findBy(this.primaryIdColumn, id);
}
/**
* Find document by the given column and value
*/
public static async findBy<T>(
this: ChildModel<T>,
column: string,
value: any,
): Promise<T | null> {
const query = this.query();
const result = await query.findOne({
[column]: value,
});
return result ? this.self(result as Record<string, any>) : null;
}
/**
* List multiple documents based on the given filter
*/
public static async list<T>(
this: ChildModel<T>,
filter: Record<string, any> = {},
): Promise<T[]> {
const query = this.query();
const documents = await query.find(filter).toArray();
return documents.map(document => this.self(document));
}
/**
* Paginate records based on the given filter
*/
public static async paginate<T>(
this: ChildModel<T>,
filter: Record<string, any>,
page: number,
limit: number,
): Promise<PaginationListing<T>> {
const query = this.query();
const documents = await query
.find(filter)
.skip((page - 1) * limit)
.limit(limit)
.toArray();
const totalDocumentsOfFilter = await query.countDocuments(filter);
const result: PaginationListing<T> = {
documents: documents.map(document => this.self(document)),
paginationInfo: {
limit,
page,
result: documents.length,
total: totalDocumentsOfFilter,
pages: Math.ceil(totalDocumentsOfFilter / limit),
},
};
return result;
}
/**
* Delete single document if the given filter is an ObjectId of mongodb
* Otherwise, delete multiple documents based on the given filter object
*/
public static async delete<T>(
this: ChildModel<T>,
filter: PrimaryIdType | Record<string, any>,
): Promise<number> {
const query = this.query();
if (
filter instanceof ObjectId ||
typeof filter === "string" ||
typeof filter === "number"
) {
const result = await query.deleteOne({
[this.primaryIdColumn]: filter,
});
return result.deletedCount;
}
const result = await query.deleteMany(filter);
return result.deletedCount;
}
}
What we did here is we moved all variables and connection methods to the base class, the Model now is left with CRUD operations which are common for all models.
But let's move these crud operations also to another base class, let's call it CrudModel
, that CurdModel
will extend the BaseModel
and the Model
will extend the CrudModel
.
// src/core/database/models/crud-model.ts
import { ObjectId } from "mongodb";
import BaseModel from "./base-model";
import { ChildModel, PaginationListing, PrimaryIdType } from "./types";
export default abstract class CrudModel extends BaseModel {
/**
* Create a new record in the database for the current model (child class of this one)
* and return a new instance of it with the created data and the new generated id
*/
public static async create<T>(
this: ChildModel<T>,
data: Record<string, any>,
): Promise<T> {
// 1- get the query of the collection
const query = this.query();
const modelData = { ...data };
modelData.id = await this.generateNextId();
// perform the insertion
const result = await query.insertOne(modelData);
modelData._id = result.insertedId;
return this.self(modelData);
}
/**
* Update model by the given id
*/
public static async update<T>(
this: ChildModel<T>,
id: PrimaryIdType,
data: Record<string, any>,
): Promise<T> {
// get the query of the current collection
const query = this.query();
// execute the update operation
const filter = {
[this.primaryIdColumn]: id,
};
const result = await query.findOneAndUpdate(
filter,
{
$set: data,
},
{
returnDocument: "after",
},
);
return this.self(result.value as Record<string, any>);
}
/**
* Replace the entire document for the given document id with the given new data
*/
public static async replace<T>(
this: ChildModel<T>,
id: PrimaryIdType,
data: Record<string, any>,
): Promise<T> {
const query = this.query();
const filter = {
[this.primaryIdColumn]: id,
};
const result = await query.findOneAndReplace(filter, data, {
returnDocument: "after",
});
return this.self(result.value as Record<string, any>);
}
/**
* Find and update the document for the given filter with the given data or create a new document/record
* if filter has no matching
*/
public static async upsert<T>(
this: ChildModel<T>,
filter: Record<string, any>,
data: Record<string, any>,
): Promise<T> {
// get the query of the current collection
const query = this.query();
// execute the update operation
const result = await query.findOneAndUpdate(
filter,
{
$set: data,
},
{
returnDocument: "after",
upsert: true,
},
);
return this.self(result.value as Record<string, any>);
}
/**
* Find document by id
*/
public static async find<T>(this: ChildModel<T>, id: PrimaryIdType) {
return this.findBy(this.primaryIdColumn, id);
}
/**
* Find document by the given column and value
*/
public static async findBy<T>(
this: ChildModel<T>,
column: string,
value: any,
): Promise<T | null> {
const query = this.query();
const result = await query.findOne({
[column]: value,
});
return result ? this.self(result as Record<string, any>) : null;
}
/**
* List multiple documents based on the given filter
*/
public static async list<T>(
this: ChildModel<T>,
filter: Record<string, any> = {},
): Promise<T[]> {
const query = this.query();
const documents = await query.find(filter).toArray();
return documents.map(document => this.self(document));
}
/**
* Paginate records based on the given filter
*/
public static async paginate<T>(
this: ChildModel<T>,
filter: Record<string, any>,
page: number,
limit: number,
): Promise<PaginationListing<T>> {
const query = this.query();
const documents = await query
.find(filter)
.skip((page - 1) * limit)
.limit(limit)
.toArray();
const totalDocumentsOfFilter = await query.countDocuments(filter);
const result: PaginationListing<T> = {
documents: documents.map(document => this.self(document)),
paginationInfo: {
limit,
page,
result: documents.length,
total: totalDocumentsOfFilter,
pages: Math.ceil(totalDocumentsOfFilter / limit),
},
};
return result;
}
/**
* Delete single document if the given filter is an ObjectId of mongodb
* Otherwise, delete multiple documents based on the given filter object
*/
public static async delete<T>(
this: ChildModel<T>,
filter: PrimaryIdType | Record<string, any>,
): Promise<number> {
const query = this.query();
if (
filter instanceof ObjectId ||
typeof filter === "string" ||
typeof filter === "number"
) {
const result = await query.deleteOne({
[this.primaryIdColumn]: filter,
});
return result.deletedCount;
}
const result = await query.deleteMany(filter);
return result.deletedCount;
}
}
We just moved the CRUD operations to the CrudModel
and the Model
now is mostly empty, but we need to make it extend the CrudModel
.
// src/core/database/models/model.ts
import CrudModel from "./crud-model";
export default abstract class Model extends CrudModel {}
Now the model is pretty much clean xD, but we're really really good now as we can split the low level code to be in the BaseModel
class, and the crud operations in another class, which will leave a good space to our Model
class to have its own methods and properties as we didn't start doing it yet.
🎨 Conclusion
In this article, we created a types
file and move our declarations there, we also split the model into two base classes, one contains the base methods and the other contains the CRUD operations.
🚀 Project Repository
You can find the latest updates of this project on Github
😍 Join our community
Join our community on Discord to get help and support (Node Js 2023 Channel).
🎞️ Video Course (Arabic Voice)
If you want to learn this course in video format, you can find it on Youtube, the course is in Arabic language.
💰 Bonus Content 💰
You may have a look at these articles, it will definitely boost your knowledge and productivity.
General Topics
- Event Driven Architecture: A Practical Guide in Javascript
- Best Practices For Case Styles: Camel, Pascal, Snake, and Kebab Case In Node And Javascript
- After 6 years of practicing MongoDB, Here are my thoughts on MongoDB vs MySQL
Packages & Libraries
- Collections: Your ultimate Javascript Arrays Manager
- Supportive Is: an elegant utility to check types of values in JavaScript
- Localization: An agnostic i18n package to manage localization in your project
React Js Packages
Courses (Articles)
Posted on November 5, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.