Robe

RobeNode.js ODM for MongoDB using ES6 generators

$ npm install --save robe
View on Github

Robe wraps around monk to provide a simple ODM library for MongoDB.

Overview:

To use Robe within your code:

"use strict";

var Robe = require('robe');

Connecting

Robe supports multiple independent database connections:

var db1 = yield Robe.connect('127.0.0.1'),
  db2 = yield Robe.connect('127.2.1.1');

The returned object is an instance of Robe.Database.

Replica sets are also supported:

var db = yield Robe.connect([
  '127.0.0.1/db-name?replicaSet=test12', 
  '127.2.3.23/db-name?replicaSet=test12'
]);

If there is a connection error then the Error object thrown will contain a reference to the error returned by the monk connection handler:

try {
  yield Robe.connect([
    '127.0.0.1/db-name?replicaSet=test12', 
    '127.2.3.23/db-name?replicaSet=test12'
  ]);          
} catch (err) {
  console.log(err.message); /* Failed to connect to db */
  // do something with the error object returned by the native driver.
  console.log(err.root); 
}

Querying

Imagine we have a sample collection - students - with the following data:

_idnamescore
1Tom19
2Jim22
3Abe98
4Candy5
5Ram27
6Drew41
7Jeff31

To fetch results:

var collection = db.collection('students');

var results = yield collection.find({
  // the filtering query (all Mongo modifiers supported)
  score: {
    $gt: 20
  },
}, {
  // only include certain fields in the results
  fields: ['name'],
  // sort results
  sort: { name: 1 },
  // limit no. of results to return
  limit: 3,
  // ignore the first N results
  skip: 1,
});

console.log(
  results.map(function(r) {
    return r.name;
  })
);

/* 'Drew', 'Jeff', 'Ram' */

When you only need one result there is a shortcut method you can use:

var collection = db.collection('students');

var result = yield collection.findOne({
  // the filtering query (all Mongo modifiers supported)
  score: {
    $gt: 20
  },
}, {
  // only include certain fields in the results
  fields: ['name'],
  // sort results
  sort: { name: 1 },
  // ignore the first N results
  skip: 1,
});

console.log(result.name); 

/* 'Drew' */

Documents

Each returned result is an instance of Robe.Document. Documents encapsulate the raw data and can be enumerated just like plain objects:

var result = yield collection.findOne({
  // the filtering query (all Mongo modifiers supported)
  score: {
    $gt: 20
  },
}, {
  // only include certain fields in the results
  fields: ['name'],
  // sort results
  sort: { name: 1 },
  // ignore the first N results
  skip: 1,
});

// output the raw data
console.log( result.toJSON() );

/*
  {
    _id: 6,
    name: 'Drew',
    score: 41
  }
 */

// output document keys
console.log( Object.keys(result) );

/*
  '_id', 'name', 'score'
*/

Robe.Documents instances automatically keep track of which properties get changed and only update those properties in the database when you call save().

var result = yield collection.findOne({
  score: {
    $gt: 20
  },
});

result.name = 'Jimbo';

yield result.save();

/* 
Same as calling:

yield collection.update({_id: result._id}, {
  $set: {
    name: 'Jimbo'
  }
});
*/

When setting Array and Object properties of a Robe.Document you may need to additionally call the markChanged() method to tell Robe that that specific property got updated:

var result = yield collection.findOne({
  'location.city': 'NYC'
});

result.location.city = 'London';

// we need to tell Robe that the 'location' key got updated
result.markChanged('location');

yield result.save();

/* 
Same as calling:

yield collection.update({_id: result._id}, {
  $set: {
    location: {
      city: 'London'
    }
  }
});
*/
 

Raw data

If you do wish to deal directly with Mongo data and not use Robe.Document instances you can specify the rawMode option:

var results = yield collection.findOne({
  _id: 1
}, {
  // return Mongo data objects rather than Document instances
  rawMode: true 
});

console.log( result instanceof Robe.Document ); // false

console.log( result );

/*
  {
    _id: 1,
    name: 'Tom',
    score: 19
  }
*/

The rawMode option can be specified at the collection level so that you don't have to keep specifying it for every query:

var collection = db.collection('students', {
  rawMode: true
});

var results = yield collection.findOne({  _id: 1 });

console.log( result instanceof Robe.Document ); // false

Cursors (streaming)

Sometimes it's useful to be able to stream data from the database one document at a time, especially if we expect the result set to be quite large and thus wish to process it bit-by-bit.

Robe can give you an event-driven cursor to facilitate this:

Note: At the moment we use an event emitter (and thus, callbacks) when working with cursors. We hope to use generators instead in future.

var cursor = yield collection.findStream({  
  score: {
    $gt: 20
  }
}, {
  limit: 3,
  sort: {
    name: -1
  },
  fields: [ 'name' ],
});


cursor.on('result', function(doc) {
  console.log(doc instanceof Robe.Document);  // true
  console.log( doc.toJSON() );
});

cursor.on('error', function(err) {
  console.error(err);
});

cursor.on('success', function() {
  console.log('All done.');
});

/*
  {
    name: 'Tom'                
  }
  {
    name: 'Ram'                
  }
  {
    name: 'Jim'                
  }
  All done.
*/

Inserting

Inserting data is done via the Robe.Collection instance:

var collection = db.collection('students');

var item = yield collection.insert({
  name: 'Amanda',
  score: 76
});

console.log( item instanceof Robe.Document ); // true

console.log( item.toJSON() );

/*
  {
    _id: ...auto-generated mongo id...,
    name: 'Amanda',
    score: 76
  }  
*/

Raw insertion mode is also supported:

var collection = db.collection('students');

var item = yield collection.insert({
  name: 'Amanda',
  score: 76
}, {
  raw: true
});

console.log( item instanceof Robe.Document ); // false

console.log( item );

/*
  {
    _id: ...auto-generated mongo id...,
    name: 'Amanda',
    score: 76
  }  
*/

Updating

Changes made to a Robe.Document can be saved:

var item = yield collection.findOne({
  _id: 1
});

item.name = 'Martha';

yield item.save();

Internally this method calls through to the collection:

yield collection.update({
  _id: 1  
}, {
  $set: {
    name: 'Martha'
  }
});

When calling update() on the collection, ommitting $set will cause the entire document to be overriden, just as you would expect:

yield collection.update({
  _id: 1  
}, {
  name: 'Martha'
});

Removing

Removing a document:

var item = yield collection.findOne({
  _id: 1
});

yield item.remove();

Internally this method calls through to the collection:

yield collection.remove({
  _id: 1  
});

Custom methods

You can extend Robe.Collection instances with custom querying methods:

var collection = db.collection('students', {
  methods: {
    // Custom methods must be generator methods.
    findStudentsWithScoreAbove: function*(threshold) {
      // The 'this' context is automatically set to be the Collection instance
      return yield this.find({
        score: {
          $gt: threshold
        }
      }, {
        sort: {
          score: 1
        },
        raw: true
      });
    }
  }
});

yield collection.findStudentsWithScoreAbove(40);

/*
  [
    {
      _id: 3,
      name: 'Abe',
      score: 98
    },
    {
      _id: 6,
      name: 'Drew',
      score: 41
    }
  ]          
*/

Schema validation

Robe will validate the structure of your document against a schema you provide:

var collection = db.collection('students', {
  schema: {
    name: {
      type: String
    },
    score: {
      type: Number,
      required: true
    }
  }
});

yield collection.insert({
  name: 23,
  hasKids: true  
});

/*
  Error: Validation failed
*/

The thrown Error instance has a failures key which lists the specific validation failures. For the previous example:

console.log( err.failures );

/*
  [
    "/name: must be a string"
    "/score: missing value"
  ]
*/

Note: Although hasKids is present in the inserted document, since it isn't present in the schema it gets ignored.

Schema validation is done by simple-mongo-schema, allowing for nested hierarchies:

var collection = db.collection('students', {
  schema: {
    name: {
      type: String
    },
    address: {
      type: {
        houseNum: {
          type: Number,
          required: true
        },
        streetName: {
          type: String,
          required: true
        },
        country: {
          type: String,
          required: true
        }
      }
    }
  }
});

try {
  yield collection.insert({
    name: 23,
    address: {
      houseNum: 'view street',
      streetName: 23
    }
  });
} catch (err) {
  console.log(err.failures);

  /*
    [ 
      "/address/houseNum: must be a number",
      "/address/streetName: must be a string",
      "/address/country: missing value",
    ]
  */
}

Indexes

Robe supports the full MongoDB index specification, including compound, geospatial and other index types. Specify the indexes when fetching a collection:

var collection = db.collection('students', {
  schema: {
    name: {
      type: String
    },
    score: {
      type: Number,
    }
  },
  indexes: [
    // single-field index, unique
    {
      fields: {
        name: 1
      },
      options: {
        unique: true
      }
    },
    // compount-field index, custom index name
    {
      fields: {
        name: 1,
        score: 1,
      },
      options: {
        name: 'index2'
      }
    }
  ]
});

// Create the indexes if not already present. Throws Error if it fails.
yield collection.ensureIndexes();

Hooks

Hooks allow you to perform additional asynchronous processing upon data before and/or after all inserts, updates and removals.

You can register hooks against a Robe.Collection instance. Hooks get triggered regardless of whether you call methods on the collection or perform updates through a Robe.Document instance:

var collection = db.collection('students');

// run before schema validation and insertion
collection.before('insert', function*(attrs, next) {
  console.log('before');

  attrs.hasKids = true;

  yield next;
});

// run after successful insertion
collection.after('insert', function*(result, next) {
  console.log('after');

  console.log(result);

  yield next;
});

collection.insert({
  name: 'Janice'
});

/*
  before
  after
  {
    _id: ...mongo id...,
    name: 'Janice',
    hasKids: true
  }  
*/

Multiple handlers can be registerd for a given hook:

collection.before('remove', function*(search, next) {
  console.log('before 1');

  search._id += 5;

  yield next;
});

collection.before('remove', function*(search, next) {
  console.log('before 2');

  search._id += 1;

  yield next;
});

collection.before('remove', function*(search, result, next) {
  console.log('after: ' + result);

  console.log(search._id);

  yield next;
});

collection.remove({
  _id: 0
});

/*
  before 1
  before 2
  after: 1
  6
*/

For updates you get passed the search query as well as the update instructions:

collection.before('update', function*(search, update, next) {
  console.log('before 1');

  yield next;
});

collection.after('update', function*(search, update, result, next) {
  console.log('after 1');

  yield next;
});

collection.after('update', function*(search, update, result, next) {
  console.log('after 2');

  yield next;
});

collection.update({
  _id: 0
}, {
  $set: {
    name: 'Devon'
  }
});

/*
  before 1
  after 1
  after 2
*/          

Oplog tailing

If you connect to a Mongo replica set then Robe can notify you when data within your collections gets updated by your client process or any other.

This can be thought of as a pub/sub mechanism and is used to great effect in reactive framworks such as Meteor.

To get started:

var collection = db.collection("students");

// watch for any changes to the collection
yield collection.addWatcher(function(collectionName, operationType, data, metaData) {
  // collectionName = collection which got updated ("student" in this case)
  // operationType = one of: "insert", "update", "delete"
  // data = the data which got inserted or updated (an object)
  // metaData = e.g. the "_id" of the inserted document
});

The internal oplog query cursor is not activated until you add a watcher. To stop watching for changes:

var collection = db.collection("students");

var callback = function() { // ... };

// watch for any changes to the collection
yield collection.addWatcher(callback);

// stop watching
yield collection.removeWatcher(callback);

You can also access and use the oplog directly through the Database object:

// get the oplog
var oplog = yield db.oplog();

// start it
yield oplog.start();

// listen for any operation on any collection
oplog.onAny(function(collectionName, operationType, data, metaData) {
  // ...
});

// listen for any operation on the "student" collection
oplog.on('student:*', function(collectionName, operationType, data, metaData) {
  // ...
});

// listen for "insert" operations on the "student" collection
oplog.on('student:insert', function(collectionName, operationType, data, metaData) {
  // ...
});

Note: The current oplog tailing cursor implementation is naive and does not take into account how Mongo chooses to populate the oplog. Certain bulk operations MAY result in multiple notifications being sent to the registered watchers.

API