Couchbase CRUD API With NodeJs And TypeScript
7 min read
We are now adding a persistence layer (a database) to the very simple CRUD API that was created in the second part of this series.
Setup
To get started, we need to install the Couchbase Server in our local machine. You can download it here for free. After downloading and successfully installing it, create a bucket, a desired scope, and a collection. To have an overview of what these terms mean, take a look at the comparison of Couchbase (a NoSQL database) with a relational database below:
- bucket → database
- scope → schema
- collection → table
- document → row
Here's mine. I have a bucket called node-ts-cb
. Inside it is a scope named demo_app
and inside this scope is one collection called users
.
Now that it's done, let's install the Couchbase SDK for NodeJS by running the ff. in your terminal. Make sure that you are in the project directory.
yarn add couchbase
Then add a .env
file to store the necessary environment variables. Inside it, include the ff:
DB_HOST = couchbase://127.0.0.1
DB_USER = YOUR_COUCHBASE_SERVER_USERNAME
DB_PASSWORD = YOUR_COUCHBASE_SERVER_PASSWORD
DB_BUCKET = node-ts-cb
DB_SCOPE = demo_app
Then load the environment variables by adding the ff. lines of code at the top of main.ts
:
import * as dotenv from 'dotenv';
dotenv.config({ path: __dirname + '/.env' });
Creating a DBClient Module
For this, we could just call the SDK's functions directly in our model or in the controller. But it is a best practice not to expose the entire library to the models. We should abstract it so that it is more maintainable and when we decide to change our persistence layer thus also changing the necessary SDK, we will also do the update in a single place. So for this reason, create a folder named db
and inside it create a file called dbClient.ts
.
import { Bucket, connect, Scope } from 'couchbase';
class DBClient {
private HOST = process.env.DB_HOST as string;
private USERNAME = process.env.DB_USER as string;
private PASSWORD = process.env.DB_PASSWORD as string;
private BUCKET_NAME = process.env.DB_BUCKET as string;
private SCOPE_NAME = process.env.DB_SCOPE as string;
private bucket!: Bucket;
private scope!: Scope;
private constructor() {}
// connect function
private async createConnection() {
try {
const cluster = await connect(this.HOST, {
username: this.USERNAME,
password: this.PASSWORD,
});
this.bucket = cluster.bucket(this.BUCKET_NAME);
this.scope = this.bucket.scope(this.SCOPE_NAME);
console.log('Connected to DB');
} catch (error) {
console.log('Connection error', error);
}
}
}
That code is pretty straight forward. We're just defining the necessary constants and variables. We also created the function to be used to establish a connection to the Couchbase Server.
For the next step, let's apply the singleton pattern so that we don't have to create a new instance of the DBClient
class everytime we use it.
class DBClient {
// other code here
private static instance: DBClient;
public static getInstance(): DBClient {
if (!DBClient.instance) {
DBClient.instance = new DBClient();
}
DBClient.instance.createConnection();
return DBClient.instance;
}
}
Now let's expose a DBClient
instance:
const dbClient = DBClient.getInstance();
export default dbClient;
Adding the CRUD functions
Let's now add the CRUD functionality inside the DBClient class:
query
This is used to execute a N1QL Query.
import { QueryOptions } from 'couchbase';
class DBClient {
// other code
public async query<T>(queryString: string, options?: QueryOptions) {
const result = await this.scope.query<T>(queryString, options);
return result.rows;
}
}
💡 Info
Before running a query, make sure to create at least a Primary Index but note that Primary Index is not recommended to use in production.
findOne
This is used to find a document by its ID or key.
import { GetOptions } from 'couchbase';
class DBClient {
// other code
public async findOne<T>(collectionName: string, id: string, options?: GetOptions) {
const document = await this.scope.collection(collectionName).get(id, options);
return document.content as T;
}
}
insert
This is used to insert a document to a certain collection.
import { InsertOptions } from 'couchbase';
class DBClient {
// other code
public async insert<T extends { docType: string }>(
collectionName: string,
doc: T,
options?: InsertOptions
) {
// generate an ID like "USER::12345"
const id = `${doc.docType.toUpperCase()}::${Date.now().toString()}`;
const docToInsert = {
...doc,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await this.scope.collection(collectionName).insert(id, docToInsert, options);
return id;
}
}
update
This is used to update certain fields of an existing document.
import { MutateInSpec } from 'couchbase';
class DBClient {
// other code
public async update<T>(collectionName: string, id: string, updatedDoc: Partial<T>) {
// collect the fields to update
const fields = {
...updatedDoc,
updatedAt: new Date().toISOString(),
};
const fieldsToUpdate = Object.entries(fields).map(([key, value]) =>
MutateInSpec.upsert(key, value)
);
// perform mutation/update
await this.scope.collection(collectionName).mutateIn(id, fieldsToUpdate);
return id;
}
}
💡 Info
There's another method for updating a document called replace
but it replaces the entire
document. So to make sure that we're only updating certain fields, we used the approach above.
remove
This is used to delete a document.
class DBClient {
// other code
public async remove(collectionName: string, id: string) {
await this.scope.collection(collectionName).remove(id);
return id;
}
}
The entire DBClient implementation is as follows:
import {
Bucket,
connect,
GetOptions,
InsertOptions,
MutateInSpec,
QueryOptions,
Scope,
} from 'couchbase';
class DBClient {
private HOST = process.env.DB_HOST as string;
private USERNAME = process.env.DB_USER as string;
private PASSWORD = process.env.DB_PASSWORD as string;
private BUCKET_NAME = process.env.DB_BUCKET as string;
private SCOPE_NAME = process.env.DB_SCOPE as string;
private static instance: DBClient;
private bucket!: Bucket;
private scope!: Scope;
private constructor() {}
private async createConnection() {
try {
const cluster = await connect(this.HOST, {
username: this.USERNAME,
password: this.PASSWORD,
});
this.bucket = cluster.bucket(this.BUCKET_NAME);
this.scope = this.bucket.scope(this.SCOPE_NAME);
console.log('Connected to DB');
} catch (error) {
console.log('Connection error', error);
}
}
public static getInstance(): DBClient {
if (!DBClient.instance) {
DBClient.instance = new DBClient();
}
DBClient.instance.createConnection();
return DBClient.instance;
}
public async query<T>(queryString: string, options?: QueryOptions) {
const result = await this.scope.query<T>(queryString, options);
return result.rows;
}
public async findOne<T>(collectionName: string, id: string, options?: GetOptions) {
const document = await this.scope.collection(collectionName).get(id, options);
return document.content as T;
}
public async insert<T extends { docType: string }>(
collectionName: string,
doc: T,
options?: InsertOptions
) {
const id = `${doc.docType.toUpperCase()}::${Date.now().toString()}`;
const docToInsert = {
...doc,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await this.scope.collection(collectionName).insert(id, docToInsert, options);
return id;
}
public async update<T>(collectionName: string, id: string, updatedDoc: Partial<T>) {
// collect the fields to update
const fields = {
...updatedDoc,
updatedAt: new Date().toISOString(),
};
const fieldsToUpdate = Object.entries(fields).map(([key, value]) =>
MutateInSpec.upsert(key, value)
);
// perform mutation/update
await this.scope.collection(collectionName).mutateIn(id, fieldsToUpdate);
return id;
}
public async remove(collectionName: string, id: string) {
await this.scope.collection(collectionName).remove(id);
return id;
}
}
const dbClient = DBClient.getInstance();
export default dbClient;
Applying in the User Model
Now that we have created an abstraction, we can now easily use it in the User Model.
import dbClient from '../db/dbClient';
import { User, CreateUserDto, UpdateUserDto } from './schema';
const docType = 'USER';
const collectionName = 'users';
// GET
const getAll = async () => {
const queryString = `SELECT u.*, meta().id
FROM users u
WHERE docType = $docType`;
const users = await dbClient.query<User>(queryString, { parameters: { docType } });
return users;
};
// GET by ID
const getById = async (id: string) => {
const user = await dbClient.findOne(collectionName, id);
return user;
};
// CREATE
const create = async (user: CreateUserDto) => {
const newUserId = await dbClient.insert(collectionName, { ...user, docType });
return newUserId;
};
// UPDATE
const update = async (id: string, user: UpdateUserDto) => {
const updateUserId = await dbClient.update(collectionName, id, user);
return updateUserId;
};
// DELETE
const remove = async (id: string) => {
const deletedId = await dbClient.remove(collectionName, id);
return deletedId;
};
const UserModel = {
getAll,
getById,
create,
update,
remove,
};
export default UserModel;
That's it! We can of course expand this more by adding other collections and defining relationships among collections. In a future post, I will show you how to handle errors. Until then, happy coding!
Additional Resources
Here's the part 2 of this series: