rethinker

ActiveRecord-like API service layer for RethinkDB

Rethinker

Rethinker offers a minimalist ActiveRecord-like API service layer for RethinkDB, the main focus is to simplify the relational queries for has-one, has-many, many-many relationships, with filters, and nested relational query support.

#Install

npm install rethinker

#Running Tests

Ensure that RethinkDB is installed correctly, and it's listening on port 28015. Then run the tests with

npm test

#Getting started

Let's assume we have the following entries and their relationships in our model:

A onlne course can be composed by many video lectures, which can be either be public or private, and a course is enrolled by many students.

And we would like to query the following:

  • All courses along with their private lectures, with video related data if it's available
  • All students with email ending in '@institution.org', along with their enrolled courses

##1. Initialize rethinker with database connection string

 
var Rethinker = require('rethinker').init({
  host: 'localhost',
  port: 28015,
  db: 'test',
  pool: { // optional settings for pooling (further reference: https://github.com/coopernurse/node-pool) 
    max: 100,
    min: 0,
    log: false,
    idleTimeoutMillis: 30000,
    reapIntervalMillis: 15000
  }
})
 

##2. Initialize services

 
var LecturesService = Rethinker.extend({
    modelName: 'Lecture',
    tableName: 'lectures', // optional, by default it takes the modelName, lowercase it, and make it plural 
    relations: {
        hasOne: {
            course: {  //for simplicity, 'has one course' is the same as 'belongsTo a course' 
                on: 'courseId', // attribute defined on the 'lectures' table 
                from: 'Course'
            },
            video: {
                on: 'videoId',
                from: 'Video'
            }
        }
    }
});
 
var CoursesService = Rethinker.extend({
    modelName: 'Course',
    relations: {
        hasMany: {
            videoLectures: {
                on: 'courseId',
                from: 'Lecture',
                filter : function(lecture){ // this will be used for 'filter' method in the rethinkdb API 
                  return lecture.ne(null);
                }
            },
            students: {
                on: ['courseId', 'studentId'],
                through: 'courses_students', //table 'courses_students' has to be created manually for now 
                from: 'Student'
            }
        },
        hasOne: {
            privateLecture: {
                on: 'courseId',
                from: 'Lecture',
                filter: {
                    private: true
                }
            }
        }
    }
});
 
var StudentsService = Rethinker.extend({
    modelName: 'Student',
    relations: {
        hasMany: {
            'enrolledCourses': {
                on: ['studentId', 'courseId'],
                through: {
                    tableName: 'courses_students',
                    filter: {
                        enrolled: true
                    }
                },
                from: 'Course'
            }
        }
    }
});
 
var VideosService = Rethinker.extend({
    modelName: 'Video',
})
 
var lecturesService = new LecturesService(),
    coursesService = new CoursesService(),
    studentsService = new StudentsService(),
    videosService = new VideosService();
 

##3. Querying data

#####All courses along with their private lectures ordered by createTime, with video related data if it's available

 
coursesService.findAllCourse(null, {
  with : {
    related : 'privateLecture',
    orderBy : 'createTime desc',
    with : 'video'
  }
})
 
//Sample result
[{
   id : '0f5a54ea-dba3-4eda-b44f-faf17ab1c9e4',
   title : "Course I",
   privateLecture : {
      courseId : '0f5a54ea-dba3-4eda-b44f-faf17ab1c9e4',
      private : true,
      createTime : 1394630809686,
      videoId : '400693be-de3d-4f41-80d3-86f58eb26cc6'
      video : {
        id : '400693be-de3d-4f41-80d3-86f58eb26cc6',
        url : 'path/video1.mp4'
      }
      
   }
},
...
]

#####All students with email ending in '@institution.org', along with their enrolled courses

studentsService.findAllStudent(function(studentRow){ 
  return studentRow('email').match('@institution.org') 
}, {with : 'enrolledCourses'})
 
//Sample results
 
[{
  name : "Khanh Luc",
  email : "khanh@institution.org",
  enrolledCourses : [{
    id : "0f5a54ea-dba3-4eda-b44f-faf17ab1c9e4"
    title : "Course I",
  },
  ...
  ]
}
....
]
 

#CRUD operations

By initializing the service layer as:

var CoursesService = Rethinker.extend({modelName : 'Course'})

Rethinker adds the following methods to CoursesService.prototype

###Create

CoursesService.prototype.createCourse([jsonData, options]) -> Promise

The options argument is optional. It can be an object with the fields:

  • validate : whether to call validation method on saving the data (default = true)
  • returnVals : whether or not to return the saved value, it also supports multiple insert (default = true)
 
//Example 
var coursesService = CoursesService.getService(); // returns singleton instance of coursesService 
coursesService.createCourse({ // insert a single course data 
  title : "Physics I"
}).then(function(course){
  //course : {id: ... , title : 'Physics I'} 
})
 
coursesService.createCourse([ // insert multiple courses data 
  { title : "Physics II"},
  { title : "Physics III"}
]).then(function(courses){
  //course : [{id: ... , title : 'Physics II'}, {id: ... , title : 'Physics III'}] 
})
 

###Retrieve

CoursesService.prototype.findCourse([queryCriteria, options]) -> Promise
CoursesService.prototype.findAllCourse([queryCriteria, options]) -> Promise

The queryCriteria can be set as either object, function or string:

  • object/function: the filter method is invoked to query the data
  • string: when options.index is not set, the value is treated as primary key, otherwise getAll method is invoked to query the data

In order to query all the data in the table, the queryCriteria argument can be set to null in findAllCourses method

The options argument is optional. It can be an object with the fields:

  • index : same index value to be passed to the API
  • orderBy : same as orderBy, with a minor syntax difference: orderBy: r.desc('createTime') can be written as orderBy: 'createTime desc'
  • fields : same as pluck, it also can be provided with an array of field names: fields : ['id', 'title', 'createTitle']
  • with : can be set as either string, array, or object
    • string : name of the relationship previously defined
    • array : an array of relational query options,
    • object : used when need to apply some filtering or query nested relational data
      • related : name of the relationship relative to the resulting queried data
      • filter : filter the results using filter
      • orderBy : order the resulting relational data
      • fields : pluck fields from the resulting relational data
      • with : in case further nested relational data need to be fetched, same options above are also applied
 
//Example 
var lecturesService = LecturesService.getService(), // returns singleton instance of lecturesService 
    coursesService = CoursesService.getService();
    
lecturesService.findLecture('143ef66b-58fd-41d0-b019-30818841699f') // find lecture by id 
lecturesService.findLecture(user.id, {index : 'userId'}) // retrieve a single lecture by secondary index 'userId' 
lecturesService.findLecture({title : "Lecture I"}, {fields : 'title'}) // find lecture's title by title 
lecturesService.findAllLecture(function(lecture){
  return lecture.hasFields('videoId')
}, {orderBy : 'title desc'}) // find all lectures that has the videoId attribute, ordered by title 
coursesService.findAllCourse(null, { // find all the courses with enrolled students, and private video lectures ordered by title 
  with : ['students', {
      related : 'lectures',
      filter : {
        private : true
      },
      orderBy : 'title',
      with : 'video'
    }
  ] 
}).then(function(courses){
  /*
    courses : [
      { 
        id : '..',
        title : 'Physics I',
        lectures : [{ id: ..., title : 'Lecture I', private : true, videoId : ..., video : {...} }...],
        students : [{ ... }]
      }, 
      ...
    ]
  */
})
 

###Update

CoursesService.prototype.updateCourse([jsonData, queryCriteria, options]) -> Promise
CoursesService.prototype.updateAllCourse([jsonData, queryCriteria, options]) -> Promise

The jsonData is the data to be updated, queryCriteria and options are the same ones described in Retrieve section, with additional options: validate, returnVals described in the Create section

 
//Example 
var videosService = VideosService.getService(); // returns singleton instance of videosService 
videosService.updateVideo({url : "path/newName.mp4"}, '3e3a00a1-7d5c-4ed3-9a10-7494d81919eb').then(function(){ // update video by it's primary key 
}).then(function(video){
   // video : video json data with updated values 
})
 
videosService.updateAllVideo({url : "path/newName.mp4"}, req.user.id, {index : 'userId'}) // update all user's videos  
  .then(function(videos){
    //returns an array of updated video values 
  })

###Delete

CoursesService.prototype.deleteCourse([queryCriteria, options]) -> Promise

queryCriteria and options are the same ones described in Retrieve section

 
//Example 
coursesService.findCourse({title : 'Physics I'})
  .then(function(course){
    return lecturesService.deleteLecture(course.id, {index : 'courseId'}) // delete all lectures in 'Physics I' 
  })
 
coursesService.deleteCourse() // delete all courses 

#Additional methods

Also the following additional methods are available, all of them return promise

