Node.js ORM for Cassandra 2.1+. Inspired by SQLAlchemy. WIP.
Status: We're rolling out the ORM in production in the coming days/weeks. We plan to leave it in its current state for now, with some tweaks and bugfixes perhaps. Later on we plan to develop eloquent support for things like relations and denormalization, as we see common patterns that arise as we use this ORM.
Goals/features:
- A fluent query builder for interacting directly with the database.
- An ORM tailored to leverage Cassandra's performance boons.
- An ORM that works how you'd expect and gets out of your way.
- Emphasis on providing 100% coverage of common (primarily CRUD) database operations, without having to write any raw query.
- A system which lends itself well to automated migrations. (todo: built-in migrations support?)
- Extremely performant, low overhead. Queries are built faster than Bluebird promises can be resolved.
- Promises. Promises everywhere.
var Cassandra = require('artisan-cassandra-orm');
var c = new Cassandra({ contactPoints: ['127.0.0.1'], keyspace: 'middleEarth' });
c.select()
.from('hobbits')
.where('last_name', '=', 'baggins')
.limit(2)
.then(function (results) {
// ...
})
This is based is datastax/nodejs-driver. You can create a connection by creating a new Connection object. Options are the same as Datastax's driver.
var Cassandra = require('artisan-cassandra-orm');
var c = new Cassandra({ contactPoints: ['127.0.0.1'], keyspace: 'middleEarth' })
We promisify and provide some additional methods atop the driver. The following method/properties are available:
.execute(query: String[, params: Array[, options: Object]]) -> Promise
.batch(queries: Array[, options: Object]]) -> Promise
.shutdown() -> Promise
.connect([options: Object]) -> Promise
getReplicas
,stream
, andeachRow
are passed verbatim to the connection object..client
is the underlying Datastax client.
The connection is an EventEmitter, and you can listen for queries on it. For instance, this will log queries to the stdout when the environment isn't production:
if (process.NODE_ENV !== 'production') {
connection.on('query', function (query, parameters) {
console.log(query);
console.log(parameters);
});
}
// Later ...
connection.execute('select * from hobbits where last_name = ?;', ['baggins']);
// Console:
// => select * from hobbits where last_name = ?;
// => ['baggins']
The query builder is at the core of the ORM. Although it is preferable to interact via models, you can create a query builder directly by calling .select()
, .insert()
, .delete()
, and .update()
on a connection.
These builders are promise-friendly. By calling a promise method, such as .then
or .catch
on the builder, it knows to execute the built query. Builders may expose several components:
Note: For any of these components, you can pass in a raw string or a Column object for any string argument.
Raw strings (created with c.Stmt.Raw(String)
) will always be inserted into queries without any additions, modifications, or parameterization.
To create a tuple, use c.Stmt.Tuple(elements...)
. Tuples can, of course, be nested.
.where('profession', '=', c.Stmt.Tuple('burglar', 'adventurer');
// => `WHERE profession = ('burglar', 'adventurer')`
We use the tuple here to differentiate it from a List or Set, which are the only data represented as an array.
Available on Select and Delete as .from()
, on Insert as .into()
, and on update as .table()
.
It takes either a string, table object, or model object as its first and only parameter, and sets the query to be executed on that table.
.table('hobbits')
Available on Select, Insert, and Select. Exposes a method "columns" which takes an array of strings or Columns as its first argument, or many strings or columns as a ternary argument.
.columns('first_name', 'last_name')
// or...
.columns(['first_name', 'last_name'])
Available on Select, Insert, Delete, and Update.
.andWhere
alias:.where
. Adds an "AND" condition to the builder..orWhere
. Adds an "OR" condition to the builder.
Both these methods are used in the same way.
- If passed three arguments, it expects them to be
column, operator, value
. The value will be parameterized unless a Raw string is passed. - If passed a function as the first an only parameter, it creates a where grouping. For example:
c.select()
.from('users')
.where('profession', 'CONTAINS', 'wizard')
.orWhere('beard_length', '=', c.Stmt.Raw('\'long\''))
.andWhere(function (w) {
w.where('location', '=', 'mordor')
.orWhere('location', '=' 'shire')
});
Outputs:
SELECT * FROM users
WHERE profession CONTAINS ?
OR beard_length = 'long'
AND (location = ? OR location = ?)
Params: ['wizard', 'mordor', 'shire']
Options are available on insert, delete, and update queries. The following two method are exposed:
.ttl(seconds: Number)
Sets the time to live of the operation..timestamp(microseconds: Number)
Sets the update time (update), creation time (insert), or tombstone record (delete) of the record.
Conditionals are available on insert, delete, and update queries.
In Cassandra 2.0.7 and later, you can conditionally [CRUD] columns using IF or IF EXISTS. Conditionals incur a non-negligible performance cost and should be used sparingly.
They expose a method .when(key[, value])
. If a value is not passed, it's an IF EXISTS statement. Example:
.when('ring_of_power')
// => IF EXISTS ring_of_power
.when('ring_owner', 'gollum')
// => IF ring_owner = ?
The select builder provides the components listed above:
- table as
.from
- columns (
.columns
) - where (
.where
,.andWhere
,.orWhere
)
Additionally, the following two methods:
.limit(amount: Number)
Limits the query to the following number..filter([filtering: Boolean=true])
TurnsALLOW FILTERING
on or off..orderBy(column: String, direction: String)
c.select()
.columns('first_name')
.from('hobbits')
.where('last_name', '=', 'baggins')
.orderBy('first_name', 'desc')
.limit(2)
.filter()
.then(function (results) {
// ...
})
The select builder provides the components listed above:
- table as
.into
- options (
.ttl
and.timestamp
)
Additionally, the following methods:
.data(map: Object)
Inserts a key: value set of data..columns
When inserting columns/values independently (see: columns component)..values(elements....)
When inserting columns/values independently.
c.insert()
.into('hobbits')
.data({ first_name: 'Frodo', last_name: 'Baggins' })
.ttl(60)
.then(function (results) {
// ...
})
The select builder provides the components listed above:
- table as
.table
- where (
.where
,.andWhere
,.orWhere
) - conditionals (
.when
) - options (
.ttl
and.timestamp
)
Additionally, the following methods:
add(column: String|Column, value)
Appends item(s) to a set, list, or map, or adds to a counter column.subtract(column: String|Column, value)
Removes item(s) from a set, list, or map, or subtracts to a counter column.set
can be used in multiple ways:set(str: String)
Adds a "raw" update. No parameterization or anything. Alias:setRaw
.set(column: String|Column, value)
Updates a column to equal a value,column = value
. Alias:setSimple
.set(column: String|Column, index, value)
Updates an index in a set,column[index] = value
. Alias:setIndex
.
c.update()
.table('hobbits')
.when('ring_of_power')
.where('location', '=', 'erebor')
.add('victims', 'Smaug')
.set('location', 'Shire')
.ttl(60)
.then(function (results) {
// ...
})
The delete builder provides the components listed above:
- table as
.from
- where (
.where
,.andWhere
,.orWhere
) - columns (
.columns
) - conditionals (
.when
) - options (
.timestamp
)
c.delete()
.table('dwarves')
.where('name', '=', 'Thorin Oakenshield')
.then(function (results) {
// ...
})
Collections are created by calling .model(name: String)
on the connection object.
/**
* Create a new model. Its name will be converted to
* snake_case for the table name (it will be `users_info`)
*/
var User = c.model('UserInfo');
// Or we can explicitly set the table name:
User.table.setName('user_info');
// To pluralize we normally just add an "s" on the
// end, but you explicitly set its plural form like:
User.plural('UserInfoz');
The connection also provides all built-in Cassandra types for you: ASCII, BigInt, BLOB, Boolean, Counter, Decimal, Double, Float, IP, Int, Text, Timestamp, TimeUUID, UUID, VarChar, VarIn, Tuple, List, Map, Set.
You can create columns with these like so:
/**
* Add columns to the user. You can, of course, have
* many partition keys and secondary keys. They'll
* be added in the order that the columns are defined
* in the table.
*/
User.columns([
c.Column.Text('userid').partitionKey(),
t.Set('emails', [t.Text()]),
t.Text('last_name').compoundKey()
]);
/**
* You may also add table properties.
*/
User.table.addProperty('COMPACT STORAGE');
User.table.addProperty('compression', { sstable_compression: 'LZ4Compressor' });
Table schema output:
CREATE TABLE users_info (
userid text,
emails set<text>,
last_name text,
PRIMARY KEY (emails, last_name)
) WITH COMPACT STORAGE AND
compression={ 'sstable_compression': 'LZ4Compressor' }
Columns are then converted to StudlyCase and published on the collection, for use in querying later. In the above example, the following columns would be made available:
User.Userid
User.Emails
User.LastName
TBD
Like with connections, you can start a query relative to the model by calling select/update/insert/delete.
User.select();
User.update();
User.insert();
User.delete();
Relations can be defined very elegantly on any collection. Note: these work, but keep in mind that in many cases it may be better to denormalize your data rather than using relations.
Dragon.hasMany(GoldCoin).from(Dragon.Uuid).to(GoldCoins.Owner);
// Says that a Dragon has many GoldCoins, via dragon.uuid <--> goldcoins.owner
GoldCoins.belongsTo(Dragon).from(GoldCoins.Owner).to(Dragon.Uuid);
// Says the same thing! Note: you only need to do one of these,
// don't define both. We do that automatically for you.
Dragon.has(Weakness).from(Dragon.Uuid).to(Weakness.Dragon)
// One-to-one relationship
After finding a model, you can look up related models.
// You can load a model "with" owned models.
Dragon.with(GoldCoins).select().then( ... );
// You can then access that property
dragon.goldCoins; // => array of GoldCoins models
// Likewise, you can go backwards.
GoldCoins.with(Dragon).select().then( ... );
coin.dragon; // => a Dragon instance
// You can attach or detach models from each other.
// In "to-many" relations, detach will remove the
// model from the relation list and attach will add.
dragon.goldCoins.detach(coin);
dragon.goldCoins.attach(coin);
// In "to-one", detach will delete the relation and
// attach will set and overwrite an existing relation.
coin.dragon.detach(dragon); // coin.dragon will be undefined.
coin.dragon.attach(dragon1); // Now coin.dragon is dragon1
coin.dragon.attach(dragon2); // We overwrite, so coin.dragon is now dragon2
Like Express, lifecycle callbacks are done in the form of middleware. The following callbacks are available:
- beforeCreate
- afterCreate
- beforeDelete
- afterDelete
- beforeUpdate
- afterUpdate
Note: these are only called when working with models, not when querying directly on the collection (e.g., it won't run on User.delete().where('a', '=' ,'b'))
The context, this
for callbacks will be set to the model object. Methods and attributes on the model (see below) will be available. Example:
User.use('beforeCreate', function (next) {
var err = validator.try(this.attributes, rules);
if (err) {
next(err);
// or throw err;
} else {
next(); // We're all good
}
});
// You can pass multiple events in as an array.
User.use(['beforeCreate', 'beforeUpdate'], function (next) {
var self = this;
if (this.isDirty('password')) {
bcrypt.hash(this.password, 8, function (err, hashed) {
if (err) {
throw err;
} else {
self.password = hashed;
next();
}
});
} else {
next();
}
});
Models can either be created or looked up. Creating models can be done via .new()
:
// Returns a fresh new model!
var user = User.new();
// Create a new model already populated with some data.
var user = User.new({ name: 'Thorin Oakenshield' });
To look up models, simply start a "select" query on the collection. It will be resolved to an array of models.
Neither method takes arguments directly. Rather, they return a select query builder. So, for example:
User.select()
.where(User.Profession, 'CONTAINS', 'wizard')
.limit(1)
.then(function (wizards, results) {
// wizards is an array of user models.
// results is the raw output from the datastax driver
});
Static methods can be attached to the collection directly, of course:
User.sayHi = function () {
console.log('Hi');
};
You can also define methods or properties that are present on model instances.
User.define('whoAmI', function () {
return this.name;
});
User.findOne()
.where(User.Name, '=', 'Frodo Baggins')
.then(function (frodo) {
console.log(frodo.whoAmI());
// => Frodo Baggins
});
You can defined getters and setters like so:
// Say we're storing user points in the database. When we "get" the
// points we'd rather return a string like "<x> Points"
User.getter('points', function (points) {
return points + ' Points';
});
// We'd also need a setter to trim off the " Points" before we
// set the value!
User.setter('points', function (points) {
return parseInt(points.replace(' Points', ''), 10);
});
// Note that, when calling toObject, you'll get the data passed
// through the getters
User.toObject(); // => { points: '42 Points' }
// But if you don't want this to happen, pass "false" as its
// first argument.
User.toObject(false); // => { points: 42 }
Models provide several useful methods and attributes you may use.
The only enumerable properties on a model are its attributes. This means you can very easily loop through them, serialize the model to JSON, or what have you. You can likewise set to update:
var user = User.new();
user.name = 'Smaug';
user.emails = ['[email protected]'];
Models can be updated with a simple "save" call. We will create the model in the database if it does not exist, or update an existing model.
Unless "true" is passed as the first parameter, we won't update the model if no properties have been changed.
user.save().then(function (user) {
// the user model has now been updated!
});
.isDirty(column: String|Column) -> Boolean
Returns whether the column has changed since the model was created or synced with the database.isSynced() -> Boolean
Returns whether any attributes have changed since the object was last updated from the database..toObject() -> Object
Returns the current model properties as a plain object..toJson() -> String
Converts the current model properties to a string..old
is an object which contains the attributes as they exist in the database..collection
is a reference to the object's parent collection.