Rest API Framework

MongoDB Object Document Mappers

Overview

Below are all files and directories in the REST API Framework related to MongoDB

└── server/
    ├── models/
    │   ├── customTypes.js
    │   └── User.js
    └── utils/
        └── mongodb/
            ├── buildMongoFilters.js
            ├── buildMongoOrders.js
            ├── validator.js
            └── index.js

MongoDB Connector

Below is a simple example of how you can connect to MongoDB Server using the mongodb utils

import mongodb from "server/utils/mongodb"

await mongodb.connect({
  protocol: "mongodb",
  host: "127.0.0.1:27017",
  username: "",
  password: "",
  params: "",
  name: "database_name",
  debug: true, // turn on to debug database executing
})

Once connected, the server will open a connection to the DB at mongodb://127.0.0.1:27017/ named database_name and cache the connection for later uses. This is a required step before you want to do anything with the database.

Object Document Mappers (ODMs)

What is ODMs?

ODM (Object Document Mapper) is a utility whose main purpose is to map the documents from a Document Database such as MongoDB to an object in code, allowing developers to interact with the database more easily and more conveniently.

Although MongoDB Documents are stored in the database as BSON type, which is very similar to JSON, they still have some extra field types that are not supported in Javascript (NodeJS). That"s why we need ODM to remap these field types. Besides that, one of the main reasons why people use ODM is the ability to validate the data (the last defender) before storing permanently into the database.

There are 2 most used MongoDB ODM NodeJS packages that you should take a look: Mongoosejs and MongoDB TypeORM

Glife REST API Framework ODM is inspired by Mongoose, a simpler version, with a lot of tweaking to make it more suitable for Glife Developers and also has a built-in Data Loader for better performance.

Glife REST API Framework ODM

Inside mongodb utils, there is a method called createModel which will allow you to create a Model and it will be cached inside the ODM. The ODM will then map the Model to the Document in MongoDB and expose methods so you can execute database CRUD operations conveniently.

createModel method calling signature

// Calling signature
mongodb.createModel(dbCollectionName, schema, options)
  • dbCollectionName: String. The collection name in the MongoDB you want to map and store data
  • schema: Object. The ODM will use this schema for validation and mapping. You can define a field schema in two ways, using a String or an Object. Here"s the list of available properties in the field schema that you can set:
NameTypeDescription
typeString (Required)

The data type of this field.

The ODM currently supports 7 scalar types String, Int, Float, Boolean, Date, Object, ID and if the field is an array you can wrap the type with the brackets [] like this [String].

isRequiredBoolean

If this field is omitted when creating a new record, an Error will be thrown.

isPrivateBoolean

This field is private so it can only be used on the server side and will be removed when returning data to the clients.

isEmailBoolean

Only valid for String field type, the validator will check if this field value is a valid email address or not.

defaultAny

If the field has no value when creating record, the default value will be used instead.

This value will be added before validation so make sure it is valid.

lengthNumber or [Number]

Only valid for String field type. If it is a Number, the field value must be the exact same length to be valid, and if it is an Array, the field value must have the length between length[0] and length[1].

minNumber

Only valid for Int and Float field type, the field type value must be bigger then min.

maxNumber

Only valid for Int and Float field type, the field type value must be smaller then max.

enum[Any]

An array of valid values, the field value must be one of these to be valid.

  • options: extra configurations for this model. All available options are:
NameTypeDescription
virtualFieldsObject

Or you can call them calculated fields. These fields are not stored in the database but instead will be calculated base on other fields when calling resolve() method.

includesObject

Define relationship fields which can be resolved and included in when calling resolve() method. Check the Relationship Section for more information about how to define a relationship.

indexesArray

Indexes which will be passed to MongoDB Driver createIndexes method.

validationsAsync Function

Custom validations which will be executed after the model validations have run

Below is an example of how to create a Model

// server/models/User.js

import mongodb from  "server/utils/mongodb"

const User = mongodb.createModel(
  "users",
  {
    username: { type: "String", isRequired: true, length: [5] },
    email: "String|Email|Required",
    password: "String|Private|Required",
    firstName: "String|Required",
    lastName: "String",
    phone: "Phone",
    friendIds: "[ID]",
    role: { type: "String", enum: ["user", "admin"], default: "user" }
  },
  {
    virtualFields: {
      fullName: (parent) => {
        return `${parent.firstName} ${parent.lastName}`
      }
    },
    includes: {
      friends: { ref: "users", keys: "friendIds" }
      posts: { ref: "posts", foreignKey: "authorId" }
    },
    indexes: [
      { key: { username: 1 }, unique: true },
      { key: { email: 1 }, unique: true }
    ],
    validations: async(input) => {
      // validating input
      // throw error if not valid
    },
  }
)

export default User

The model file should export default the Model created by the createModel method.


Model methods

CRUD Operations

Once the model is created, you can interact with the corresponding collection using the following methods:

  • async model.find(filter, options): get an array of records that match the filter criteria. Return Cursor<[Object]>
  • async model.findOne(filter, options): get the first record that match the filter criteria. Return Cursor<Object>
  • async model.findById(id, options): wrapping findOne() to get the record which has the id matched with id. Return Cursor<Object>
  • async model.findByIds(ids, options): wrapping find() to get the array of records which have the id appeared in the ids. Return Cursor<[Object]>
  • async model.countDocuments(filter, options): count the total of records that match the filter criteria. Return Number
  • async model.createOne(doc, options): validate and insert the doc into the database. Return Cursor<Object> of created record
  • async model.updateOne(filter, newDoc, options): validate and update the first record that match the filter criteria, new values in newDoc will replace old value in the database and keep the others the same. Return Cursor<Object> of updated record
  • async model.updateById(id, newDoc, options): wrapping updateOne() to update the record which has the id matched with id. Return Cursor<Object> of updated record
  • async model.deleteOne(filter, options): wrapping updateOne() to soft-delete (upsert the deletedAt field) the first record that match the filter criteria. Return Cursor<Object> of deleted record
  • async model.deleteById(id, options): wrapping deleteOne() to soft-delete the record that has the id matched with id. Return Cursor<Object> of deleted record

Cursor

Cursor is a special Class extends Object or Array class based on which methods are called. Basically, Cursor is the same as Object and Array classes but has 2 special methods:

  • async resolve(includes): a method to tell the Cursor to resolve virtualFields and relationship fields based on the includes passed in. includes is an Object to tell the Cursor which fields to keep, which to be removed after resolved.
  • toJSON(): call this method will return plain Object or Array type of the Cursor (without extra methods or properties)

Hooks

For createOne, updateOne and deleteOne, there are also hooks which allow you to append common logic before or after the database operations are executed so you don"t have to duplicate code in many places

To add the hooks, simply call the on() method (Event Emitter) with the event named like this "{before|after}:{method}". For example:

const User = mongodb.createModel(...)

User.on("before:createOne", (doc, options) => {
  // hook before createOne
})

User.on("after:updateOne", (filter, newDoc, options) => {
  // hook after updateOne
})

You should know

If you want to hook both createOne and updateOne in just one place, you can use the before:save or after:save event. And to check if it is create or update, check the oldDocument property in the options


Relationship and Data Loader

Data Loader is a generic utility to be used as part of your application"s data fetching layer to provide a simplified and consistent API over various remote data sources such as databases or web services via batching and caching. You can check out how the Data Loader works in the Official DataLoader Repo

Data Loader allow the ODM to batch resolving relationship fields among multiple documents, instead of resolving one by one which will cost significant amount of time and work load to the database.

To define a relationship, you can use the includes option when creating the Model. includes is an Object containing the relationship fields with the resolving information that will be included in the record when call resolve() on the Cursor. Below is how you can define includes:

// includes signature
// { ref: relatedCollection, [key | keys | foreignKey]: keyToLookup, filter: findFilter }

const Post = mongodb.createModel("posts", {
  title: "String",
  content: "String",
  authorId: "ID"
  published: "Boolean"
}, {
  includes: {
    author: { ref: "users", key: "authorId" }
  }
})

const User = mongodb.createModel("users", {
  email: "String",
  fullName: "String",
}, {
  includes: {
    posts: { ref: "posts", foreignKey: "authorId" },
    publishedPosts: {
      ref: "posts", foreignKey: "authorId",
      filter: { published: true }
    },
  }
})
  • ref: the collection name of the related Model you want to establish the relationship
  • key | keys | foreignKey: the name of the field whose value will be looked up when resolving the relationship with the _id field.
    • key is when the current Model is belong to related Model (Post belong to User with authorId key). Will return Object instance of the related record when resolve.
    • keys is also when the current Model is belong to related Model but the key field value is an array of ID ([ID]). Will return Array of related records when resolve.
    • foreignKey is when the related Model is belong to current Model (User want to get the Posts belong to it). Will return Array of related records when resolve.
  • filter: the filter you want to apply when resolving the related records (User want to get only the publishedPosts from Post Model)

For each include defined, there will be a dataloader that take responsibility to batch query the database and resolve related records.

Previous
Introduction