CoursesService.prototype.validateCourse([jsonData]) -> Promise // return false to cancel the persistence task
CoursesService.prototype.beforeCreateCourse([jsonData]) -> Promise // return false to cancel the insert task
CoursesService.prototype.beforeUpdateCourse([jsonData]) -> Promise // return false to cancel the update task
CoursesService.prototype.beforeSaveCourse([jsonData]) -> Promise // return false to cancel the persistence task
CoursesService.prototype.afterCreateCourse([jsonData]) -> Promise
CoursesService.prototype.afterUpdateCourse([jsonData]) -> Promise
CoursesService.prototype.existCourse([jsonData]) -> Promise
 

#Extend default methods

Each instance of Rethinker exposes the following attributes/methods that allow to build a complex queries more easily:

  • r : exposes the rethinkdb API
  • table : exposes the table instance, takes the this.tableName to initialize the r.table(this.tableName)
  • db : expose the DB instance with the run method
  • buildQuery : function buildQuery(queryCriteria, opts, tableName) -> Promise
 
OrdersService.prototype.someOtherBusinessLogics ...
 
OrdersService.prototype.findAllOrder = function (queryDataoptsfilters) { // override the default findAll method to support extra filter options 
    !opts && (opts = {});
    !filters && (filters = {});
    var orderQuery = filters.|| "",
        query = this.buildQuery(queryData, _.merge({orderBy: [filters.sort, filters.order].join(' ')}, opts));
 
    if (orderQuery.length > 0) {
        query = query.filter(function (order) {
            return order('orderId').match(orderQuery)
                .or(order('code').eq(orderQuery))
                .or(order('user')('name').match(orderQuery))
                .or(order('user')('email').match(orderQuery))
                .or(order('user')('address').match(orderQuery));
        });
    }
 
    return this.db.run(query);
};
 

Rethinker also exposes a DB instance, basically it wraps around the run method using pooling and returns a promise

 
var r = require('rethinkdb'),
    DB = require('rethinker').DB,
    db = new DB({
      host: 'localhost',
      port: 28015,
      db: 'test',
      pool: { // optional settings for pooling (further reference: https://github.com/coopernurse/node-pool) 
        max: 100,
        min: 0,
        idleTimeoutMillis: 30000,
        reapIntervalMillis: 15000
      }
    });
    
    db.run(r.tableCreate('courses_students')).then(function(result){
    
    });
 

#Save relational data

Currently it only supports saving has-one relationships

 
var BookingService = Rethinker.extend({
      modelName : 'Booking',
      relations : {
        hasOne : {
          activeOrder : {
            on : 'bookingId',
            from : 'Order',
            filter : {
              active : true
            }
            sync : true
          },
          completedOrder : {
            on : 'bookingId',
            from : 'Order',
            filter : {
              active : false,
              completed : true
            }
            sync : 'readOnly'
          }
        }
      }
    });
    
    OrdersService = Rethinker.extend({
      modelName : 'Order'
    }
})
 

The sync property in each relation declaration is used to specify whether or not to save those related data. When inserting the following data to the database:

 
var bookingService = new BookingService();
bookingService.createBooking({
  date : new Date(),
  userId : req.user.id,
  activeOrder : {
    active : true,
    completed : false
  },
  completedOrder : {
    active : false,
    completed : true
  }
});
 

It will generate the following data in 'booking' table and the 'orders' table:

 
//booking table 
{
  id : 'b0de0baa-5028-4da4-ae08-456b1c0d7239'
  date : ...
  userId : ..
}
 
//orders table 
{
  id : ...
  active : true,
  completed : false,
  bookingId : 'b0de0baa-5028-4da4-ae08-456b1c0d7239'
}
 

Notice that in order to avoid data duplicity, the activeOrder, and completedOrder attributes are not saved in the booking table. Also in the orders table, only the activeOrder is saved since it has the property sync : true

Please refer the test file for further usage example of this option.

#FAQ

##Is this an ORM? Not quite so, the main intend is to offer a wrapper around the official API, placing the main emphasis on querying relational (nested relational) data with less code, it's basically a mixin that decorates methods in a class prototype chain. If you are looking for fully featured ORM solution, there are couple of alternatives: Thinky, Reheat

##Does this offer validation layer? Personally i use the validate hook along with express-validator library to validate the incoming data manually, might consider to add a validation layer in the future releases.

##Can the API be simplified? Like instead of coursesService.findAllCourse, can't it just be courses.findAll? Sure thing, it's just my personal preference, when i'm refactoring, finding 'findAllCourse' usage is a lot more easier, and less error prone than just 'findAll', will consider to add an extra option for this.

##What version of RethinkDB supports? As RethinkDB hasn't reach the LTS release yet, use of latest version of RethinkDB would be recommended.