RobeNode.js ODM for MongoDB using ES6 generators
$ npm install --save robe
View on GithubRobe wraps around monk to provide a simple ODM library for MongoDB.
Overview:
- ES6 - no callbacks needed.
- Supports standard Mongo query modifiers.
- Connect to hosts and replica sets.
- Use documents or raw data, your choice.
- Add hooks for further processing.
- Stream results using cursors.
- Add schema validation and custom methods.
- Notifications via mongo oplog tailing.
- Clean, object-oriented API.
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:
_id | name | score |
---|---|---|
1 | Tom | 19 |
2 | Jim | 22 |
3 | Abe | 98 |
4 | Candy | 5 |
5 | Ram | 27 |
6 | Drew | 41 |
7 | Jeff | 31 |
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.