(4 minute read)
I've just released Thinodium, a flexible yet light-weight Object Document Mapper (ODM) for Node.js. In this post I'm going to talk about what it is, why it's useful, and my motivations for building it the way I did.
An ODM is a tool which allows you to interact with database tables as if they're objects (in the OOP sense). The benefit of this approach is that you get to interact with your database in a way which matches the mental model you use to interact with other objects within your app, without having to actually write database queries.
ODMs are built for interacting with document-oriented databases such as Mongo, CouchDB, etc. Their cousins - ORMs (Object Relationship Mappers) - are usually meant for interacting with relational databases such as Postgres. Nowadays though, many ODM and ORM libraries support both types, making the names almost interchangeable.
For example, in a typical ODM this is how I might update a user's last name:
// tell the ODM about the User table and provide its schema
const User = ODM.table('User');
// get user with id = 1
const doc = User.get(1);
// update the user's last name
doc.lastName = 'Smith';
// save it
doc.save();
The query to update the user's last name in SQL would look something like UPDATE Users SET lastName='Smith' WHERE ID=1. If it was RethinkDB the underlying raw query might be: r.table('Users').get(1).update({ lastName: 'Smith' });. Clearly the ODM API is more intuitive to use.
Relational databases don't just hold data in tables, they also specify how data in one table relates to data in another. Document-oriented databases don't really enforce inter-table relationships as they have different use-cases in mind. However, most ODMs will allow you to model inter-table relationships such that you can rely on them without thinking about what queries need to be run at the database level to maintain those relationships.
For example, let's assume that we're using RethinkDB and that every user has an associated country, i.e. User.countryId points to Country.id. In a typical ODM we would model this relationship in such a way that when we retrieve a user we will get the country too automatically:
// define the Country table
const Country = ODM.table('Country');
// define the User table
const User = ODM.createTable('User', {
// tell ODM to model our relationship
foreignKey: {
countryId: 'Country.id',
}
});
// get user with id = 1
const doc = User.get(1);
// output the user
console.log( doc instanceof User ); /* true */
// output the country
console.log( doc.country instanceof Country ) ; /* true */
// update country name
doc.country.name = 'France';
// this will update the country table
doc.country.save();
How convenient. If we were to use raw querying we would have to do something like:
// get connection to db
const r = require('rethinkdbdash')().db('mydb');
// get the user, and get the connected country, all in one
const ret = yield r.table('User').get(1)
.filter({id: 1})
.eqJoin('countryId', r.table('Country'))
.run();
// source table result is on the left, joined other table is on the right
const doc = ret.left[0];
const country = ret.right[0];
// console.log...
// update country name
yield r.table('Country').get(country.id).update({name: 'France'}).run();
Clearly it's easier with the ODM. But even though an ODM makes life easier there are issues one must generally keep in mind:
The ODM may not necessarily map what you want to do into the most efficient underlying database calls. A less-efficient ODM may have used two get() fetches instead of eqJoin() in the example above.
An ODM will never be able to abstract away all the types of queries supported by the underlying database engine, e.g. filter(), innerJoin().
Thus at some point, in order to ensure maximum querying efficiency you will be forced to use raw querying, despite the fact that you are using an ODM/ORM. In other words the abstraction being presented to you can never fully meet your needs without also enabling you to bypass the abstraction itself and run raw queries.
Thinodium tries to compensate for the limitations of an ODM by not trying to do too much in the first place. Thinodium does not model inter-table relationships, and it does not try to hide the underlying database engine's querying methods behind abstractions.
The user-country fetch we did earlier would look like the following in Thinodium:
const Thinodium = require('thinodium');
// connect to db
const db = yield Thinodium.connect('rethinkdb', {
db: 'mydb'
});
// define the tables
const User = yield db.model('User');
const Country = yield db.model('Country');
// fetch user and associated country using raw querying mode
const ret = yield User.rawQry()
.filter({id: 1})
.eqJoin('countryId', r.table('Country'))
.run();
// wrapRaw() wraps raw query results into ODM document objects
const doc = User.wrapRaw(ret.left[0]);
const country = Country.wrapRaw(ret.right[0]);
// update country name
country.name = 'France';
yield country.save();
Note: Thinodium uses Promises, no callbacks.
As you can see we have the best of both worlds. We can write powerful, optimised queries using the underlying database engine's methods. At the same time we can wrap the results within ODM document objects, allowing us to manipulate data with the same convenience as a typical ODM.
There are of course some basic methods built-in. You don't always have to use raw querying:
// insert a new user, will return a Thinodium.Document instance
const doc = yield User.insert({
firstName: 'john',
lastName: 'smith',
countryId: 1,
});
// update user's last name
doc.lastName = 'Wiley';
yield country.save();
// get country
let country = yield Country.get(1);
// update country name
country.name = 'France';
yield country.save();
Thinodium documents can also have virtual fields and custom methods. Model instances (i.e. the objects representing tables) can also be enhanced with schema validation and hooks among other things.
Note: For those who have used Robe (my ODM for MongoDB), you will notice that Thinodium re-uses and improves upon Robe's document architecture.
Since Thinodium exposes the underlying raw querying engine and doesn't try to abstract away the details too much it is easy to support more than one database engine. To install and use thinodium you need both the core package as well as an adapter for your database of choice. E.g. for RethinkDB:
$ npm install thinodium thinodium-rethinkdb
Writing your own adapter means extending the base Thinodium.Database and Thinodium.Model classes and implementing the necessary methods therein. More information on this is available in the adapter docs.
Right now there is just one adapter, and that's for RethinkDB. I plan to add support for Mongo at some point, which may result in Robe being deprecated. Also, I'm wondering whether to add first-class support for database updates observes (i.e. oplog tailing in Mongo, changes() in Rethink) or leaves that to library users to do so themselves.
The current list of adapters is always visible at https://www.npmjs.com/search?q=thinodium.
Links: