NodeJS MongoDB : Schema Validation, Middleware

In this blog post we will see advance usage of mongoose library.

In the previous blog post we saw how to perform various operations on mongodb in fast way. We didn’t define any schema , validation and other feature. In this post we will see how to to use all mongoose features.

Collection Schema

Each collection on your mongo database is represented using a schema. Schema are basically column definitions of your collection, which you provide to mongoose to help out in validations, type checking and other operations.

Lets first define a very simple Schema and use it

var userSchema = mongoose.Schema({
    firstname: String,
    lastname: String,
    created_at: Date,
    is_active: Boolean
});


var User = conn.model('User', userSchema);

var u1 = new User({
    firstname: 'Manish',
    lastname: 'Prakash',
    created_at: new Date(),
    is_active: true
});
u1.save(function (err) {
    if (err) {
        console.log(err);
    } else {
        console.log('Done');
    }
});

This is how it gets saved in mongodb, using all proper data types.

{
"_id": ObjectId("548d0b64fbcb585c0b8292c4"),
   "firstname": "Manish",
   "lastname": "Prakash",
   "created_at": ISODate("2014-12-14T04:00:36.811Z"),
   "is_active": true,
   "__v": NumberInt(0)	
}

Schema Validations

We can also extend schema for validation of our objects. I will expand our user schema to include more options

var allowedTypes = ['Level1','Level2','Level3'];
var userSchema = mongoose.Schema({
    firstname: String,
    lastname: String,
    created_at: Date,
    is_active: Boolean,
    meta: {
        friends: Number,
        likes: Number,
        votes: Number,
        dislikes: Number
    },
    comments: [{body: String, date: Date}],
    updated_at: {type: Date, default: Date.now},
    age: {type: Number, min: 5, max: 40},
    type: {type: String, enum: allowedTypes},
    username: {type: String, lowercase: true, required: true, trim: true},
    internal_name: {type: String, match: /int_/},
    last_payment_date: {type: Date, default: Date.now, expires: 60 * 60 * 31}
});
var User = conn.model('User', userSchema);
var u1 = new User({
    firstname: 'Manish',
    lastname: 'Prakash',
    created_at: new Date(),
    is_active: true,
    username : 'manish_iitg'
});
u1.save(function (err) {
    if (err) {
        console.log(err);
    } else {
        console.log('Done');
        process.exit();
    }
});

This is what we see in our database

 {
   "_id": ObjectId("548d0f141b41baf82259d815"),
   "firstname": "Manish",
   "lastname": "Prakash",
   "created_at": ISODate("2014-12-14T04:16:20.542Z"),
   "is_active": true,
   "username": "manish_iitg",
   "last_payment_date": ISODate("2014-12-14T04:16:20.544Z"),
   "updated_at": ISODate("2014-12-14T04:16:20.543Z"),
   "comments": [
    
  ],
   "__v": NumberInt(0)
}	

As you can see ‘last_payment_date’ and ‘updated_at’ have been filled up with default values.

If we try to save this model

var User = conn.model('User', userSchema);
var u1 = new User({
    firstname: 'Manish',
    lastname: 'Prakash',
    created_at: new Date(),
    is_active: true,
    type : 'Invalid',
    age : 2,
    internal_name : 'xyz'
});
u1.save(function (err) {
    if (err) {
        console.log(err);
    } else {
        console.log('Done');
        process.exit();
    }
});

We get a bunch of error message due to the validation applied.

  { [ValidationError: User validation failed]
  message: 'User validation failed',
  name: 'ValidationError',
  errors: 
   { username: 
      { [ValidatorError: Path `username` is required.]
        properties: [Object],
        message: 'Path `username` is required.',
        name: 'ValidatorError',
        kind: 'required',
        path: 'username',
        value: undefined },
     internal_name: 
      { [ValidatorError: Path `internal_name` is invalid (xyz).]
        properties: [Object],
        message: 'Path `internal_name` is invalid (xyz).',
        name: 'ValidatorError',
        kind: 'regexp',
        path: 'internal_name',
        value: 'xyz' },
     age: 
      { [ValidatorError: Path `age` (2) is less than minimum allowed value (5).]
        properties: [Object],
        message: 'Path `age` (2) is less than minimum allowed value (5).',
        name: 'ValidatorError',
        kind: 'min',
        path: 'age',
        value: 2 },
     type: 
      { [ValidatorError: `Invalid` is not a valid enum value for path `type`.]
        properties: [Object],
        message: '`Invalid` is not a valid enum value for path `type`.',
        name: 'ValidatorError',
        kind: 'enum',
        path: 'type',
        value: 'Invalid' } } }

We have also used another schema option ‘expires’ in ‘last_payment_date’, this basically sets the seconds after which a document should be deleted automatically by mongo. It works on version v2.4 and later. More details here http://docs.mongodb.org/manual/tutorial/expire-data/

There are also custom mongoose schema type which you can include in your collection like Email, URL more details here https://www.npmjs.com/package/openifyit-mongoose-types

Schema Custom Validation

Lets add a custom validation for 10 digit mobile number.

var allowedTypes = ['Level1','Level2','Level3'];
var userSchema = mongoose.Schema({
    firstname: String,
    lastname: String,
    created_at: Date,
    is_active: Boolean,
    meta: {
        friends: Number,
        likes: Number,
        votes: Number,
        dislikes: Number
    },
    comments: [{body: String, date: Date}],
    updated_at: {type: Date, default: Date.now},
    age: {type: Number, min: 5, max: 40},
    type: {type: String, enum: allowedTypes},
    username: {type: String, lowercase: true, required: true, trim: true},
    internal_name: {type: String, match: /int_/},
    last_payment_date: {type: Date, default: Date.now, expires: 60 * 60 * 31},
    phone: {
        type: String,
        required: true,
        validate: {
            validator: function(v) {
                return /^[0-9]{10}$/.test(v);
            },
            message: '{VALUE} is not a valid phone number!'
        }
    },
});

var User = conn.model('User', userSchema);
var u1 = new User({
    firstname: 'Manish',
    lastname: 'Prakash',
    created_at: new Date(),
    is_active: true,
    username : 'manish_iitg',
    phone : '987654'
});
u1.save(function (err) {
    if (err) {
        console.log(err);
    } else {
        console.log('Done');
        process.exit();
    }
});

The above will throw a validation error. Since 10 digit phone number is required.
Below code with 10 digit phone number will work.

var u1 = new User({
    firstname: 'Manish',
    lastname: 'Prakash',
    created_at: new Date(),
    is_active: true,
    username : 'manish_iitg',
    phone : '9876543210'
});

Schema Instance Methods and Static Methods

We can set different functions to our Schema as static and instance methods. Static method apply to model directly and instance methods apply to model documents. Let see how it works

Instance Methods

userSchema.methods.isLevelAllowed = function (cb) {
    var age = this.age;
    var type = this.type;
    var model = this.model('User');
    //can perform any database operation on the model as well
    if (age == 10 && (type == 'Level2' || type == 'Level3')) {
        cb(false, false);
    } else {
        cb(false, true);
    }
}
var User = conn.model('User', userSchema);
//make sure model is created after instance method is defined
var u1 = new User({
    firstname: 'Manish',
    lastname: 'Prakash',
    created_at: new Date(),
    is_active: true,
    username: 'manish_iitg',
    type: 'Level3',
    age: 5,
    internal_name: 'int_xetro',
    meta: {
        likes: 1
    }
});
//isLevelAllowed is an instance method, hence used on a document.
u1.isLevelAllowed(function (err, allowed) {
    if (allowed) {
        console.log('Allowed');
    } else {
        console.log('Not Allowed');
    }
    process.exit();
});

Static Method

userSchema.statics.findAllByLevel = function (level, cb) {
    this.find({
        level: level
    }, function (err, result) {
        cb(err, result);
    })
}
var User = conn.model('User', userSchema);
User.findAllByLevel('Level1', function (err, results) {

})
//static methods get directly called on model itself.

Schema Indexes

Mongodb also supports many indexes which we can apply using schemas

var userSchema = mongoose.Schema({
    firstname: String,
    lastname: String,
    created_at: Date,
    is_active: Boolean,
    email : {type:String,required:true,index:{unique:true}}, //field level
    meta: {
        friends: Number,
        likes: {type: Number, default: -1},
        votes: Number,
        dislikes: Number
    },
    comments: [{body: String, date: Date}],
    updated_at: {type: Date, default: Date.now},
    age: {type: Number, min: 5, max: 40},
    type: {type: String, enum: allowedTypes},
    username: {type: String, lowercase: true, required: true, trim: true},
    internal_name: {type: String, match: /int_/},
    last_payment_date: {type: Date, default: Date.now, expires: 60 * 60 * 31}
});
userSchema.index({firstname:-1,lastname:-1}); //schema level

If for some reason indexes are not getting created, restart mongodb server and check if index constraints are not failing already. Which means for index unique on email field, if you db already has duplicate emails indexes won’t work.
You can view indexes in rockmongo from DB -> Collection > More -> Indexes

Mongoose also tries to create indexes on each type application start, which causes overheads. To stop this do

userSchema.set('autoIndex',false);

There are few other schema options which can be seen here http://mongoosejs.com/docs/guide.html#autoIndex

Schema Middleware

Middleware are basically functions which are called during the flow of execution of a model. There are two types of middleware ‘pre’ and ‘post’ and 4 different execution flow methods init, validate, save and remove.

userSchema.pre('save', function (next) {
  // do stuff
  next();
});

if some async operation is required

userSchema.pre('save',true, function (next,done) {
  // do stuff
  next();
  asyncOperation(done);
});

so model ‘save’ will only be called when all asyncOperations are done. We can also pass error objects to middleware

userSchema.pre('save', function (next) {
  var err = new Error('something went wrong');
  next(err);
});

More details can be seen here http://mongoosejs.com/docs/middleware.html

Schema Population

Mongodb doesn’t have a concept of joins, but when we need to store relationships we use multiple collection and ObjectIds for the same. Mongoose provides an easy way to load collections with relationships, if we define it in scheme.

I will create two schema to demonstrate the same

var userSchema = mongoose.Schema({
    firstname: String,
    lastname: String,
    created_at: {type: Date, default: Date.now},
    is_active: {type: Boolean, default: true},
    email: {type: String, required: true, index: {unique: true}},
    comments: [{type: Schema.Types.ObjectId, ref: 'Comment'}]
});
var User = conn.model('User', userSchema);

var commentSchema = mongoose.Schema({
    text: String,
    created_at: {type: Date, default: Date.now},
    by: {type: Schema.Types.ObjectId, ref: 'User'}
});

var Comment = conn.model('Comment', commentSchema);

We use the ‘ref’ parameter to define relationships. With the code below i will insert data into database as per the relationship.

Till now everything is normal, we have inserted 2 users and 2 comments. Now we can use the ‘populate’ method to load relationship automatically e.g

User.find({
    email: 'manish_iitg@etech.com'
}).populate('comments').lean().exec(function (err, result) {
    console.log(result);
});

This will load replace comment_ids with comment objects automatically. We can also add extra where condition in the populate function as below

User.find({
    email: 'manish_iitg@etech.com'
}).populate({
    path: 'comments',
    match: {text: /Comment/i}
}).lean().exec(function (err, result) {
    console.log(result);
});

As we can easily populate comment_ids with comment objects, we can easily do the reverse.

var u1 = new User({
    firstname: 'Manish',
    lastname: 'Prakash',
    email: 'manish_iitg2@etech.com',
    comments: []
});

u1.comments.push(new Comment({
    text: 'Text1'
}));
u1.comments.push(new Comment({
    text: 'Text2'
}));

u1.save(); 

This will automatically replace ‘comments’ with ObjectIds.