Thinodium

Light-weight, "thin" Object Document Mapper (ODM) for Node.js.

$ npm install --save thinodium

Thinodium wraps your database tables and data rows in an easy-to-use yet flexible API.

Overview:

What sets Thinodium apart from other heavier abstractions is that it does not dictate how inter-table relationships are represented, leaving you the freedom to do what's optimal for your db.

To use Thinodium within your code:

"use strict";

const Thinodium = require('thinodium');

Connecting

In order to connect to a database Thinodium requires you to install an available adapter for that database type. We're going to use the RethinkDB adapter for all the examples going forward.

$ npm install thinodium-rethinkdb

Now we can connect:

const db = yield Thinodium.connect('rethinkdb', {
  db: 'mydb',
});

The returned object db is an instance of Thinodium.Database.

Querying

Imagine we have a simple table - students - with the following data:

idnameagesports
1Tom19['football', 'tennis']
2Jim22['running']
3Abe98[]
4Candy5['hockey', 'netball']
5Ram27['climbing']
6Drew41['hockey']
7Jeff31['tennis']

To fetch a student:

const model = yield db.model('students');

console.log( model instanceof Thinodium.Model ); /* true */

// we pass in the primary key ("id" by default for RethinkDB)
let candy = yield model.get(4);

console.log( candy instanceof Thinodium.Document ); /* true */

console.log(candy.toJSON());

/*
  {
    id: 1,
    name: "Candy",
    age: 5,
    sports: ['hockey', 'netball']
  }
*/

Documents

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

let candy = yield model.get(4);

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

/*
  [ 'id', 'name', 'age' ]
*/

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

let candy = yield model.get(4);

candy.name = 'Jimbo';

yield candy.save();

/* We have renamed Candy to Jimbo */

Most databases allow for Array or Object fields. If you modify the contents of such a field you will need to tell Thinodium that the field got modified (as it cannot detect this by itself):

let candy = yield model.get(4);

candy.sports.push('mma');

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

yield result.save();

Raw data

To deal directly with raw db data and not use Thinodium.Document instances you can use the raw methods:

let candy = yield model.rawGet(4);

console.log( candy instanceof Thinodium.Document ); /* false */

console.log( candy );

/*
  {
    id: 4,
    name: 'Candy',
    age: 5,
  }
*/

The most powerful raw method is rawQry(). This returns a querying object which should allow you to execute any query supported by underlying database engine interface:

// The Thinodium RethinDB adapter uses rethinkdbdash, which pretty much gives us all the native RethinkDB query methods.

// get all tennis players, and only return their names
let players = yield model.rawQry().filter(function(row) {
  return row('sports').contains('tennis');
}).pluck('name').run();

console.log( players );

/*
  [
    {
      name: 'Tom'
    },
    {
      name: 'Jeff'
    }
  ]
*/

Raw querying methods always return raw data. But you can wrap the returned data in Document instances using the wrapRaw() method:

// get all tennis players, and only return their names
let players = yield model.rawQry().filter(function(row) {
  return row('sports').contains('tennis');
}).run();

players = model.wrapRaw(players);

console.log( players[0] instanceof Thinodium.Document ); /* true */

Inserting

Inserting data is done via the Model instance:

const model = yield db.model('students');

let amanda = yield model.insert({
  name: 'Amanda',
  age: 76,
  sports: []
});

console.log( amanda instanceof Thinodium.Document ); /* true */

console.log( amanda.toJSON() );

/*
  {
    id: // auto-generated id,
    name: 'Amanda',
    age: 76,
    sports: [],
  }
*/

Raw insertion mode is also supported:

let amanda = yield model.rawInsert({
  name: 'Amanda',
  age: 76,
  sports: []
});

console.log( amanda instanceof Thinodium.Document ); /* false */

console.log( amanda );

/*
  {
    id: // auto-generated id,
    name: 'Amanda',
    age: 76,
    sports: [],
  }
*/

Updating

Changes made to a Thinodium.Document can be saved:

let candy = yield model.get(4);

candy.name = 'Jimbo';
candy.sports = ['mma'];

yield candy.save();

Internally this method calls through to the rawUpdate() method:

// this does the same as the previous example
yield model.rawUpdate(4, {
  name: 'Jimbo',
  sports: ['mma']
});

Removing

Removing a document:

let candy = yield model.get(4);

yield candy.remove();

Internally this method calls through to rawRemove():

// does the same as previous example
yield model.remove(4);

Customization

Thinodium is a light-weight ODM, so out of the box it only gives you the bare essentials, such as get() and insert() for models.

However you can easily extend both Thinodium.Model and Thinodium.Document instances with custom methods and fields.

Model methods

const model = yield db.model('students', {
  modelMethods: {
    /**
     * Get students who play given sport.
     * @param {String} sport The sport.
     * @return {Promise} which resolves to array of Documents.
     */
    getStudentsWhoPlay: function(sport) {
      return this.rawQry().filter(function(row) {
        return row('sports').contains(sport);
      })
        .run()
        .then((results) => {
          return this.wrapRaw(results);
        });
    },
  }
});ยง

let students = yield model.getStudentsWhoPlay('tennis');

console.log( students.length ); /* 2 */

console.log( students[0] instanceof Thinodium.Document ); /* true */

Custom methods can be generator functions too. However it is recommended that your custom methods return Promise objects and that the actual results are wrapped in Document instances, as shown above.

Document methods

const model = yield db.model('students', {
  docMethods: {
    /**
     * Add given sport unless already present, and save.
     * @return {Promise} resolves once done.
     */
    addSport: function(sport) {
      if (!this.sports.contains(sport)) {
        this.sports.push(sport);
        this.markChanged('sports');
        return this.save();
      } else {
        return Promise.resolve();
      }
    }
  }
});

let jeff = yield model.get(7);

yield jeff.addSport('tennis');  /* does nothing */
yield jeff.addSport('mma'); /* adds mma */

Virtual fields

Virtual fields work like normal Thinodium.Document fields, except that their value is actually a getter function and they are usually read-only. And they do not get saved to the database.

const model = yield db.model('students', {
  docVirtuals: {
    reverseName: {
      get: function() {
        let name = this.name, reversed = '';

        for (let i = name.length - 1;
            i >= 0;
            reversed += nam[i--]);

        return reversed;
      }
    }
  }
});

let jeff = yield model.get(7);

console.log( jeff.reverseName ); /* ffej */

console.log( jeff.toJSON );

/*
  {
    id: 7,
    name: 'Jeff',
    age: 31,
    sports: ['tennis'],
    reverseName: 'jjef'
  }
 */

jeff.reverseName = 'test'; /* throws Error */

Virtual fields do not have to be read-only:

const reverseStr = function(str) {
  let reversed = '';

  for (let i = str.length - 1;
      i >= 0;
      reversed += str[i--]);

  return reversed;
}

const model = yield db.model('students', {
  docVirtuals: {
    reverseName: {
      get: function() {
        return reverseStr(this.name);
      },
      set: function(val) {
        this.name = reverseStr(val);
      }
    }
  }
});

let jeff = yield model.get(7);

jeff.reverseName = 'ruhtra';

console.log( jeff.name ); /* 'arthur' */

Schema validation

Thinodium can optionally validate the structure of your document against a schema you provide. The schema specified according what is supported by the simple-nosql-schema module.

const model = yield db.model('students', {
  schema: {
    name: {
      type: String
    },
    age: {
      type: Number,
      required: true
    }
  }
});

try {
  yield model.insert({
    name: 23,
    hasKids: true
  });
} catch (err) {
  console.log( err.failures );

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

Note: Although hasKids is present in the inserted document, the validator will ignore it since it isn't present in the schema.

Nested hierarchies can also be validated:

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

try {
  yield model.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",
    ]
  */
}

Index creation

The Thinodium core library does not assume the use of database indexes. However, individual db adapters may opt to support them. The RethinkDB adapter will create indexes for you if they don't exist:

const model = yield db.collection('students', {
  indexes: [
    { name: 'name' },
    {
      name: 'sports',
      options: {
        multi: true
      }
    }
  ]
});

// at this point the indexes are created and ready, so we can use them
// in queries.

let jeffs = yield model.rawQry().getAll("jeff", { index: 'name'}).run();

console.log( jeffs.length ); /* 1 */

Note: The thinodium-rethinkdb adapter readme has more information on what types of indexes can be created.

Hooks

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

Hooks are implemented as events which get emmitted during the raw querying methods:

const model = yield db.model('students');

// run before insertion
collection.on('before:rawInsert', function(attrs) {
  console.log('before');

  attrs.hasKids = true;
});

// run after insertion
collection.on('after:rawInsert', function(doc) {
  console.log('after');

  console.log(doc);
});

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

/*
  before
*/

/*
  after
  {
    id: // auto-generated id
    name: 'Janice',
    hasKids: true
  }
*/

Since Thinodium.Model inherits from EventEmitter all the usual event handling benefits are available.

The full list of events which can be listened to:

Each before: handler will receive all the arguments passed into the equivalent raw querying method.

Database adapters

Thinodium is database engine-agnostic. To add support for your database engine of choice you can write an adapter.

An adapter has to extend the base Database and Model classes and override the necessary internal methods:

/* File: /path/to/myAdapter.js */

"use strict";

const Thinodium = require('thinodium');

class Database extends Thinodium.Database {
  _connect (options) {
    return new Promise((resolve, reject) => {
      // do what's needed for connection here and save into "connection" var
      resolve(connection);
    });
  }

  _disconnect (connection) {
    return new Promise((resolve, reject) => {
      // disconnect connection
      resolve();
    });
  }

  _model (connection, name, config) {
    return new Model(connection, name, config);
  }
}

class Model extends Thinodium.Model {
  rawQry() {
    // return object for doing raw querying
  }

  rawGet (id) {
    return new Promise((resolve, reject) => {
      // fetch doc with given id
      resolve(doc);
    });
  }

  rawInsert (attrs) {
    return new Promise((resolve, reject) => {
      // insert doc
      resolve(doc);
    });
  }

  rawUpdate (id, changes, document) {
    return new Promise((resolve, reject) => {
      // update doc
      resolve();
    });
  }

  rawRemove (id) {
    return new Promise((resolve, reject) => {
      // remove doc with id
      resolve();
    });
  }
}

module.exports = Database;

Once you've written your adapter you can use it in Thinodium as follows:

const db = yield Thinodium.connect('/path/to/myAdapter.js', {
  /* connection options here */
});

Alternatively, if the adapter is an NPM module then you can load it in via its suffix name:

// Adapter is an NPM module: thinodium-myAdapter
const db = yield Thinodium.connect('myAdapter', {
  /* connection options here */
});

If you are going to publish your adapter as an NPM module please ensure the following:

All existing adapters are viewable on NPM.

API