diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..096746c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/node_modules/ \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ece56ac --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +doc: + makedoc lib/abstract-class.js lib/schema.js lib/validatable.js -t "JugglingDB API docs" + +test: + @ONLY=memory ./support/nodeunit/bin/nodeunit test/*_test.* + +.PHONY: test +.PHONY: doc diff --git a/index.js b/index.js new file mode 100644 index 0000000..37e7b46 --- /dev/null +++ b/index.js @@ -0,0 +1,24 @@ +var fs = require('fs'); +var path = require('path'); + +exports.Schema = require('./lib/schema').Schema; +exports.AbstractClass = require('./lib/abstract-class').AbstractClass; +exports.Validatable = require('./lib/validatable').Validatable; + +exports.init = function (rw) { + if (typeof rw === 'string') { + railway.orm = exports; + } else { + rw.orm = {Schema: exports.Schema, AbstractClass: exports.AbstractClass}; + } + require('./lib/railway')(rw); +}; + +try { + if (process.versions.node < '0.6') { + exports.version = JSON.parse(fs.readFileSync(__dirname + '/package.json')).version; + } else { + exports.version = require('../package').version; + } +} catch (e) {} + diff --git a/lib/abstract-class.js b/lib/abstract-class.js new file mode 100644 index 0000000..12e6bf4 --- /dev/null +++ b/lib/abstract-class.js @@ -0,0 +1,969 @@ +/** + * Module dependencies + */ +var util = require('util'); +var jutil = require('./jutil'); +var Validatable = require('./validatable').Validatable; +var List = require('./list'); +var Hookable = require('./hookable').Hookable; +var DEFAULT_CACHE_LIMIT = 1000; +var BASE_TYPES = ['String', 'Boolean', 'Number', 'Date', 'Text']; + +exports.AbstractClass = AbstractClass; + +AbstractClass.__proto__ = Validatable; +AbstractClass.prototype.__proto__ = Validatable.prototype; +jutil.inherits(AbstractClass, Hookable); + +/** + * Abstract class - base class for all persist objects + * provides **common API** to access any database adapter. + * This class describes only abstract behavior layer, refer to `lib/adapters/*.js` + * to learn more about specific adapter implementations + * + * `AbstractClass` mixes `Validatable` and `Hookable` classes methods + * + * @constructor + * @param {Object} data - initial object data + */ +function AbstractClass(data) { + this._initProperties(data, true); +} + +AbstractClass.prototype._initProperties = function (data, applySetters) { + var self = this; + var ctor = this.constructor; + var ds = ctor.schema.definitions[ctor.modelName]; + var properties = ds.properties; + data = data || {}; + + Object.defineProperty(this, '__cachedRelations', { + writable: true, + enumerable: false, + configurable: true, + value: {} + }); + + Object.defineProperty(this, '__data', { + writable: true, + enumerable: false, + configurable: true, + value: {} + }); + + Object.defineProperty(this, '__dataWas', { + writable: true, + enumerable: false, + configurable: true, + value: {} + }); + + for (var i in data) this.__data[i] = this.__dataWas[i] = data[i]; + + if (applySetters && ctor.setter) { + Object.keys(ctor.setter).forEach(function (attr) { + if (self.__data.hasOwnProperty(attr)) { + ctor.setter[attr].call(self, self.__data[attr]); + } + }); + } + + ctor.forEachProperty(function (attr) { + + if (!self.__data.hasOwnProperty(attr)) { + self.__data[attr] = self.__dataWas[attr] = getDefault(attr); + } else { + self.__dataWas[attr] = self.__data[attr]; + } + + }); + + ctor.forEachProperty(function (attr) { + + var type = properties[attr].type; + + if (BASE_TYPES.indexOf(type.name) === -1) { + if (typeof self.__data[attr] !== 'object' && self.__data[attr]) { + try { + self.__data[attr] = JSON.parse(self.__data[attr] + ''); + } catch (e) { + console.log(e.stack); + } + } + if (type.name === 'Array' || typeof type === 'object' && type.constructor.name === 'Array') { + self.__data[attr] = new List(self.__data[attr], type, self); + } + } + + }); + + function getDefault(attr) { + var def = properties[attr]['default']; + if (isdef(def)) { + if (typeof def === 'function') { + return def(); + } else { + return def; + } + } else { + return null; + } + } + + this.trigger("initialize"); +} + +/** + * @param {String} prop - property name + * @param {Object} params - various property configuration + */ +AbstractClass.defineProperty = function (prop, params) { + this.schema.defineProperty(this.modelName, prop, params); +}; + +AbstractClass.whatTypeName = function (propName) { + var ds = this.schema.definitions[this.modelName]; + return ds.properties[propName] && ds.properties[propName].type.name; +}; + +AbstractClass._forDB = function (data) { + var res = {}; + Object.keys(data).forEach(function (propName) { + if (this.whatTypeName(propName) === 'JSON' || data[propName] instanceof Array) { + res[propName] = JSON.stringify(data[propName]); + } else { + res[propName] = data[propName]; + } + }.bind(this)); + return res; +}; + +AbstractClass.prototype.whatTypeName = function (propName) { + return this.constructor.whatTypeName(propName); +}; + +/** + * Create new instance of Model class, saved in database + * + * @param data [optional] + * @param callback(err, obj) + * callback called with arguments: + * + * - err (null or Error) + * - instance (null or Model) + */ +AbstractClass.create = function (data, callback) { + if (stillConnecting(this.schema, this, arguments)) return; + + var modelName = this.modelName; + + if (typeof data === 'function') { + callback = data; + data = {}; + } + + if (typeof callback !== 'function') { + callback = function () {}; + } + + var obj = null; + // if we come from save + if (data instanceof this && !data.id) { + obj = data; + data = obj.toObject(true); + obj._initProperties(data, false); + create(); + } else { + obj = new this(data); + data = obj.toObject(true); + + // validation required + obj.isValid(function (valid) { + if (!valid) { + callback(new Error('Validation error'), obj); + } else { + create(); + } + }); + } + + function create() { + obj.trigger('create', function (done) { + + var data = this.toObject(true); // Added this to fix the beforeCreate trigger not fire. + // The fix is per issue #72 and the fix was found by by5739. + + this._adapter().create(modelName, this.constructor._forDB(data), function (err, id) { + if (id) { + obj.__data.id = id; + obj.__dataWas.id = id; + defineReadonlyProp(obj, 'id', id); + } + done.call(this, function () { + if (callback) { + callback(err, obj); + } + }); + }.bind(this)); + }); + } +}; + +function stillConnecting(schema, obj, args) { + if (schema.connected) return false; + var method = args.callee; + schema.on('connected', function () { + method.apply(obj, [].slice.call(args)); + }); + return true; +}; + +/** + * Update or insert + */ +AbstractClass.upsert = AbstractClass.updateOrCreate = function upsert(data, callback) { + if (stillConnecting(this.schema, this, arguments)) return; + + var Model = this; + if (!data.id) return this.create(data, callback); + if (this.schema.adapter.updateOrCreate) { + var inst = new Model(data); + this.schema.adapter.updateOrCreate(Model.modelName, inst.toObject(), function (err, data) { + var obj; + if (data) { + inst._initProperties(data); + obj = inst; + } else { + obj = null; + } + callback(err, obj); + }); + } else { + this.find(data.id, function (err, inst) { + if (err) return callback(err); + if (inst) { + inst.updateAttributes(data, callback); + } else { + var obj = new Model(data); + obj.save(data, callback); + } + }); + } +}; + +/** + * Check whether object exitst in database + * + * @param {id} id - identifier of object (primary key value) + * @param {Function} cb - callbacl called with (err, exists: Bool) + */ +AbstractClass.exists = function exists(id, cb) { + if (stillConnecting(this.schema, this, arguments)) return; + + if (id) { + this.schema.adapter.exists(this.modelName, id, cb); + } else { + cb(new Error('Model::exists requires positive id argument')); + } +}; + +/** + * Find object by id + * + * @param {id} id - primary key value + * @param {Function} cb - callback called with (err, instance) + */ +AbstractClass.find = function find(id, cb) { + if (stillConnecting(this.schema, this, arguments)) return; + + this.schema.adapter.find(this.modelName, id, function (err, data) { + var obj = null; + if (data) { + data.id = id; + obj = new this(); + obj._initProperties(data, false); + } + cb(err, obj); + }.bind(this)); +}; + +/** + * Find all instances of Model, matched by query + * make sure you have marked as `index: true` fields for filter or sort + * + * @param {Object} params (optional) + * + * - where: Object `{ key: val, key2: {gt: 'val2'}}` + * - order: String + * - limit: Number + * - skip: Number + * + * @param {Function} callback (required) called with arguments: + * + * - err (null or Error) + * - Array of instances + */ +AbstractClass.all = function all(params, cb) { + if (stillConnecting(this.schema, this, arguments)) return; + + if (arguments.length === 1) { + cb = params; + params = null; + } + var constr = this; + this.schema.adapter.all(this.modelName, params, function (err, data) { + if (data && data.map) { + data.forEach(function (d, i) { + var obj = new constr; + obj._initProperties(d, false); + data[i] = obj; + }); + if (data && data.countBeforeLimit) { + data.countBeforeLimit = data.countBeforeLimit; + } + cb(err, data); + } + else + cb(err, []); + }); +}; + +/** + * Find one record, same as `all`, limited by 1 and return object, not collection + * + * @param {Object} params - search conditions + * @param {Function} cb - callback called with (err, instance) + */ +AbstractClass.findOne = function findOne(params, cb) { + if (stillConnecting(this.schema, this, arguments)) return; + + if (typeof params === 'function') { + cb = params; + params = {}; + } + params.limit = 1; + this.all(params, function (err, collection) { + if (err || !collection || !collection.length > 0) return cb(err); + cb(err, collection[0]); + }); +}; + +function substractDirtyAttributes(object, data) { + Object.keys(object.toObject()).forEach(function (attr) { + if (data.hasOwnProperty(attr) && object.propertyChanged(attr)) { + delete data[attr]; + } + }); +} + +/** + * Destroy all records + * @param {Function} cb - callback called with (err) + */ +AbstractClass.destroyAll = function destroyAll(cb) { + if (stillConnecting(this.schema, this, arguments)) return; + + this.schema.adapter.destroyAll(this.modelName, function (err) { + cb(err); + }.bind(this)); +}; + +/** + * Return count of matched records + * + * @param {Object} where - search conditions (optional) + * @param {Function} cb - callback, called with (err, count) + */ +AbstractClass.count = function (where, cb) { + if (stillConnecting(this.schema, this, arguments)) return; + + if (typeof where === 'function') { + cb = where; + where = null; + } + this.schema.adapter.count(this.modelName, cb, where); +}; + +/** + * Return string representation of class + * + * @override default toString method + */ +AbstractClass.toString = function () { + return '[Model ' + this.modelName + ']'; +}; + +/** + * Save instance. When instance haven't id, create method called instead. + * Triggers: validate, save, update | create + * @param options {validate: true, throws: false} [optional] + * @param callback(err, obj) + */ +AbstractClass.prototype.save = function (options, callback) { + if (stillConnecting(this.constructor.schema, this, arguments)) return; + + if (typeof options == 'function') { + callback = options; + options = {}; + } + + callback = callback || function () {}; + options = options || {}; + + if (!('validate' in options)) { + options.validate = true; + } + if (!('throws' in options)) { + options.throws = false; + } + + if (options.validate) { + this.isValid(function (valid) { + if (valid) { + save.call(this); + } else { + var err = new Error('Validation error'); + // throws option is dangerous for async usage + if (options.throws) { + throw err; + } + callback(err, this); + } + }.bind(this)); + } else { + save.call(this); + } + + function save() { + this.trigger('save', function (saveDone) { + var modelName = this.constructor.modelName; + var data = this.toObject(true); + var inst = this; + if (inst.id) { + inst.trigger('update', function (updateDone) { + inst._adapter().save(modelName, inst.constructor._forDB(data), function (err) { + if (err) { + console.log(err); + } else { + inst._initProperties(data, false); + } + updateDone.call(inst, function () { + saveDone.call(inst, function () { + callback(err, inst); + }); + }); + }); + }, data); + } else { + inst.constructor.create(inst, function (err) { + saveDone.call(inst, function () { + callback(err, inst); + }); + }); + } + + }); + } +}; + +AbstractClass.prototype.isNewRecord = function () { + return !this.id; +}; + +/** + * Return adapter of current record + * @private + */ +AbstractClass.prototype._adapter = function () { + return this.constructor.schema.adapter; +}; + +/** + * Convert instance to Object + * + * @param {Boolean} onlySchema - restrict properties to schema only, default false + * when onlySchema == true, only properties defined in schema returned, + * otherwise all enumerable properties returned + * @returns {Object} - canonical object representation (no getters and setters) + */ +AbstractClass.prototype.toObject = function (onlySchema) { + var data = {}; + var ds = this.constructor.schema.definitions[this.constructor.modelName]; + var properties = ds.properties; + var self = this; + + this.constructor.forEachProperty(function (attr) { + if (self[attr] instanceof List) { + data[attr] = self[attr].toObject(); + } else if (self.__data.hasOwnProperty(attr)) { + data[attr] = self[attr]; + } else { + data[attr] = null; + } + }); + + if (!onlySchema) { + Object.keys(self).forEach(function (attr) { + if (!data.hasOwnProperty(attr)) { + data[attr] = this[attr]; + } + }); + } + + return data; +}; + +// AbstractClass.prototype.hasOwnProperty = function (prop) { +// return this.__data && this.__data.hasOwnProperty(prop) || +// Object.getOwnPropertyNames(this).indexOf(prop) !== -1; +// }; + +AbstractClass.prototype.toJSON = function () { + return this.toObject(); +}; + +/** + * Delete object from persistence + * + * @triggers `destroy` hook (async) before and after destroying object + */ +AbstractClass.prototype.destroy = function (cb) { + if (stillConnecting(this.constructor.schema, this, arguments)) return; + + this.trigger('destroy', function (destroyed) { + this._adapter().destroy(this.constructor.modelName, this.id, function (err) { + destroyed(function () { + if(cb) cb(err); + }); + }.bind(this)); + }); +}; + +/** + * Update single attribute + * + * equals to `updateAttributes({name: value}, cb) + * + * @param {String} name - name of property + * @param {Mixed} value - value of property + * @param {Function} callback - callback called with (err, instance) + */ +AbstractClass.prototype.updateAttribute = function updateAttribute(name, value, callback) { + var data = {}; + data[name] = value; + this.updateAttributes(data, callback); +}; + +/** + * Update set of attributes + * + * this method performs validation before updating + * + * @trigger `validation`, `save` and `update` hooks + * @param {Object} data - data to update + * @param {Function} callback - callback called with (err, instance) + */ +AbstractClass.prototype.updateAttributes = function updateAttributes(data, cb) { + if (stillConnecting(this.constructor.schema, this, arguments)) return; + + var inst = this; + var model = this.constructor.modelName; + + // update instance's properties + Object.keys(data).forEach(function (key) { + inst[key] = data[key]; + }); + + inst.isValid(function (valid) { + if (!valid) { + if (cb) { + cb(new Error('Validation error'), inst); + } + } else { + update(); + } + }); + + function update() { + inst.trigger('save', function (saveDone) { + inst.trigger('update', function (done) { + + Object.keys(data).forEach(function (key) { + data[key] = inst[key]; + }); + + inst._adapter().updateAttributes(model, inst.id, inst.constructor._forDB(data), function (err) { + if (!err) { + // update _was attrs + Object.keys(data).forEach(function (key) { + inst.__dataWas[key] = inst.__data[key]; + }); + } + done.call(inst, function () { + saveDone.call(inst, function () { + cb(err, inst); + }); + }); + }); + }, data); + }); + } +}; + +AbstractClass.prototype.fromObject = function (obj) { + Object.keys(obj).forEach(function (key) { + this[key] = obj[key]; + }.bind(this)); +}; + +/** + * Checks is property changed based on current property and initial value + * + * @param {String} attr - property name + * @return Boolean + */ +AbstractClass.prototype.propertyChanged = function propertyChanged(attr) { + return this.__data[attr] !== this.__dataWas[attr]; +}; + +/** + * Reload object from persistence + * + * @requires `id` member of `object` to be able to call `find` + * @param {Function} callback - called with (err, instance) arguments + */ +AbstractClass.prototype.reload = function reload(callback) { + if (stillConnecting(this.constructor.schema, this, arguments)) return; + + this.constructor.find(this.id, callback); +}; + +/** + * Reset dirty attributes + * + * this method does not perform any database operation it just reset object to it's + * initial state + */ +AbstractClass.prototype.reset = function () { + var obj = this; + Object.keys(obj).forEach(function (k) { + if (k !== 'id' && !obj.constructor.schema.definitions[obj.constructor.modelName].properties[k]) { + delete obj[k]; + } + if (obj.propertyChanged(k)) { + obj[k] = obj[k + '_was']; + } + }); +}; + +/** + * Declare hasMany relation + * + * @param {Class} anotherClass - class to has many + * @param {Object} params - configuration {as:, foreignKey:} + * @example `User.hasMany(Post, {as: 'posts', foreignKey: 'authorId'});` + */ +AbstractClass.hasMany = function hasMany(anotherClass, params) { + var methodName = params.as; // or pluralize(anotherClass.modelName) + var fk = params.foreignKey; + // each instance of this class should have method named + // pluralize(anotherClass.modelName) + // which is actually just anotherClass.all({where: {thisModelNameId: this.id}}, cb); + defineScope(this.prototype, anotherClass, methodName, function () { + var x = {}; + x[fk] = this.id; + return {where: x}; + }, { + find: find, + destroy: destroy + }); + + // obviously, anotherClass should have attribute called `fk` + anotherClass.schema.defineForeignKey(anotherClass.modelName, fk); + + function find(id, cb) { + anotherClass.find(id, function (err, inst) { + if (err) return cb(err); + if (!inst) return cb(new Error('Not found')); + if (inst[fk] == this.id) { + cb(null, inst); + } else { + cb(new Error('Permission denied')); + } + }.bind(this)); + } + + function destroy(id, cb) { + this.find(id, function (err, inst) { + if (err) return cb(err); + if (inst) { + inst.destroy(cb); + } else { + cb(new Error('Not found')); + } + }); + } + +}; + +/** + * Declare belongsTo relation + * + * @param {Class} anotherClass - class to belong + * @param {Object} params - configuration {as: 'propertyName', foreignKey: 'keyName'} + * + * **Usage examples** + * Suppose model Post have a *belongsTo* relationship with User (the author of the post). You could declare it this way: + * Post.belongsTo(User, {as: 'author', foreignKey: 'userId'}); + * + * When a post is loaded, you can load the related author with: + * post.author(function(err, user) { + * // the user variable is your user object + * }); + * + * The related object is cached, so if later you try to get again the author, no additional request will be made. + * But there is an optional boolean parameter in first position that set whether or not you want to reload the cache: + * post.author(true, function(err, user) { + * // The user is reloaded, even if it was already cached. + * }); + * + * This optional parameter default value is false, so the related object will be loaded from cache if available. + */ +AbstractClass.belongsTo = function (anotherClass, params) { + var methodName = params.as; + var fk = params.foreignKey; + + this.schema.defineForeignKey(this.modelName, fk); + this.prototype['__finders__'] = this.prototype['__finders__'] || {}; + + this.prototype['__finders__'][methodName] = function (id, cb) { + anotherClass.find(id, function (err,inst) { + if (err) return cb(err); + if (!inst) return cb(null, null); + if (inst[fk] === this.id) { + cb(null, inst); + } else { + cb(new Error('Permission denied')); + } + }.bind(this)); + }; + + this.prototype[methodName] = function (refresh, p) { + if (arguments.length === 1) { + p = refresh; + refresh = false; + } else if (arguments.length > 2) { + throw new Error('Method can\'t be called with more than two arguments'); + } + var self = this; + var cachedValue; + if (!refresh && this.__cachedRelations && (typeof this.__cachedRelations[methodName] !== 'undefined')) { + cachedValue = this.__cachedRelations[methodName]; + } + if (p instanceof AbstractClass) { // acts as setter + this[fk] = p.id; + this.__cachedRelations[methodName] = p; + } else if (typeof p === 'function') { // acts as async getter + if (typeof cachedValue === 'undefined') { + this.__finders__[methodName](this[fk], function(err, inst) { + if (!err) { + self.__cachedRelations[methodName] = inst; + } + p(err, inst); + }); + return this[fk]; + } else { + p(null, cachedValue); + return cachedValue; + } + } else if (typeof p === 'undefined') { // acts as sync getter + return this[fk]; + } else { // setter + this[fk] = p; + delete this.__cachedRelations[methodName]; + } + }; + +}; + +/** + * Define scope + * TODO: describe behavior and usage examples + */ +AbstractClass.scope = function (name, params) { + defineScope(this, this, name, params); +}; + +function defineScope(cls, targetClass, name, params, methods) { + + // collect meta info about scope + if (!cls._scopeMeta) { + cls._scopeMeta = {}; + } + + // only makes sence to add scope in meta if base and target classes + // are same + if (cls === targetClass) { + cls._scopeMeta[name] = params; + } else { + if (!targetClass._scopeMeta) { + targetClass._scopeMeta = {}; + } + } + + Object.defineProperty(cls, name, { + enumerable: false, + configurable: true, + get: function () { + var f = function caller(condOrRefresh, cb) { + var actualCond = {}; + var actualRefresh = false; + var saveOnCache = true; + if (arguments.length === 1) { + cb = condOrRefresh; + } else if (arguments.length === 2) { + if (typeof condOrRefresh === 'boolean') { + actualRefresh = condOrRefresh; + } else { + actualCond = condOrRefresh; + actualRefresh = true; + saveOnCache = false; + } + } else { + throw new Error('Method can be only called with one or two arguments'); + } + + if (!this.__cachedRelations || (typeof this.__cachedRelations[name] == 'undefined') || actualRefresh) { + var self = this; + return targetClass.all(mergeParams(actualCond, caller._scope), function(err, data) { + if (!err && saveOnCache) { + self.__cachedRelations[name] = data; + } + cb(err, data); + }); + } else { + cb(null, this.__cachedRelations[name]); + } + }; + f._scope = typeof params === 'function' ? params.call(this) : params; + f.build = build; + f.create = create; + f.destroyAll = destroyAll; + for (var i in methods) { + f[i] = methods[i].bind(this); + } + + // define sub-scopes + Object.keys(targetClass._scopeMeta).forEach(function (name) { + Object.defineProperty(f, name, { + enumerable: false, + get: function () { + mergeParams(f._scope, targetClass._scopeMeta[name]); + return f; + } + }); + }.bind(this)); + return f; + } + }); + + // and it should have create/build methods with binded thisModelNameId param + function build(data) { + return new targetClass(mergeParams(this._scope, {where:data || {}}).where); + } + + function create(data, cb) { + if (typeof data === 'function') { + cb = data; + data = {}; + } + this.build(data).save(cb); + } + + /* + Callback + - The callback will be called after all elements are destroyed + - For every destroy call which results in an error + - If fetching the Elements on which destroyAll is called results in an error + */ + function destroyAll(cb) { + targetClass.all(this._scope, function (err, data) { + if (err) { + cb(err); + } else { + (function loopOfDestruction (data) { + if(data.length > 0) { + data.shift().destroy(function(err) { + if(err && cb) cb(err); + loopOfDestruction(data); + }); + } else { + if(cb) cb(); + } + }(data)); + } + }); + } + + function mergeParams(base, update) { + if (update.where) { + base.where = merge(base.where, update.where); + } + + // overwrite order + if (update.order) { + base.order = update.order; + } + + return base; + + } +} + +AbstractClass.prototype.inspect = function () { + return util.inspect(this.__data, false, 4, true); +}; + + +/** + * Check whether `s` is not undefined + * @param {Mixed} s + * @return {Boolean} s is undefined + */ +function isdef(s) { + var undef; + return s !== undef; +} + +/** + * Merge `base` and `update` params + * @param {Object} base - base object (updating this object) + * @param {Object} update - object with new data to update base + * @returns {Object} `base` + */ +function merge(base, update) { + base = base || {}; + if (update) { + Object.keys(update).forEach(function (key) { + base[key] = update[key]; + }); + } + return base; +} + +/** + * Define readonly property on object + * + * @param {Object} obj + * @param {String} key + * @param {Mixed} value + */ +function defineReadonlyProp(obj, key, value) { + Object.defineProperty(obj, key, { + writable: false, + enumerable: true, + configurable: true, + value: value + }); +} + diff --git a/lib/adapters/cradle.js b/lib/adapters/cradle.js new file mode 100644 index 0000000..cd1b74a --- /dev/null +++ b/lib/adapters/cradle.js @@ -0,0 +1,334 @@ +var safeRequire = require('../utils').safeRequire; + +/** + * Module dependencies + */ +var cradle = safeRequire('cradle'); + +/** + * Private functions for internal use + */ +function CradleAdapter(client) { + this._models = {}; + this.client = client; +} + +function createdbif(client, callback) { + client.exists(function (err, exists) { + if(err) callback(err); + if (!exists) { client.create(function() { callback(); }); } + else { callback(); } + }); +} + +function naturalize(data, model) { + data.nature = model; + //TODO: maybe this is not a really good idea + if(data.date) data.date = data.date.toString(); + return data; +} +function idealize(data) { + data.id = data._id; + return data; +} +function stringify(data) { + return data ? data.toString() : data +} + +function errorHandler(callback, func) { + return function(err, res) { + if (err) { + console.log('cradle', err); + callback(err); + } else { + if(func) { + func(res, function(res) { + callback(null, res); + }); + } else { + callback(null, res); + } + } + } +}; + +function synchronize(functions, args, callback) { + if(functions.length === 0) callback(); + if(functions.length > 0 && args.length === functions.length) { + functions[0](args[0][0], args[0][1], function(err, res) { + if(err) callback(err); + functions.splice(0, 1); + args.splice(0, 1); + synchronize(functions, args, callback); + }); + } +}; + +function applyFilter(filter) { + if (typeof filter.where === 'function') { + return filter.where; + } + var keys = Object.keys(filter.where); + return function (obj) { + var pass = true; + keys.forEach(function (key) { + if (!test(filter.where[key], obj[key])) { + pass = false; + } + }); + return pass; + } + + function test(example, value) { + if (typeof value === 'string' && example && example.constructor.name === 'RegExp') { + return value.match(example); + } + // not strict equality + return example == value; + } +} + +function numerically(a, b) { + return a[this[0]] - b[this[0]]; +} + +function literally(a, b) { + return a[this[0]] > b[this[0]]; +} + +function filtering(res, model, filter, instance) { + + if(model) { + if(filter == null) filter = {}; + if(filter.where == null) filter.where = {}; + filter.where.nature = model; + } + // do we need some filtration? + if (filter.where) { + res = res ? res.filter(applyFilter(filter)) : res; + } + + // do we need some sorting? + if (filter.order) { + var props = instance[model].properties; + var allNumeric = true; + var orders = filter.order; + var reverse = false; + if (typeof filter.order === "string") { + orders = [filter.order]; + } + + orders.forEach(function (key, i) { + var m = key.match(/\s+(A|DE)SC$/i); + if (m) { + key = key.replace(/\s+(A|DE)SC/i, ''); + if (m[1] === 'DE') reverse = true; + } + orders[i] = key; + if (props[key].type.name !== 'Number') { + allNumeric = false; + } + }); + if (allNumeric) { + res = res.sort(numerically.bind(orders)); + } else { + res = res.sort(literally.bind(orders)); + } + if (reverse) res = res.reverse(); + } + return res; +} + +/** + * Connection/Disconnection + */ +exports.initialize = function(schema, callback) { + if (!cradle) return; + + if (!schema.settings.url) { + var host = schema.settings.host || 'localhost'; + var port = schema.settings.port || '5984'; + var options = schema.settings.options || { + cache: true, + raw: false + }; + if (schema.settings.username) { + options.auth = {}; + options.auth.username = schema.settings.username; + if (schema.settings.password) { + options.auth.password = schema.settings.password; + } + } + var database = schema.settings.database || 'jugglingdb'; + + schema.settings.host = host; + schema.settings.port = port; + schema.settings.database = database; + schema.settings.options = options; + } + schema.client = new(cradle.Connection)(schema.settings.host, schema.settings.port,schema.settings.options).database(schema.settings.database); + + createdbif( + schema.client, + errorHandler(callback, function() { + schema.adapter = new CradleAdapter(schema.client); + process.nextTick(callback); + })); +}; + +CradleAdapter.prototype.disconnect = function() { +}; + +/** + * Write methods + */ +CradleAdapter.prototype.define = function(descr) { + this._models[descr.model.modelName] = descr; +}; + +CradleAdapter.prototype.create = function(model, data, callback) { + this.client.save( + stringify(data.id), + naturalize(data, model), + errorHandler(callback, function(res, cb) { + cb(res.id); + }) + ); +}; + +CradleAdapter.prototype.save = function(model, data, callback) { + this.client.save( + stringify(data.id), + naturalize(data, model), + errorHandler(callback) + ) +}; + +CradleAdapter.prototype.updateAttributes = function(model, id, data, callback) { + this.client.merge( + stringify(id), + data, + errorHandler(callback, function(doc, cb) { + cb(idealize(doc)); + }) + ); +}; + +CradleAdapter.prototype.updateOrCreate = function(model, data, callback) { + this.client.get( + stringify(data.id), + function (err, doc) { + if(err) { + this.create(model, data, callback); + } else { + this.updateAttributes(model, data.id, data, callback); + } + }.bind(this) + ) +}; + +/** + * Read methods + */ +CradleAdapter.prototype.exists = function(model, id, callback) { + this.client.get( + stringify(id), + errorHandler(callback, function(doc, cb) { + cb(!!doc); + }) + ); +}; + +CradleAdapter.prototype.find = function(model, id, callback) { + this.client.get( + stringify(id), + errorHandler(callback, function(doc, cb) { + cb(idealize(doc)); + }) + ); +}; + +CradleAdapter.prototype.count = function(model, callback, where) { + this.models( + model, + {where: where}, + callback, + function(docs, cb) { + cb(docs.length); + } + ); +}; + +CradleAdapter.prototype.models = function(model, filter, callback, func) { + var limit = 200; + var skip = 0; + if (filter != null) { + limit = filter.limit || limit; + skip = filter.skip ||skip; + } + + var self = this; + + self.client.save('_design/'+model, { + views : { + all : { + map : 'function(doc) { if (doc.nature == "'+model+'") { emit(doc._id, doc); } }' + } + } + }, function() { + self.client.view(model+'/all', {include_docs:true, limit:limit, skip:skip}, errorHandler(callback, function(res, cb) { + var docs = res.map(function(doc) { + return idealize(doc); + }); + var filtered = filtering(docs, model, filter, this._models) + + func ? func(filtered, cb) : cb(filtered); + }.bind(self))); + }); +}; + +CradleAdapter.prototype.all = function(model, filter, callback) { + this.models( + model, + filter, + callback + ); +}; + +/** + * Detroy methods + */ +CradleAdapter.prototype.destroy = function(model, id, callback) { + this.client.remove( + stringify(id), + function (err, doc) { + callback(err); + } + ); +}; + +CradleAdapter.prototype.destroyAll = function(model, callback) { + this.models( + model, + null, + callback, + function(docs, cb) { + var docIds = docs.map(function(doc) { + return doc.id; + }); + this.client.get(docIds, function(err, res) { + if(err) cb(err); + + var funcs = res.map(function(doc) { + return this.client.remove.bind(this.client); + }.bind(this)); + + var args = res.map(function(doc) { + return [doc._id, doc._rev]; + }); + + synchronize(funcs, args, cb); + }.bind(this)); + }.bind(this) + ); +}; diff --git a/lib/adapters/memory.js b/lib/adapters/memory.js new file mode 100644 index 0000000..9be7438 --- /dev/null +++ b/lib/adapters/memory.js @@ -0,0 +1,168 @@ +exports.initialize = function initializeSchema(schema, callback) { + schema.adapter = new Memory(); + process.nextTick(callback); +}; + +function Memory() { + this._models = {}; + this.cache = {}; + this.ids = {}; +} + +Memory.prototype.define = function defineModel(descr) { + var m = descr.model.modelName; + this._models[m] = descr; + this.cache[m] = {}; + this.ids[m] = 1; +}; + +Memory.prototype.create = function create(model, data, callback) { + var id = data.id || this.ids[model]++; + data.id = id; + this.cache[model][id] = data; + callback(null, id); +}; + +Memory.prototype.updateOrCreate = function (model, data, callback) { + var mem = this; + this.exists(model, data.id, function (err, exists) { + if (exists) { + mem.save(model, data, callback); + } else { + mem.create(model, data, function (err, id) { + data.id = id; + callback(err, data); + }); + } + }); +}; + +Memory.prototype.save = function save(model, data, callback) { + this.cache[model][data.id] = data; + callback(null, data); +}; + +Memory.prototype.exists = function exists(model, id, callback) { + callback(null, this.cache[model].hasOwnProperty(id)); +}; + +Memory.prototype.find = function find(model, id, callback) { + callback(null, this.cache[model][id]); +}; + +Memory.prototype.destroy = function destroy(model, id, callback) { + delete this.cache[model][id]; + callback(); +}; + +Memory.prototype.all = function all(model, filter, callback) { + var nodes = Object.keys(this.cache[model]).map(function (key) { + return this.cache[model][key]; + }.bind(this)); + + if (filter) { + + // do we need some filtration? + if (filter.where) { + nodes = nodes ? nodes.filter(applyFilter(filter)) : nodes; + } + + // do we need some sorting? + if (filter.order) { + var props = this._models[model].properties; + var orders = filter.order; + if (typeof filter.order === "string") { + orders = [filter.order]; + } + orders.forEach(function (key, i) { + var reverse = 1; + var m = key.match(/\s+(A|DE)SC$/i); + if (m) { + key = key.replace(/\s+(A|DE)SC/i, ''); + if (m[1] === 'DE') reverse = -1; + } + orders[i] = {"key": key, "reverse": reverse}; + }); + nodes = nodes.sort(sorting.bind(orders)); + } + } + + process.nextTick(function () { + callback(null, nodes); + }); + + function sorting(a, b) { + for (var i=0, l=this.length; i b[this[i].key]) { + return 1*this[i].reverse; + } else if (a[this[i].key] < b[this[i].key]) { + return -1*this[i].reverse; + } + } + return 0; + } +}; + +function applyFilter(filter) { + if (typeof filter.where === 'function') { + return filter.where; + } + var keys = Object.keys(filter.where); + return function (obj) { + var pass = true; + keys.forEach(function (key) { + if (!test(filter.where[key], obj[key])) { + pass = false; + } + }); + return pass; + } + + function test(example, value) { + if (typeof value === 'string' && example && example.constructor.name === 'RegExp') { + return value.match(example); + } + // not strict equality + return (example !== null ? example.toString() : example) == (value !== null ? value.toString() : value); + } +} + +Memory.prototype.destroyAll = function destroyAll(model, callback) { + Object.keys(this.cache[model]).forEach(function (id) { + delete this.cache[model][id]; + }.bind(this)); + this.cache[model] = {}; + callback(); +}; + +Memory.prototype.count = function count(model, callback, where) { + var cache = this.cache[model]; + var data = Object.keys(cache) + if (where) { + data = data.filter(function (id) { + var ok = true; + Object.keys(where).forEach(function (key) { + if (cache[id][key] != where[key]) { + ok = false; + } + }); + return ok; + }); + } + callback(null, data.length); +}; + +Memory.prototype.updateAttributes = function updateAttributes(model, id, data, cb) { + data.id = id; + var base = this.cache[model][id]; + this.save(model, merge(base, data), cb); +}; + +function merge(base, update) { + if (!base) return update; + Object.keys(update).forEach(function (key) { + base[key] = update[key]; + }); + return base; +} + diff --git a/lib/adapters/mongodb.js b/lib/adapters/mongodb.js new file mode 100644 index 0000000..333ad71 --- /dev/null +++ b/lib/adapters/mongodb.js @@ -0,0 +1,242 @@ + +var safeRequire = require('../utils').safeRequire; + +/** + * Module dependencies + */ +var mongodb = safeRequire('mongodb'); +var ObjectID = mongodb.ObjectID; + +exports.initialize = function initializeSchema(schema, callback) { + if (!mongodb) return; + + var s = schema.settings; + + if (schema.settings.rs) { + + s.rs = schema.settings.rs; + if (schema.settings.url) { + var uris = schema.settings.url.split(','); + s.hosts = [] + s.ports = [] + uris.forEach(function(uri) { + var url = require('url').parse(uri); + + s.hosts.push(url.hostname || 'localhost'); + s.ports.push(parseInt(url.port || '27017', 10)); + + if (!s.database) s.database = url.pathname.replace(/^\//, ''); + if (!s.username) s.username = url.auth && url.auth.split(':')[0]; + if (!s.password) s.password = url.auth && url.auth.split(':')[1]; + }); + } + + s.database = s.database || 'test'; + + } else { + + if (schema.settings.url) { + var url = require('url').parse(schema.settings.url); + s.host = url.hostname; + s.port = url.port; + s.database = url.pathname.replace(/^\//, ''); + s.username = url.auth && url.auth.split(':')[0]; + s.password = url.auth && url.auth.split(':')[1]; + } + + s.host = s.host || 'localhost'; + s.port = parseInt(s.port || '27017', 10); + s.database = s.database || 'test'; + + } + + s.safe = s.safe || false; + + schema.adapter = new MongoDB(s, schema, callback); +}; + +function MongoDB(s, schema, callback) { + var i, n; + this._models = {}; + this.collections = {}; + + var server; + if (s.rs) { + set = []; + for (i = 0, n = s.hosts.length; i < n; i++) { + set.push(new mongodb.Server(s.hosts[i], s.ports[i], {auto_reconnect: true})); + } + server = new mongodb.ReplSetServers(set, {rs_name: s.rs}); + + } else { + server = new mongodb.Server(s.host, s.port, {}); + } + + new mongodb.Db(s.database, server, { safe: s.safe }).open(function (err, client) { + if (err) throw err; + if (s.username && s.password) { + t = this; + client.authenticate(s.username, s.password, function (err, result) { + t.client = client; + schema.client = client; + callback(); + }); + + } else { + this.client = client; + schema.client = client; + callback(); + } + }.bind(this)); +} + +MongoDB.prototype.define = function (descr) { + if (!descr.settings) descr.settings = {}; + this._models[descr.model.modelName] = descr; +}; + +MongoDB.prototype.defineProperty = function (model, prop, params) { + this._models[model].properties[prop] = params; +}; + +MongoDB.prototype.collection = function (name) { + if (!this.collections[name]) { + this.collections[name] = new mongodb.Collection(this.client, name); + } + return this.collections[name]; +}; + +MongoDB.prototype.create = function (model, data, callback) { + if (data.id === null) { + delete data.id; + } + this.collection(model).insert(data, {}, function (err, m) { + callback(err, err ? null : m[0]._id.toString()); + }); +}; + +MongoDB.prototype.save = function (model, data, callback) { + this.collection(model).update({_id: new ObjectID(data.id)}, data, function (err) { + callback(err); + }); +}; + +MongoDB.prototype.exists = function (model, id, callback) { + this.collection(model).findOne({_id: new ObjectID(id)}, function (err, data) { + callback(err, !err && data); + }); +}; + +MongoDB.prototype.find = function find(model, id, callback) { + this.collection(model).findOne({_id: new ObjectID(id)}, function (err, data) { + if (data) data.id = id; + callback(err, data); + }); +}; + +MongoDB.prototype.updateOrCreate = function updateOrCreate(model, data, callback) { + var adapter = this; + if (!data.id) return this.create(data, callback); + this.find(model, data.id, function (err, inst) { + if (err) return callback(err); + if (inst) { + adapter.updateAttributes(model, data.id, data, callback); + } else { + delete data.id; + adapter.create(model, data, function (err, id) { + if (err) return callback(err); + if (id) { + data.id = id; + delete data._id; + callback(null, data); + } else{ + callback(null, null); // wtf? + } + }); + } + }); +}; + +MongoDB.prototype.destroy = function destroy(model, id, callback) { + this.collection(model).remove({_id: new ObjectID(id)}, callback); +}; + +MongoDB.prototype.all = function all(model, filter, callback) { + if (!filter) { + filter = {}; + } + var query = {}; + if (filter.where) { + Object.keys(filter.where).forEach(function (k) { + var cond = filter.where[k]; + var spec = false; + if (cond && cond.constructor.name === 'Object') { + spec = Object.keys(cond)[0]; + cond = cond[spec]; + } + if (spec) { + if (spec === 'between') { + query[k] = { $gte: cond[0], $lte: cond[1]}; + } else { + query[k] = {}; + query[k]['$' + spec] = cond; + } + } else { + if (cond === null) { + query[k] = {$type: 10}; + } else { + query[k] = cond; + } + } + }); + } + var cursor = this.collection(model).find(query); + + if (filter.order) { + var m = filter.order.match(/\s+(A|DE)SC$/); + var key = filter.order; + var reverse = false; + if (m) { + key = key.replace(/\s+(A|DE)SC$/, ''); + if (m[1] === 'DE') reverse = true; + } + if (reverse) { + cursor.sort([[key, 'desc']]); + } else { + cursor.sort(key); + } + } + if (filter.limit) { + cursor.limit(filter.limit); + } + if (filter.skip) { + cursor.skip(filter.skip); + } else if (filter.offset) { + cursor.skip(filter.offset); + } + cursor.toArray(function (err, data) { + if (err) return callback(err); + callback(null, data.map(function (o) { o.id = o._id.toString(); delete o._id; return o; })); + }); +}; + +MongoDB.prototype.destroyAll = function destroyAll(model, callback) { + this.collection(model).remove({}, callback); +}; + +MongoDB.prototype.count = function count(model, callback, where) { + this.collection(model).count(where, function (err, count) { + callback(err, count); + }); +}; + +MongoDB.prototype.updateAttributes = function updateAttrs(model, id, data, cb) { + this.collection(model).findAndModify({_id: new ObjectID(id)}, [['_id','asc']], {$set: data}, {}, function(err, object) { + cb(err, object); + }); +}; + +MongoDB.prototype.disconnect = function () { + this.client.close(); +}; + diff --git a/lib/adapters/mongoose.js b/lib/adapters/mongoose.js new file mode 100644 index 0000000..4c9cba6 --- /dev/null +++ b/lib/adapters/mongoose.js @@ -0,0 +1,246 @@ +var safeRequire = require('../utils').safeRequire; + +/** + * Module dependencies + */ +var mongoose = safeRequire('mongoose'); + +exports.initialize = function initializeSchema(schema, callback) { + console.error('WARN: mongoose adapter is not supported, please use "mongodb" adapter instead'); + if (!mongoose) return; + + if (!schema.settings.url) { + var url = schema.settings.host || 'localhost'; + if (schema.settings.port) url += ':' + schema.settings.port; + var auth = ''; + if (schema.settings.username) { + auth = schema.settings.username; + if (schema.settings.password) { + auth += ':' + schema.settings.password; + } + } + if (auth) { + url = auth + '@' + url; + } + if (schema.settings.database) { + url += '/' + schema.settings.database; + } else { + url += '/'; + } + url = 'mongodb://' + url; + schema.settings.url = url; + } + if (!schema.settings.rs) { + schema.client = mongoose.connect(schema.settings.url); + } else { + schema.client = mongoose.connectSet(schema.settings.url, {rs_name: schema.settings.rs}); + } + + schema.adapter = new MongooseAdapter(schema.client); + process.nextTick(callback); +}; + +function MongooseAdapter(client) { + this._models = {}; + this.client = client; + this.cache = {}; +} + +MongooseAdapter.prototype.define = function (descr) { + var props = {}; + Object.keys(descr.properties).forEach(function (key) { + props[key] = {}; + props[key].type = descr.properties[key].type; + if (props[key].type.name === 'Text' || props[key].type.name === 'JSON') { + props[key].type = String; + } + if (props[key].type.name === 'Object') { + props[key].type = mongoose.Schema.Types.Mixed; + } + if (descr.properties[key].index) { + props[key].index = descr.properties[key].index; + } + }); + var schema = new mongoose.Schema(props); + this._models[descr.model.modelName] = mongoose.model(descr.model.modelName, schema, descr.settings.table || null); + this.cache[descr.model.modelName] = {}; +}; + +MongooseAdapter.prototype.defineForeignKey = function (model, key, cb) { + var piece = {}; + piece[key] = {type: mongoose.Schema.ObjectId, index: true}; + this._models[model].schema.add(piece); + cb(null, String); +}; + +MongooseAdapter.prototype.setCache = function (model, instance) { + this.cache[model][instance.id] = instance; +}; + +MongooseAdapter.prototype.getCached = function (model, id, cb) { + if (this.cache[model][id]) { + cb(null, this.cache[model][id]); + } else { + this._models[model].findById(id, function (err, instance) { + if (err) { + return cb(err); + } + this.cache[model][id] = instance; + cb(null, instance); + }.bind(this)); + } +}; + +MongooseAdapter.prototype.create = function (model, data, callback) { + var m = new this._models[model](data); + m.save(function (err) { + callback(err, err ? null : m.id); + }); +}; + +MongooseAdapter.prototype.save = function (model, data, callback) { + this.getCached(model, data.id, function (err, inst) { + if (err) { + return callback(err); + } + merge(inst, data); + inst.save(callback); + }); +}; + +MongooseAdapter.prototype.exists = function (model, id, callback) { + delete this.cache[model][id]; + this.getCached(model, id, function (err, data) { + if (err) { + return callback(err); + } + callback(err, !!data); + }); +}; + +MongooseAdapter.prototype.find = function find(model, id, callback) { + delete this.cache[model][id]; + this.getCached(model, id, function (err, data) { + if (err) { + return callback(err); + } + callback(err, data ? data.toObject() : null); + }); +}; + +MongooseAdapter.prototype.destroy = function destroy(model, id, callback) { + this.getCached(model, id, function (err, data) { + if (err) { + return callback(err); + } + if (data) { + data.remove(callback); + } else { + callback(null); + } + }); +}; + +MongooseAdapter.prototype.all = function all(model, filter, callback) { + if (!filter) { + filter = {}; + } + var query = this._models[model].find({}); + if (filter.where) { + Object.keys(filter.where).forEach(function (k) { + var cond = filter.where[k]; + var spec = false; + if (cond && cond.constructor.name === 'Object') { + spec = Object.keys(cond)[0]; + cond = cond[spec]; + } + if (spec) { + if (spec === 'between') { + query.where(k).gte(cond[0]).lte(cond[1]); + } else { + query.where(k)[spec](cond); + } + } else { + query.where(k, cond); + } + }); + } + if (filter.order) { + var keys = filter.order; // can be Array or String + if (typeof(keys) == "string") { + keys = keys.split(','); + } + var args = []; + + for(index in keys) { + var m = keys[index].match(/\s+(A|DE)SC$/); + + keys[index] = keys[index].replace(/\s+(A|DE)SC$/, ''); + if (m && m[1] === 'DE') { + query.sort(keys[index].trim(), -1); + } else { + query.sort(keys[index].trim(), 1); + } + } + } + if (filter.limit) { + query.limit(filter.limit); + } + if (filter.skip) { + query.skip(filter.skip); + } else if (filter.offset) { + query.skip(filter.offset); + } + query.exec(function (err, data) { + if (err) return callback(err); + callback(null, data); + }); +}; + +MongooseAdapter.prototype.destroyAll = function destroyAll(model, callback) { + var wait = 0; + this._models[model].find(function (err, data) { + if (err) return callback(err); + wait = data.length; + if (!data.length) return callback(null); + data.forEach(function (obj) { + obj.remove(done) + }); + }); + + var error = null; + function done(err) { + error = error || err; + if (--wait === 0) { + callback(error); + } + } + +}; + +MongooseAdapter.prototype.count = function count(model, callback, where) { + this._models[model].count(where || {}, callback); +}; + +MongooseAdapter.prototype.updateAttributes = function updateAttrs(model, id, data, cb) { + this.getCached(model, id, function (err, inst) { + if (err) { + return cb(err); + } else if (inst) { + merge(inst, data); + inst.save(cb); + } else cb(); + }); +}; + +MongooseAdapter.prototype.disconnect = function () { + this.client.connection.close(); +}; + +function merge(base, update) { + Object.keys(update).forEach(function (key) { + base[key] = update[key]; + }); + return base; +} + diff --git a/lib/adapters/mysql.js b/lib/adapters/mysql.js new file mode 100644 index 0000000..5d46402 --- /dev/null +++ b/lib/adapters/mysql.js @@ -0,0 +1,535 @@ +var safeRequire = require('../utils').safeRequire; + +/** + * Module dependencies + */ +var mysql = safeRequire('mysql'); +var BaseSQL = require('../sql'); + +exports.initialize = function initializeSchema(schema, callback) { + if (!mysql) return; + + var s = schema.settings; + schema.client = mysql.createConnection({ + host: s.host || 'localhost', + port: s.port || 3306, + user: s.username, + password: s.password, + debug: s.debug + }); + + schema.adapter = new MySQL(schema.client); + schema.adapter.schema = schema; + // schema.client.query('SET TIME_ZONE = "+04:00"', callback); + schema.client.query('USE `' + s.database + '`', function (err) { + if (err && err.message.match(/^unknown database/i)) { + var dbName = s.database; + schema.client.query('CREATE DATABASE ' + dbName, function (error) { + if (!error) { + schema.client.query('USE ' + s.database, callback); + } else { + throw error; + } + }); + } else callback(); + }); +}; + +/** + * MySQL adapter + */ +function MySQL(client) { + this._models = {}; + this.client = client; +} + +require('util').inherits(MySQL, BaseSQL); + +MySQL.prototype.query = function (sql, callback) { + if (!this.schema.connected) { + return this.schema.on('connected', function () { + this.query(sql, callback); + }.bind(this)); + } + var client = this.client; + var time = Date.now(); + var log = this.log; + if (typeof callback !== 'function') throw new Error('callback should be a function'); + this.client.query(sql, function (err, data) { + if (err && err.message.match(/^unknown database/i)) { + var dbName = err.message.match(/^unknown database '(.*?)'/i)[1]; + client.query('CREATE DATABASE ' + dbName, function (error) { + if (!error) { + client.query(sql, callback); + } else { + callback(err); + } + }); + return; + } + if (log) log(sql, time); + callback(err, data); + }); +}; + +/** + * Must invoke callback(err, id) + */ +MySQL.prototype.create = function (model, data, callback) { + var fields = this.toFields(model, data); + var sql = 'INSERT INTO ' + this.tableEscaped(model); + if (fields) { + sql += ' SET ' + fields; + } else { + sql += ' VALUES ()'; + } + this.query(sql, function (err, info) { + callback(err, info && info.insertId); + }); +}; + +MySQL.prototype.updateOrCreate = function (model, data, callback) { + var mysql = this; + var fieldsNames = []; + var fieldValues = []; + var combined = []; + var props = this._models[model].properties; + Object.keys(data).forEach(function (key) { + if (props[key] || key === 'id') { + var k = '`' + key + '`'; + var v; + if (key !== 'id') { + v = mysql.toDatabase(props[key], data[key]); + } else { + v = data[key]; + } + fieldsNames.push(k); + fieldValues.push(v); + if (key !== 'id') combined.push(k + ' = ' + v); + } + }); + + var sql = 'INSERT INTO ' + this.tableEscaped(model); + sql += ' (' + fieldsNames.join(', ') + ')'; + sql += ' VALUES (' + fieldValues.join(', ') + ')'; + sql += ' ON DUPLICATE KEY UPDATE ' + combined.join(', '); + + this.query(sql, function (err, info) { + if (!err && info && info.insertId) { + data.id = info.insertId; + } + callback(err, data); + }); +}; + +MySQL.prototype.toFields = function (model, data) { + var fields = []; + var props = this._models[model].properties; + Object.keys(data).forEach(function (key) { + if (props[key]) { + fields.push('`' + key.replace(/\./g, '`.`') + '` = ' + this.toDatabase(props[key], data[key])); + } + }.bind(this)); + return fields.join(','); +}; + +function dateToMysql(val) { + return val.getUTCFullYear() + '-' + + fillZeros(val.getUTCMonth() + 1) + '-' + + fillZeros(val.getUTCDate()) + ' ' + + fillZeros(val.getUTCHours()) + ':' + + fillZeros(val.getUTCMinutes()) + ':' + + fillZeros(val.getUTCSeconds()); + + function fillZeros(v) { + return v < 10 ? '0' + v : v; + } +} + +MySQL.prototype.toDatabase = function (prop, val) { + if (val === null) return 'NULL'; + if (val.constructor.name === 'Object') { + var operator = Object.keys(val)[0] + val = val[operator]; + if (operator === 'between') { + return this.toDatabase(prop, val[0]) + + ' AND ' + + this.toDatabase(prop, val[1]); + } else if (operator == 'inq' || operator == 'nin') { + if (!(val.propertyIsEnumerable('length')) && typeof val === 'object' && typeof val.length === 'number') { //if value is array + for (var i = 0; i < val.length; i++) { + val[i] = this.client.escape(val[i]); + } + return val.join(','); + } else { + return val; + } + } + } + if (!prop) return val; + if (prop.type.name === 'Number') return val; + if (prop.type.name === 'Date') { + if (!val) return 'NULL'; + if (!val.toUTCString) { + val = new Date(val); + } + return '"' + dateToMysql(val) + '"'; + } + if (prop.type.name == "Boolean") return val ? 1 : 0; + return this.client.escape(val.toString()); +}; + +MySQL.prototype.fromDatabase = function (model, data) { + if (!data) return null; + var props = this._models[model].properties; + Object.keys(data).forEach(function (key) { + var val = data[key]; + if (props[key]) { + if (props[key].type.name === 'Date' && val !== null) { + val = new Date(val.toString().replace(/GMT.*$/, 'GMT')); + } + } + data[key] = val; + }); + return data; +}; + +MySQL.prototype.escapeName = function (name) { + return '`' + name.replace(/\./g, '`.`') + '`'; +}; + +MySQL.prototype.all = function all(model, filter, callback) { + + var sql = 'SELECT * FROM ' + this.tableEscaped(model); + var self = this; + var props = this._models[model].properties; + + if (filter) { + + if (filter.where) { + sql += ' ' + buildWhere(filter.where); + } + + if (filter.order) { + sql += ' ' + buildOrderBy(filter.order); + } + + if (filter.limit) { + sql += ' ' + buildLimit(filter.limit, filter.offset || 0); + } + + } + + this.query(sql, function (err, data) { + if (err) { + return callback(err, []); + } + callback(null, data.map(function (obj) { + return self.fromDatabase(model, obj); + })); + }.bind(this)); + + return sql; + + function buildWhere(conds) { + var cs = []; + Object.keys(conds).forEach(function (key) { + var keyEscaped = '`' + key.replace(/\./g, '`.`') + '`' + var val = self.toDatabase(props[key], conds[key]); + if (conds[key] === null) { + cs.push(keyEscaped + ' IS NULL'); + } else if (conds[key].constructor.name === 'Object') { + var condType = Object.keys(conds[key])[0]; + var sqlCond = keyEscaped; + if ((condType == 'inq' || condType == 'nin') && val.length == 0) { + cs.push(condType == 'inq' ? 0 : 1); + return true; + } + switch (condType) { + case 'gt': + sqlCond += ' > '; + break; + case 'gte': + sqlCond += ' >= '; + break; + case 'lt': + sqlCond += ' < '; + break; + case 'lte': + sqlCond += ' <= '; + break; + case 'between': + sqlCond += ' BETWEEN '; + break; + case 'inq': + sqlCond += ' IN '; + break; + case 'nin': + sqlCond += ' NOT IN '; + break; + case 'neq': + sqlCond += ' != '; + break; + } + sqlCond += (condType == 'inq' || condType == 'nin') ? '(' + val + ')' : val; + cs.push(sqlCond); + } else { + cs.push(keyEscaped + ' = ' + val); + } + }); + if (cs.length === 0) { + return ''; + } + return 'WHERE ' + cs.join(' AND '); + } + + function buildOrderBy(order) { + if (typeof order === 'string') order = [order]; + return 'ORDER BY ' + order.join(', '); + } + + function buildLimit(limit, offset) { + return 'LIMIT ' + (offset ? (offset + ', ' + limit) : limit); + } + +}; + +MySQL.prototype.autoupdate = function (cb) { + var self = this; + var wait = 0; + Object.keys(this._models).forEach(function (model) { + wait += 1; + self.query('SHOW FIELDS FROM ' + self.tableEscaped(model), function (err, fields) { + self.query('SHOW INDEXES FROM ' + self.tableEscaped(model), function (err, indexes) { + if (!err && fields.length) { + self.alterTable(model, fields, indexes, done); + } else { + self.createTable(model, done); + } + }); + }); + }); + + function done(err) { + if (err) { + console.log(err); + } + if (--wait === 0 && cb) { + cb(); + } + } +}; + +MySQL.prototype.isActual = function (cb) { + var ok = false; + var self = this; + var wait = 0; + Object.keys(this._models).forEach(function (model) { + wait += 1; + self.query('SHOW FIELDS FROM ' + model, function (err, fields) { + self.query('SHOW INDEXES FROM ' + model, function (err, indexes) { + self.alterTable(model, fields, indexes, done, true); + }); + }); + }); + + function done(err, needAlter) { + if (err) { + console.log(err); + } + ok = ok || needAlter; + if (--wait === 0 && cb) { + cb(null, !ok); + } + } +}; + +MySQL.prototype.alterTable = function (model, actualFields, actualIndexes, done, checkOnly) { + var self = this; + var m = this._models[model]; + var propNames = Object.keys(m.properties).filter(function (name) { + return !!m.properties[name]; + }); + var indexNames = m.settings.indexes ? Object.keys(m.settings.indexes).filter(function (name) { + return !!m.settings.indexes[name]; + }) : []; + var sql = []; + var ai = {}; + + if (actualIndexes) { + actualIndexes.forEach(function (i) { + var name = i.Key_name; + if (!ai[name]) { + ai[name] = { + info: i, + columns: [] + }; + } + ai[name].columns[i.Seq_in_index - 1] = i.Column_name; + }); + } + var aiNames = Object.keys(ai); + + // change/add new fields + propNames.forEach(function (propName) { + if (propName === 'id') return; + var found; + actualFields.forEach(function (f) { + if (f.Field === propName) { + found = f; + } + }); + + if (found) { + actualize(propName, found); + } else { + sql.push('ADD COLUMN `' + propName + '` ' + self.propertySettingsSQL(model, propName)); + } + }); + + // drop columns + actualFields.forEach(function (f) { + var notFound = !~propNames.indexOf(f.Field); + if (f.Field === 'id') return; + if (notFound || !m.properties[f.Field]) { + sql.push('DROP COLUMN `' + f.Field + '`'); + } + }); + + // remove indexes + aiNames.forEach(function (indexName) { + if (indexName === 'id' || indexName === 'PRIMARY') return; + if (indexNames.indexOf(indexName) === -1 && !m.properties[indexName] || m.properties[indexName] && !m.properties[indexName].index) { + sql.push('DROP INDEX `' + indexName + '`'); + } else { + // first: check single (only type and kind) + if (m.properties[indexName] && !m.properties[indexName].index) { + // TODO + return; + } + // second: check multiple indexes + var orderMatched = true; + if (indexNames.indexOf(indexName) !== -1) { + m.settings.indexes[indexName].columns.split(/,\s*/).forEach(function (columnName, i) { + if (ai[indexName].columns[i] !== columnName) orderMatched = false; + }); + } + if (!orderMatched) { + sql.push('DROP INDEX `' + indexName + '`'); + delete ai[indexName]; + } + } + }); + + // add single-column indexes + propNames.forEach(function (propName) { + var i = m.properties[propName].index; + if (!i) { + return; + } + var found = ai[propName] && ai[propName].info; + if (!found) { + var type = ''; + var kind = ''; + if (i.type) { + type = 'USING ' + i.type; + } + if (i.kind) { + // kind = i.kind; + } + if (kind && type) { + sql.push('ADD ' + kind + ' INDEX `' + propName + '` (`' + propName + '`) ' + type); + } else { + sql.push('ADD ' + kind + ' INDEX `' + propName + '` ' + type + ' (`' + propName + '`) '); + } + } + }); + + // add multi-column indexes + indexNames.forEach(function (indexName) { + var i = m.settings.indexes[indexName]; + var found = ai[indexName] && ai[indexName].info; + if (!found) { + var type = ''; + var kind = ''; + if (i.type) { + type = 'USING ' + i.kind; + } + if (i.kind) { + kind = i.kind; + } + if (kind && type) { + sql.push('ADD ' + kind + ' INDEX `' + indexName + '` (' + i.columns + ') ' + type); + } else { + sql.push('ADD ' + kind + ' INDEX ' + type + ' `' + indexName + '` (' + i.columns + ')'); + } + } + }); + + if (sql.length) { + var query = 'ALTER TABLE ' + self.tableEscaped(model) + ' ' + sql.join(',\n'); + if (checkOnly) { + done(null, true, {statements: sql, query: query}); + } else { + this.query(query, done); + } + } else { + done(); + } + + function actualize(propName, oldSettings) { + var newSettings = m.properties[propName]; + if (newSettings && changed(newSettings, oldSettings)) { + sql.push('CHANGE COLUMN `' + propName + '` `' + propName + '` ' + self.propertySettingsSQL(model, propName)); + } + } + + function changed(newSettings, oldSettings) { + if (oldSettings.Null === 'YES' && (newSettings.allowNull === false || newSettings.null === false)) return true; + if (oldSettings.Null === 'NO' && !(newSettings.allowNull === false || newSettings.null === false)) return true; + if (oldSettings.Type.toUpperCase() !== datatype(newSettings)) return true; + return false; + } +}; + +MySQL.prototype.propertiesSQL = function (model) { + var self = this; + var sql = ['`id` INT(11) NOT NULL AUTO_INCREMENT UNIQUE PRIMARY KEY']; + Object.keys(this._models[model].properties).forEach(function (prop) { + if (prop === 'id') return; + sql.push('`' + prop + '` ' + self.propertySettingsSQL(model, prop)); + }); + return sql.join(',\n '); + +}; + +MySQL.prototype.propertySettingsSQL = function (model, prop) { + var p = this._models[model].properties[prop]; + return datatype(p) + ' ' + + (p.allowNull === false || p['null'] === false ? 'NOT NULL' : 'NULL'); +}; + +function datatype(p) { + var dt = ''; + switch (p.type.name) { + default: + case 'String': + case 'JSON': + dt = 'VARCHAR(' + (p.limit || 255) + ')'; + break; + case 'Text': + dt = 'TEXT'; + break; + case 'Number': + dt = 'INT(' + (p.limit || 11) + ')'; + break; + case 'Date': + dt = 'DATETIME'; + break; + case 'Boolean': + dt = 'TINYINT(1)'; + break; + } + return dt; +} + diff --git a/lib/adapters/neo4j.js b/lib/adapters/neo4j.js new file mode 100644 index 0000000..cf5c3f9 --- /dev/null +++ b/lib/adapters/neo4j.js @@ -0,0 +1,372 @@ +var safeRequire = require('../utils').safeRequire; + +/** + * Module dependencies + */ +var neo4j = safeRequire('neo4j'); + +exports.initialize = function initializeSchema(schema, callback) { + schema.client = new neo4j.GraphDatabase(schema.settings.url); + schema.adapter = new Neo4j(schema.client); + process.nextTick(callback); +}; + +function Neo4j(client) { + this._models = {}; + this.client = client; + this.cache = {}; +} + +Neo4j.prototype.define = function defineModel(descr) { + this.mixClassMethods(descr.model, descr.properties); + this.mixInstanceMethods(descr.model.prototype, descr.properties); + this._models[descr.model.modelName] = descr; +}; + +Neo4j.prototype.createIndexHelper = function (cls, indexName) { + var db = this.client; + var method = 'findBy' + indexName[0].toUpperCase() + indexName.substr(1); + cls[method] = function (value, cb) { + db.getIndexedNode(cls.modelName, indexName, value, function (err, node) { + if (err) return cb(err); + if (node) { + node.data.id = node.id; + cb(null, new cls(node.data)); + } else { + cb(null, null); + } + }); + }; +}; + +Neo4j.prototype.mixClassMethods = function mixClassMethods(cls, properties) { + var neo = this; + + Object.keys(properties).forEach(function (name) { + if (properties[name].index) { + neo.createIndexHelper(cls, name); + } + }); + + cls.setupCypherQuery = function (name, queryStr, rowHandler) { + cls[name] = function cypherQuery(params, cb) { + if (typeof params === 'function') { + cb = params; + params = []; + } else if (params.constructor.name !== 'Array') { + params = [params]; + } + + var i = 0; + var q = queryStr.replace(/\?/g, function () { + return params[i++]; + }); + + neo.client.query(function (err, result) { + if (err) return cb(err, []); + cb(null, result.map(rowHandler)); + }, q); + }; + }; + + /** + * @param from - id of object to check relation from + * @param to - id of object to check relation to + * @param type - type of relation + * @param direction - all | incoming | outgoing + * @param cb - callback (err, rel || false) + */ + cls.relationshipExists = function relationshipExists(from, to, type, direction, cb) { + neo.node(from, function (err, node) { + if (err) return cb(err); + node._getRelationships(direction, type, function (err, rels) { + if (err && cb) return cb(err); + if (err && !cb) throw err; + var found = false; + if (rels && rels.forEach) { + rels.forEach(function (r) { + if (r.start.id === from && r.end.id === to) { + found = true; + } + }); + } + cb && cb(err, found); + }); + }); + }; + + cls.createRelationshipTo = function createRelationshipTo(id1, id2, type, data, cb) { + var fromNode, toNode; + neo.node(id1, function (err, node) { + if (err && cb) return cb(err); + if (err && !cb) throw err; + fromNode = node; + ok(); + }); + neo.node(id2, function (err, node) { + if (err && cb) return cb(err); + if (err && !cb) throw err; + toNode = node; + ok(); + }); + function ok() { + if (fromNode && toNode) { + fromNode.createRelationshipTo(toNode, type, cleanup(data), cb); + } + } + }; + + cls.createRelationshipFrom = function createRelationshipFrom(id1, id2, type, data, cb) { + cls.createRelationshipTo(id2, id1, type, data, cb); + } + + // only create relationship if it is not exists + cls.ensureRelationshipTo = function (id1, id2, type, data, cb) { + cls.relationshipExists(id1, id2, type, 'outgoing', function (err, exists) { + if (err && cb) return cb(err); + if (err && !cb) throw err; + if (exists) return cb && cb(null); + cls.createRelationshipTo(id1, id2, type, data, cb); + }); + } +}; + +Neo4j.prototype.mixInstanceMethods = function mixInstanceMethods(proto) { + var neo = this; + + /** + * @param obj - Object or id of object to check relation with + * @param type - type of relation + * @param cb - callback (err, rel || false) + */ + proto.isInRelationWith = function isInRelationWith(obj, type, direction, cb) { + this.constructor.relationshipExists(this.id, obj.id || obj, type, 'all', cb); + }; +}; + +Neo4j.prototype.node = function find(id, callback) { + if (this.cache[id]) { + callback(null, this.cache[id]); + } else { + this.client.getNodeById(id, function (err, node) { + if (node) { + this.cache[id] = node; + } + callback(err, node); + }.bind(this)); + } +}; + +Neo4j.prototype.create = function create(model, data, callback) { + data.nodeType = model; + var node = this.client.createNode(); + node.data = cleanup(data); + node.data.nodeType = model; + node.save(function (err) { + if (err) { + return callback(err); + } + this.cache[node.id] = node; + node.index(model, 'id', node.id, function (err) { + if (err) return callback(err); + this.updateIndexes(model, node, function (err) { + if (err) return callback(err); + callback(null, node.id); + }); + }.bind(this)); + }.bind(this)); +}; + +Neo4j.prototype.updateIndexes = function updateIndexes(model, node, cb) { + var props = this._models[model].properties; + var wait = 1; + Object.keys(props).forEach(function (key) { + if (props[key].index && node.data[key]) { + wait += 1; + node.index(model, key, node.data[key], done); + } + }); + + done(); + + var error = false; + function done(err) { + error = error || err; + if (--wait === 0) { + cb(error); + } + } +}; + +Neo4j.prototype.save = function save(model, data, callback) { + var self = this; + + this.node(data.id, function (err, node) { + //delete id property since that's redundant and we use the node.id + delete data.id; + if (err) return callback(err); + node.data = cleanup(data); + node.save(function (err) { + if (err) return callback(err); + self.updateIndexes(model, node, function (err) { + if (err) return console.log(err); + //map node id to the id property being sent back + node.data.id = node.id; + callback(null, node.data); + }); + }); + }); +}; + +Neo4j.prototype.exists = function exists(model, id, callback) { + delete this.cache[id]; + this.node(id, callback); +}; + +Neo4j.prototype.find = function find(model, id, callback) { + delete this.cache[id]; + this.node(id, function (err, node) { + if (node && node.data) { + node.data.id = id; + } + callback(err, this.readFromDb(model, node && node.data)); + }.bind(this)); +}; + +Neo4j.prototype.readFromDb = function readFromDb(model, data) { + if (!data) return data; + var res = {}; + var props = this._models[model].properties; + Object.keys(data).forEach(function (key) { + if (props[key] && props[key].type.name === 'Date') { + res[key] = new Date(data[key]); + } else { + res[key] = data[key]; + } + }); + return res; +}; + +Neo4j.prototype.destroy = function destroy(model, id, callback) { + var force = true; + this.node(id, function (err, node) { + if (err) return callback(err); + node.delete(function (err) { + if (err) return callback(err); + delete this.cache[id]; + }.bind(this), force); + }); +}; + +Neo4j.prototype.all = function all(model, filter, callback) { + this.client.queryNodeIndex(model, 'id:*', function (err, nodes) { + if (nodes) { + nodes = nodes.map(function (obj) { + obj.data.id = obj.id; + return this.readFromDb(model, obj.data); + }.bind(this)); + } + if (filter) { + nodes = nodes ? nodes.filter(applyFilter(filter)) : nodes; + if (filter.order) { + var key = filter.order.split(' ')[0]; + var dir = filter.order.split(' ')[1]; + nodes = nodes.sort(function (a, b) { + return a[key] > b[key]; + }); + if (dir === 'DESC') nodes = nodes.reverse(); + } + } + callback(err, nodes); + }.bind(this)); +}; + +Neo4j.prototype.allNodes = function all(model, callback) { + this.client.queryNodeIndex(model, 'id:*', function (err, nodes) { + callback(err, nodes); + }); +}; + +function applyFilter(filter) { + if (typeof filter.where === 'function') { + return filter.where; + } + var keys = Object.keys(filter.where || {}); + return function (obj) { + var pass = true; + keys.forEach(function (key) { + if (!test(filter.where[key], obj[key])) { + pass = false; + } + }); + return pass; + } + + function test(example, value) { + if (typeof value === 'string' && example && example.constructor.name === 'RegExp') { + return value.match(example); + } + if (typeof value === 'object' && value.constructor.name === 'Date' && typeof example === 'object' && example.constructor.name === 'Date') { + return example.toString() === value.toString(); + } + // not strict equality + return example == value; + } +} + +Neo4j.prototype.destroyAll = function destroyAll(model, callback) { + var wait, error = null; + this.allNodes(model, function (err, collection) { + if (err) return callback(err); + wait = collection.length; + collection && collection.forEach && collection.forEach(function (node) { + node.delete(done, true); + }); + }); + + function done(err) { + error = error || err; + if (--wait === 0) { + callback(error); + } + } +}; + +Neo4j.prototype.count = function count(model, callback, conds) { + this.all(model, {where: conds}, function (err, collection) { + callback(err, collection ? collection.length : 0); + }); +}; + +Neo4j.prototype.updateAttributes = function updateAttributes(model, id, data, cb) { + data.id = id; + this.node(id, function (err, node) { + this.save(model, merge(node.data, data), cb); + }.bind(this)); +}; + +function cleanup(data) { + if (!data) return null; + + var res = {}; + Object.keys(data).forEach(function (key) { + var v = data[key]; + if (v === null) { + // skip + // console.log('skip null', key); + } else if (v && v.constructor.name === 'Array' && v.length === 0) { + // skip + // console.log('skip blank array', key); + } else if (typeof v !== 'undefined') { + res[key] = v; + } + }); + return res; +} + +function merge(base, update) { + Object.keys(update).forEach(function (key) { + base[key] = update[key]; + }); + return base; +} diff --git a/lib/adapters/postgres.js b/lib/adapters/postgres.js new file mode 100644 index 0000000..4eefe8a --- /dev/null +++ b/lib/adapters/postgres.js @@ -0,0 +1,579 @@ +var safeRequire = require('../utils').safeRequire; + +/** + * Module dependencies + */ +var pg = safeRequire('pg'); +var BaseSQL = require('../sql'); +var util = require('util'); + +exports.initialize = function initializeSchema(schema, callback) { + if (!pg) return; + + var Client = pg.Client; + var s = schema.settings; + schema.client = new Client(s.url ? s.url : { + host: s.host || 'localhost', + port: s.port || 5432, + user: s.username, + password: s.password, + database: s.database, + debug: s.debug + }); + schema.adapter = new PG(schema.client); + + schema.adapter.connect(callback); +}; + +function PG(client) { + this._models = {}; + this.client = client; +} + +require('util').inherits(PG, BaseSQL); + +PG.prototype.connect = function (callback) { + this.client.connect(function (err) { + if (!err){ + callback(); + }else{ + console.error(err); + throw err; + } + }); +}; + +PG.prototype.query = function (sql, callback) { + var time = Date.now(); + var log = this.log; + this.client.query(sql, function (err, data) { + if (log) log(sql, time); + callback(err, data ? data.rows : null); + }); +}; + +/** + * Must invoke callback(err, id) + */ +PG.prototype.create = function (model, data, callback) { + var fields = this.toFields(model, data, true); + var sql = 'INSERT INTO ' + this.tableEscaped(model) + ''; + if (fields) { + sql += ' ' + fields; + } else { + sql += ' VALUES ()'; + } + sql += ' RETURNING id'; + this.query(sql, function (err, info) { + if (err) return callback(err); + callback(err, info && info[0] && info[0].id); + }); +}; + +PG.prototype.updateOrCreate = function (model, data, callback) { + var pg = this; + var fieldsNames = []; + var fieldValues = []; + var combined = []; + var props = this._models[model].properties; + Object.keys(data).forEach(function (key) { + if (props[key] || key === 'id') { + var k = '"' + key + '"'; + var v; + if (key !== 'id') { + v = pg.toDatabase(props[key], data[key]); + } else { + v = data[key]; + } + fieldsNames.push(k); + fieldValues.push(v); + if (key !== 'id') combined.push(k + ' = ' + v); + } + }); + + var sql = 'UPDATE ' + this.tableEscaped(model); + sql += ' SET ' + combined + ' WHERE id = ' + data.id + ';'; + sql += ' INSERT INTO ' + this.tableEscaped(model); + sql += ' (' + fieldsNames.join(', ') + ')'; + sql += ' SELECT ' + fieldValues.join(', ') + sql += ' WHERE NOT EXISTS (SELECT 1 FROM ' + this.tableEscaped(model); + sql += ' WHERE id = ' + data.id + ') RETURNING id'; + + this.query(sql, function (err, info) { + if (!err && info && info[0] && info[0].id) { + data.id = info[0].id; + } + callback(err, data); + }); +}; + +PG.prototype.toFields = function (model, data, forCreate) { + var fields = []; + var props = this._models[model].properties; + + if(forCreate){ + var columns = []; + Object.keys(data).forEach(function (key) { + if (props[key]) { + if (key === 'id') return; + columns.push('"' + key + '"'); + fields.push(this.toDatabase(props[key], data[key])); + } + }.bind(this)); + return '(' + columns.join(',') + ') VALUES ('+fields.join(',')+')'; + }else{ + Object.keys(data).forEach(function (key) { + if (props[key]) { + fields.push('"' + key + '" = ' + this.toDatabase(props[key], data[key])); + } + }.bind(this)); + return fields.join(','); + } +}; + +function dateToPostgres(val) { + return [ + val.getUTCFullYear(), + fz(val.getUTCMonth() + 1), + fz(val.getUTCDate()) + ].join('-') + ' ' + [ + fz(val.getUTCHours()), + fz(val.getUTCMinutes()), + fz(val.getUTCSeconds()) + ].join(':'); + + function fz(v) { + return v < 10 ? '0' + v : v; + } +} + +PG.prototype.toDatabase = function (prop, val) { + if (val === null) { + // Postgres complains with NULLs in not null columns + // If we have an autoincrement value, return DEFAULT instead + if( prop.autoIncrement ) { + return 'DEFAULT'; + } + else { + return 'NULL'; + } + } + if (val.constructor.name === 'Object') { + var operator = Object.keys(val)[0] + val = val[operator]; + if (operator === 'between') { + return this.toDatabase(prop, val[0]) + ' AND ' + this.toDatabase(prop, val[1]); + } + if (operator === 'inq' || operator === 'nin') { + for (var i = 0; i < val.length; i++) { + val[i] = escape(val[i]); + } + return val.join(','); + } + } + if (prop.type.name === 'Number') { + if (!val && val!=0) { + if( prop.autoIncrement ) { + return 'DEFAULT'; + } + else { + return 'NULL'; + } + } + return val + }; + + if (prop.type.name === 'Date') { + if (!val) { + if( prop.autoIncrement ) { + return 'DEFAULT'; + } + else { + return 'NULL'; + } + } + if (!val.toUTCString) { + val = new Date(val); + } + return escape(dateToPostgres(val)); + } + return escape(val.toString()); + +}; + +PG.prototype.fromDatabase = function (model, data) { + if (!data) return null; + var props = this._models[model].properties; + Object.keys(data).forEach(function (key) { + var val = data[key]; + data[key] = val; + }); + return data; +}; + +PG.prototype.escapeName = function (name) { + return '"' + name.replace(/\./g, '"."') + '"'; +}; + +PG.prototype.all = function all(model, filter, callback) { + this.query('SELECT * FROM ' + this.tableEscaped(model) + ' ' + this.toFilter(model, filter), function (err, data) { + if (err) { + return callback(err, []); + } + callback(err, data); + }.bind(this)); +}; + +PG.prototype.toFilter = function (model, filter) { + if (filter && typeof filter.where === 'function') { + return filter(); + } + if (!filter) return ''; + var props = this._models[model].properties; + var out = ''; + if (filter.where) { + var fields = []; + var conds = filter.where; + Object.keys(conds).forEach(function (key) { + if (filter.where[key] && filter.where[key].constructor.name === 'RegExp') { + return; + } + if (props[key]) { + var filterValue = this.toDatabase(props[key], filter.where[key]); + if (filterValue === 'NULL') { + fields.push('"' + key + '" IS ' + filterValue); + } else if (conds[key].constructor.name === 'Object') { + var condType = Object.keys(conds[key])[0]; + var sqlCond = '"' + key + '"'; + if ((condType == 'inq' || condType == 'nin') && filterValue.length == 0) { + fields.push(condType == 'inq' ? 'FALSE' : 'TRUE'); + return true; + } + switch (condType) { + case 'gt': + sqlCond += ' > '; + break; + case 'gte': + sqlCond += ' >= '; + break; + case 'lt': + sqlCond += ' < '; + break; + case 'lte': + sqlCond += ' <= '; + break; + case 'between': + sqlCond += ' BETWEEN '; + break; + case 'inq': + sqlCond += ' IN '; + break; + case 'nin': + sqlCond += ' NOT IN '; + break; + case 'neq': + sqlCond += ' != '; + break; + } + sqlCond += (condType == 'inq' || condType == 'nin') ? '(' + filterValue + ')' : filterValue; + fields.push(sqlCond); + } else { + fields.push('"' + key + '" = ' + filterValue); + } + } + }.bind(this)); + if (fields.length) { + out += ' WHERE ' + fields.join(' AND '); + } + } + + if (filter.order) { + out += ' ORDER BY ' + filter.order; + } + + if (filter.limit) { + out += ' LIMIT ' + filter.limit + ' OFFSET ' + (filter.offset || '0'); + } + + return out; +}; + +function getTableStatus(model, cb){ + function decoratedCallback(err, data){ + data.forEach(function(field){ + field.Type = mapPostgresDatatypes(field.Type); + }); + cb(err, data); + }; + this.query('SELECT column_name as "Field", udt_name as "Type", is_nullable as "Null", column_default as "Default" FROM information_schema.COLUMNS WHERE table_name = \'' + this.table(model) + '\'', decoratedCallback); +}; + +PG.prototype.autoupdate = function (cb) { + var self = this; + var wait = 0; + Object.keys(this._models).forEach(function (model) { + wait += 1; + var fields; + getTableStatus.call(self, model, function(err, fields){ + if(err) console.log(err); + self.alterTable(model, fields, done); + }); + }); + + function done(err) { + if (err) { + console.log(err); + } + if (--wait === 0 && cb) { + cb(); + } + }; +}; + +PG.prototype.isActual = function(cb) { + var self = this; + var wait = 0; + changes = []; + Object.keys(this._models).forEach(function (model) { + wait += 1; + getTableStatus.call(self, model, function(err, fields){ + changes = changes.concat(getPendingChanges.call(self, model, fields)); + done(err, changes); + }); + }); + + function done(err, fields) { + if (err) { + console.log(err); + } + if (--wait === 0 && cb) { + var actual = (changes.length === 0); + cb(null, actual); + } + }; +}; + +PG.prototype.alterTable = function (model, actualFields, done) { + var self = this; + var pendingChanges = getPendingChanges.call(self, model, actualFields); + applySqlChanges.call(self, model, pendingChanges, done); +}; + +function getPendingChanges(model, actualFields){ + var sql = []; + var self = this; + sql = sql.concat(getColumnsToAdd.call(self, model, actualFields)); + sql = sql.concat(getPropertiesToModify.call(self, model, actualFields)); + sql = sql.concat(getColumnsToDrop.call(self, model, actualFields)); + return sql; +}; + +function getColumnsToAdd(model, actualFields){ + var self = this; + var m = self._models[model]; + var propNames = Object.keys(m.properties); + var sql = []; + propNames.forEach(function (propName) { + if (propName === 'id') return; + var found = searchForPropertyInActual.call(self, propName, actualFields); + if(!found && propertyHasNotBeenDeleted.call(self, model, propName)){ + sql.push(addPropertyToActual.call(self, model, propName)); + } + }); + return sql; +}; + +function addPropertyToActual(model, propName){ + var self = this; + var p = self._models[model].properties[propName]; + var sqlCommand = 'ADD COLUMN "' + propName + '" ' + datatype(p) + " " + (propertyCanBeNull.call(self, model, propName) ? "" : " NOT NULL"); + return sqlCommand; +}; + +function searchForPropertyInActual(propName, actualFields){ + var found = false; + actualFields.forEach(function (f) { + if (f.Field === propName) { + found = f; + return; + } + }); + return found; +}; + +function getPropertiesToModify(model, actualFields){ + var self = this; + var sql = []; + var m = self._models[model]; + var propNames = Object.keys(m.properties); + var found; + propNames.forEach(function (propName) { + if (propName === 'id') return; + found = searchForPropertyInActual.call(self, propName, actualFields); + if(found && propertyHasNotBeenDeleted.call(self, model, propName)){ + if (datatypeChanged(propName, found)) { + sql.push(modifyDatatypeInActual.call(self, model, propName)); + } + if (nullabilityChanged(propName, found)){ + sql.push(modifyNullabilityInActual.call(self, model, propName)); + } + } + }); + + return sql; + + function datatypeChanged(propName, oldSettings){ + var newSettings = m.properties[propName]; + if(!newSettings) return false; + return oldSettings.Type.toLowerCase() !== datatype(newSettings); + }; + + function nullabilityChanged(propName, oldSettings){ + var newSettings = m.properties[propName]; + if(!newSettings) return false; + var changed = false; + if (oldSettings.Null === 'YES' && (newSettings.allowNull === false || newSettings.null === false)) changed = true; + if (oldSettings.Null === 'NO' && !(newSettings.allowNull === false || newSettings.null === false)) changed = true; + return changed; + }; +}; + +function modifyDatatypeInActual(model, propName) { + var self = this; + var sqlCommand = 'ALTER COLUMN "' + propName + '" TYPE ' + datatype(self._models[model].properties[propName]); + return sqlCommand; +}; + +function modifyNullabilityInActual(model, propName) { + var self = this; + var sqlCommand = 'ALTER COLUMN "' + propName + '" '; + if(propertyCanBeNull.call(self, model, propName)){ + sqlCommand = sqlCommand + "DROP "; + } else { + sqlCommand = sqlCommand + "SET "; + } + sqlCommand = sqlCommand + "NOT NULL"; + return sqlCommand; +}; + +function getColumnsToDrop(model, actualFields){ + var self = this; + var sql = []; + actualFields.forEach(function (actualField) { + if (actualField.Field === 'id') return; + if (actualFieldNotPresentInModel(actualField, model)) { + sql.push('DROP COLUMN "' + actualField.Field + '"'); + } + }); + return sql; + + function actualFieldNotPresentInModel(actualField, model){ + return !(self._models[model].properties[actualField.Field]); + }; +}; + +function applySqlChanges(model, pendingChanges, done){ + var self = this; + if (pendingChanges.length) { + var thisQuery = 'ALTER TABLE ' + self.tableEscaped(model); + var ranOnce = false; + pendingChanges.forEach(function(change){ + if(ranOnce) thisQuery = thisQuery + ','; + thisQuery = thisQuery + ' ' + change; + ranOnce = true; + }); + thisQuery = thisQuery + ';'; + self.query(thisQuery, callback); + } + + function callback(err, data){ + if(err) console.log(err); + } + + done(); +}; + +PG.prototype.propertiesSQL = function (model) { + var self = this; + var sql = ['"id" SERIAL PRIMARY KEY']; + Object.keys(this._models[model].properties).forEach(function (prop) { + if (prop === 'id') return; + sql.push('"' + prop + '" ' + self.propertySettingsSQL(model, prop)); + }); + return sql.join(',\n '); + +}; + +PG.prototype.propertySettingsSQL = function (model, propName) { + var self = this; + var p = self._models[model].properties[propName]; + var result = datatype(p) + ' '; + if(!propertyCanBeNull.call(self, model, propName)) result = result + 'NOT NULL '; + return result; +}; + +function propertyCanBeNull(model, propName){ + var p = this._models[model].properties[propName]; + return !(p.allowNull === false || p['null'] === false); +}; + +function escape(val) { + if (val === undefined || val === null) { + return 'NULL'; + } + + switch (typeof val) { + case 'boolean': return (val) ? 'true' : 'false'; + case 'number': return val+''; + } + + if (typeof val === 'object') { + val = (typeof val.toISOString === 'function') + ? val.toISOString() + : val.toString(); + } + + val = val.replace(/[\0\n\r\b\t\\\'\"\x1a]/g, function(s) { + switch(s) { + case "\0": return "\\0"; + case "\n": return "\\n"; + case "\r": return "\\r"; + case "\b": return "\\b"; + case "\t": return "\\t"; + case "\x1a": return "\\Z"; + default: return "\\"+s; + } + }); + return "E'"+val+"'"; +}; + +function datatype(p) { + switch (p.type.name) { + default: + case 'String': + case 'JSON': + return 'varchar'; + case 'Text': + return 'text'; + case 'Number': + return 'integer'; + case 'Date': + return 'timestamp'; + case 'Boolean': + return 'boolean'; + } +}; + +function mapPostgresDatatypes(typeName) { + //TODO there are a lot of synonymous type names that should go here-- this is just what i've run into so far + switch (typeName){ + case 'int4': + return 'integer'; + default: + return typeName; + } +}; + +function propertyHasNotBeenDeleted(model, propName){ + return !!this._models[model].properties[propName]; +}; diff --git a/lib/adapters/redis.js b/lib/adapters/redis.js new file mode 100644 index 0000000..2130ce4 --- /dev/null +++ b/lib/adapters/redis.js @@ -0,0 +1,458 @@ +var safeRequire = require('../utils').safeRequire; + +/** + * Module dependencies + */ +var redis = safeRequire('redis'); + +exports.initialize = function initializeSchema(schema, callback) { + console.log('GOOD NEWS! This redis adapter version is deprecated, use redis2 instead. A lot of improvements, and new indexes incompatible with old (sorry about that): now we only store id and not ModelName:id in indexes. Also dates format in indexes changed to unix timestamp for better sorting and filtering performance'); + if (!redis) return; + + if (schema.settings.url) { + var url = require('url'); + var redisUrl = url.parse(schema.settings.url); + var redisAuth = (redisUrl.auth || '').split(':'); + schema.settings.host = redisUrl.hostname; + schema.settings.port = redisUrl.port; + + if (redisAuth.length == 2) { + schema.settings.db = redisAuth[0]; + schema.settings.password = redisAuth[1]; + } + } + + schema.client = redis.createClient( + schema.settings.port, + schema.settings.host, + schema.settings.options + ); + schema.client.auth(schema.settings.password); + var callbackCalled = false; + var database = schema.settings.hasOwnProperty('database') && schema.settings.database; + schema.client.on('connect', function () { + if (!callbackCalled && database === false) { + callbackCalled = true; + callback(); + } else if (database !== false) { + if (callbackCalled) { + return schema.client.select(schema.settings.database); + } else { + callbackCalled = true; + return schema.client.select(schema.settings.database, callback); + } + } + }); + + schema.adapter = new BridgeToRedis(schema.client); +}; + +function BridgeToRedis(client) { + this._models = {}; + this.client = client; + this.indexes = {}; +} + +BridgeToRedis.prototype.define = function (descr) { + var m = descr.model.modelName; + this._models[m] = descr; + this.indexes[m] = {}; + Object.keys(descr.properties).forEach(function (prop) { + if (descr.properties[prop].index) { + this.indexes[m][prop] = descr.properties[prop].type; + } + }.bind(this)); +}; + +BridgeToRedis.prototype.defineForeignKey = function (model, key, cb) { + this.indexes[model][key] = Number; + cb(null, Number); +}; + +BridgeToRedis.prototype.save = function (model, data, callback) { + deleteNulls(data); + var log = this.logger('HMSET ' + model + ':' + data.id + ' ...'); + this.client.hmset(model + ':' + data.id, data, function (err) { + log(); + if (err) return callback(err); + this.updateIndexes(model, data.id, data, callback); + }.bind(this)); +}; + +BridgeToRedis.prototype.updateIndexes = function (model, id, data, callback) { + var i = this.indexes[model]; + var schedule = [['sadd', 's:' + model, id]]; + Object.keys(data).forEach(function (key) { + if (i[key]) { + schedule.push([ + 'sadd', + 'i:' + model + ':' + key + ':' + data[key], + model + ':' + id + ]); + } + }.bind(this)); + + if (schedule.length) { + this.client.multi(schedule).exec(function (err) { + callback(err, data); + }); + } else { + callback(null); + } +}; + +BridgeToRedis.prototype.create = function (model, data, callback) { + if (data.id) return create.call(this, data.id, true); + + var log = this.logger('INCR id:' + model); + this.client.incr('id:' + model, function (err, id) { + log(); + create.call(this, id); + }.bind(this)); + + function create(id, upsert) { + data.id = id; + this.save(model, data, function (err) { + if (callback) { + callback(err, id); + } + }); + + // push the id to the list of user ids for sorting + log('SADD s:' + model + ' ' + data.id); + this.client.sadd("s:" + model, upsert ? data : data.id); + } +}; + +BridgeToRedis.prototype.updateOrCreate = function (model, data, callback) { + if (!data.id) return this.create(model, data, callback); + this.save(model, data, callback); +}; + +BridgeToRedis.prototype.exists = function (model, id, callback) { + var log = this.logger('EXISTS ' + model + ':' + id); + this.client.exists(model + ':' + id, function (err, exists) { + log(); + if (callback) { + callback(err, exists); + } + }); +}; + +BridgeToRedis.prototype.find = function find(model, id, callback) { + var t1 = Date.now(); + this.client.hgetall(model + ':' + id, function (err, data) { + this.log('HGETALL ' + model + ':' + id, t1); + if (data && Object.keys(data).length > 0) { + data.id = id; + } else { + data = null; + } + callback(err, data); + }.bind(this)); +}; + +BridgeToRedis.prototype.destroy = function destroy(model, id, callback) { + var t1 = Date.now(); + this.client.del(model + ':' + id, function (err) { + this.log('DEL ' + model + ':' + id, t1); + callback(err); + }.bind(this)); + this.log('SREM s:' + model, t1); + this.client.srem("s:" + model, id); +}; + +BridgeToRedis.prototype.possibleIndexes = function (model, filter) { + if (!filter || Object.keys(filter.where || {}).length === 0) return false; + + var foundIndex = []; + var noIndex = []; + Object.keys(filter.where).forEach(function (key) { + if (this.indexes[model][key] && (typeof filter.where[key] === 'string' || typeof filter.where[key] === 'number')) { + foundIndex.push('i:' + model + ':' + key + ':' + filter.where[key]); + } else { + noIndex.push(key); + } + }.bind(this)); + + return [foundIndex, noIndex]; +}; + +BridgeToRedis.prototype.all = function all(model, filter, callback) { + var ts = Date.now(); + var client = this.client; + var log = this.log; + var t1 = Date.now(); + var cmd; + var that = this; + var sortCmd = []; + var props = this._models[model].properties; + var allNumeric = true; + + // TODO: we need strict mode when filtration only possible when we have indexes + // WHERE + if (filter && filter.where) { + var pi = this.possibleIndexes(model, filter); + var indexes = pi[0]; + var noIndexes = pi[1]; + + if (indexes && indexes.length) { + cmd = 'SINTER "' + indexes.join('" "') + '"'; + if (noIndexes.length) { + log(model + ': no indexes found for ', noIndexes.join(', '), + 'slow sorting and filtering'); + } + indexes.push(noIndexes.length ? orderLimitStageBad : orderLimitStage); + client.sinter.apply(client, indexes); + } else { + // filter manually + cmd = 'KEYS ' + model + ':*'; + client.keys(model + ':*', orderLimitStageBad); + } + } else { + // no filtering, just sort/limit (if any) + gotKeys('*'); + } + + // bad case when we trying to filter on non-indexed fields + // in bad case we need retrieve all data and filter/limit/sort manually + function orderLimitStageBad(err, keys) { + log(cmd, t1); + var t2 = Date.now(); + if (err) { + return callback(err, []); + } + var query = keys.map(function (key) { + return ['hgetall', key]; + }); + client.multi(query).exec(function (err, replies) { + log(query, t2); + gotFilteredData(err, replies.filter(applyFilter(filter))); + }); + + function gotFilteredData(err, nodes) { + if (err) return callback(null); + + if (filter.order) { + var allNumeric = true; + var orders = filter.order; + if (typeof filter.order === "string") { + orders = [filter.order]; + } + orders.forEach(function (key) { + key = key.split(' ')[0]; + if (props[key].type.name !== 'Number' && props[key].type.name !== 'Date') { + allNumeric = false; + } + }); + if (allNumeric) { + nodes = nodes.sort(numerically.bind(orders)); + } else { + nodes = nodes.sort(literally.bind(orders)); + } + } + + // LIMIT + if (filter && filter.limit) { + var from = (filter.offset || 0), to = from + filter.limit; + callback(null, nodes.slice(from, to)); + } else { + callback(null, nodes); + } + } + } + + function orderLimitStage(err, keys) { + log(cmd, t1); + var t2 = Date.now(); + if (err) { + return callback(err, []); + } + + gotKeys(keys); + } + + function gotKeys(keys) { + + // ORDER + var reverse = false; + if (filter && filter.order) { + var orders = filter.order; + if (typeof filter.order === "string"){ + orders = [filter.order]; + } + orders.forEach(function (key) { + var m = key.match(/\s+(A|DE)SC$/i); + if (m) { + key = key.replace(/\s+(A|DE)SC/i, ''); + if (m[1] === 'DE') reverse = true; + } + if (key !== 'id') { + if (props[key].type.name !== 'Number' && props[key].type.name !== 'Date') { + allNumeric = false; + } + } + sortCmd.push("BY", model + ":*->" + key); + }); + } + + // LIMIT + if (keys === '*' && filter && filter.limit){ + var from = (filter.offset || 0), to = from + filter.limit; + sortCmd.push("LIMIT", from, to); + } + + // we need ALPHA modifier when sorting string values + // the only case it's not required - we sort numbers + // TODO: check if we sort numbers + if (!allNumeric) { + sortCmd.push('ALPHA'); + } + + if (reverse) { + sortCmd.push('DESC'); + } + + if (sortCmd.length) { + sortCmd.unshift("s:" + model); + sortCmd.push("GET", "#"); + cmd = "SORT " + sortCmd.join(" "); + var ttt = Date.now(); + sortCmd.push(function(err, ids){ + if (err) { + return callback(err, []); + } + log(cmd, ttt); + var sortedKeys = ids.map(function (i) { + return model + ":" + i; + }); + handleKeys(err, intersect(sortedKeys, keys)); + }); + client.sort.apply(client, sortCmd); + } else { + // no sorting or filtering: just get all keys + if (keys === '*') { + cmd = 'KEYS ' + model + ':*'; + client.keys(model + ':*', handleKeys); + } else { + handleKeys(null, keys); + } + } + } + + function handleKeys(err, keys) { + var t2 = Date.now(); + var query = keys.map(function (key) { + return ['hgetall', key]; + }); + client.multi(query).exec(function (err, replies) { + log(query, t2); + // console.log('Redis time: %dms', Date.now() - ts); + callback(err, filter ? replies.filter(applyFilter(filter)) : replies); + }); + } + + return; + + function numerically(a, b) { + return a[this[0]] - b[this[0]]; + } + + function literally(a, b) { + return a[this[0]] > b[this[0]]; + } + + // TODO: find better intersection method + function intersect(sortedKeys, filteredKeys) { + if (filteredKeys === '*') return sortedKeys; + var index = {}; + filteredKeys.forEach(function (x) { + index[x] = true; + }); + return sortedKeys.filter(function (x) { + return index[x]; + }); + } +}; + +function applyFilter(filter) { + if (typeof filter.where === 'function') { + return filter.where; + } + var keys = Object.keys(filter.where || {}); + return function (obj) { + var pass = true; + if (!obj) return false; + keys.forEach(function (key) { + if (!test(filter.where[key], obj[key])) { + pass = false; + } + }); + return pass; + }; + + function test(example, value) { + if (typeof value === 'string' && example && example.constructor.name === 'RegExp') { + return value.match(example); + } + // not strict equality + return example == value; + } +} + +BridgeToRedis.prototype.destroyAll = function destroyAll(model, callback) { + var keysQuery = model + ':*'; + var t1 = Date.now(); + this.client.keys(keysQuery, function (err, keys) { + this.log('KEYS ' + keysQuery, t1); + if (err) { + return callback(err, []); + } + var query = keys.map(function (key) { + return ['del', key]; + }); + var t2 = Date.now(); + this.client.multi(query).exec(function (err, replies) { + this.log(query, t2); + this.client.del('s:' + model, function () { + callback(err); + }); + }.bind(this)); + }.bind(this)); +}; + +BridgeToRedis.prototype.count = function count(model, callback, where) { + var keysQuery = model + ':*'; + var t1 = Date.now(); + if (where && Object.keys(where).length) { + this.all(model, {where: where}, function (err, data) { + callback(err, err ? null : data.length); + }); + } else { + this.client.keys(keysQuery, function (err, keys) { + this.log('KEYS ' + keysQuery, t1); + callback(err, err ? null : keys.length); + }.bind(this)); + } +}; + +BridgeToRedis.prototype.updateAttributes = function updateAttrs(model, id, data, cb) { + var t1 = Date.now(); + deleteNulls(data); + this.client.hmset(model + ':' + id, data, function () { + this.log('HMSET ' + model + ':' + id, t1); + this.updateIndexes(model, id, data, cb); + }.bind(this)); +}; + +function deleteNulls(data) { + Object.keys(data).forEach(function (key) { + if (data[key] === null) delete data[key]; + }); +} + +BridgeToRedis.prototype.disconnect = function disconnect() { + this.log('QUIT', Date.now()); + this.client.quit(); +}; + diff --git a/lib/adapters/redis2.js b/lib/adapters/redis2.js new file mode 100644 index 0000000..a6a344a --- /dev/null +++ b/lib/adapters/redis2.js @@ -0,0 +1,553 @@ +var safeRequire = require('../utils').safeRequire; + +/** + * Module dependencies + */ +var redis = safeRequire('redis'); + +exports.initialize = function initializeSchema(schema, callback) { + if (!redis) return; + + if (schema.settings.url) { + var url = require('url'); + var redisUrl = url.parse(schema.settings.url); + var redisAuth = (redisUrl.auth || '').split(':'); + schema.settings.host = redisUrl.hostname; + schema.settings.port = redisUrl.port; + + if (redisAuth.length == 2) { + schema.settings.db = redisAuth[0]; + schema.settings.password = redisAuth[1]; + } + } + + schema.client = redis.createClient( + schema.settings.port, + schema.settings.host, + schema.settings.options + ); + schema.client.auth(schema.settings.password); + var callbackCalled = false; + var database = schema.settings.hasOwnProperty('database') && schema.settings.database; + schema.client.on('connect', function () { + if (!callbackCalled && database === false) { + callbackCalled = true; + callback(); + } else if (database !== false) { + if (callbackCalled) { + return schema.client.select(schema.settings.database); + } else { + callbackCalled = true; + return schema.client.select(schema.settings.database, callback); + } + } + }); + + var clientWrapper = new Client(schema.client); + + schema.adapter = new BridgeToRedis(clientWrapper); + clientWrapper._adapter = schema.adapter; +}; + +function Client(client) { + this._client = client; +} + +var commands = Object.keys(redis.Multi.prototype).filter(function (n) { + return n.match(/^[a-z]/); +}); + +commands.forEach(function (cmd) { + + Client.prototype[cmd] = function (args, callback) { + + var c = this._client, log; + + if (typeof args === 'string') { + args = [args]; + } + + if (!args) args = []; + + log = this._adapter.logger(cmd.toUpperCase() + ' ' + args.map(function (a) { + if (typeof a === 'object') return JSON.stringify(a); + return a; + }).join(' ')); + args.push(function (err, res) { + if (err) console.log(err); + log(); + if (callback) { + callback(err, res); + } + }); + + c[cmd].apply(c, args); + }; +}); + +Client.prototype.multi = function (commands, callback) { + if (commands.length === 0) return callback && callback(); + if (commands.length === 1) { + return this[commands[0].shift().toLowerCase()].call( + this, + commands[0], + callback && function (e, r) { callback(e, [r]) }); + } + var log = this._adapter.logger('MULTI\n ' + commands.map(function (x) { + return x.join(' '); + }).join('\n ') + '\nEXEC'); + this._client.multi(commands).exec(function (err, replies) { + if (err) console.log(err); + log(); + callback && callback(err, replies); + }); +}; + +Client.prototype.transaction = function () { + return new Transaction(this); +}; + +function Transaction(client) { + this._client = client; + this._handlers = []; + this._schedule = []; +} + +Transaction.prototype.run = function (cb) { + var t = this; + var atLeastOneHandler = false; + switch (this._schedule.length) { + case 0: return cb(); + case 1: return this._client[this._schedule[0].shift()].call( + this._client, + this._schedule[0], + this._handlers[0] || cb); + default: + this._client.multi(this._schedule, function (err, replies) { + if (err) return cb(err); + replies.forEach(function (r, i) { + if (t._handlers[i]) { + atLeastOneHandler = true; + t._handlers[i](err, r); + } + }); + if (!atLeastOneHandler) cb(err); + }); + } + +}; + +commands.forEach(function (k) { + + Transaction.prototype[k] = function (args, cb) { + if (typeof args === 'string') { + args = [args]; + } + args.unshift(k); + this._schedule.push(args); + this._handlers.push(cb || false); + }; + +}); + +function BridgeToRedis(client) { + this._models = {}; + this.client = client; + this.indexes = {}; +} + +BridgeToRedis.prototype.define = function (descr) { + var m = descr.model.modelName; + this._models[m] = descr; + this.indexes[m] = {}; + Object.keys(descr.properties).forEach(function (prop) { + if (descr.properties[prop].index) { + this.indexes[m][prop] = descr.properties[prop].type; + } + }.bind(this)); +}; + +BridgeToRedis.prototype.defineForeignKey = function (model, key, cb) { + this.indexes[model][key] = Number; + cb(null, Number); +}; + +BridgeToRedis.prototype.forDb = function (model, data) { + var p = this._models[model].properties; + for (var i in data) { + if (!p[i]) continue; + if (!data[i]) { + data[i] = ""; + continue; + } + switch (p[i].type.name) { + case "Date": + data[i] = data[i].getTime ? data[i].getTime().toString() : "0"; + break; + case "Number": + data[i] = data[i].toString(); + break; + case "Boolean": + data[i] = !!data[i] ? "1" : "0"; + break; + case "String": + case "Text": + break; + default: + data[i] = JSON.stringify(data[i]); + } + } + return data; +}; + +BridgeToRedis.prototype.fromDb = function (model, data) { + var p = this._models[model].properties, d; + for (var i in data) { + if (!p[i]) continue; + if (!data[i]) { + data[i] = ""; + continue; + } + switch (p[i].type.name) { + case "Date": + d = new Date(data[i]); + d.setTime(data[i]); + data[i] = d; + break; + case "Number": + data[i] = Number(data[i]); + break; + case "Boolean": + data[i] = data[i] === "1"; + break; + default: + d = data[i]; + try { + data[i] = JSON.parse(data[i]); + } + catch(e) { + data[i] = d; + } + } + } + return data; +}; + +BridgeToRedis.prototype.save = function (model, data, callback) { + data = this.forDb(model, data); + deleteNulls(data); + this.client.hgetall(model + ':' + data.id, function (err, prevData) { + if (err) return callback(err); + this.client.hmset([model + ':' + data.id, data], function (err) { + if (err) return callback(err); + if (prevData) { + Object.keys(prevData).forEach(function (k) { + if (data.hasOwnProperty(k)) return; + data[k] = prevData[k]; + }); + } + this.updateIndexes(model, data.id, data, callback, this.forDb(model, prevData)); + }.bind(this)); + }.bind(this)); +}; + +BridgeToRedis.prototype.updateIndexes = function (model, id, data, callback, prevData) { + var p = this._models[model].properties; + var i = this.indexes[model]; + var schedule = []; + if (!callback.removed) { + schedule.push(['SADD', 's:' + model, id]); + } + Object.keys(i).forEach(function (key) { + if (data.hasOwnProperty(key)) { + var val = data[key]; + schedule.push([ + 'SADD', + 'i:' + model + ':' + key + ':' + val, + id + ]); + } + if (prevData && prevData[key] !== data[key]) { + schedule.push([ + 'SREM', + 'i:' + model + ':' + key + ':' + prevData[key], + id + ]); + } + }); + + if (schedule.length) { + this.client.multi(schedule, function (err) { + callback(err, data); + }); + } else { + callback(null); + } +}; + +BridgeToRedis.prototype.create = function (model, data, callback) { + if (data.id) return create.call(this, data.id, true); + + this.client.incr('id:' + model, function (err, id) { + create.call(this, id); + }.bind(this)); + + function create(id, upsert) { + data.id = id.toString(); + this.save(model, data, function (err) { + if (callback) { + callback(err, parseInt(id, 10)); + } + }); + + // push the id to the list of user ids for sorting + this.client.sadd(['s:' + model, data.id]); + } +}; + +BridgeToRedis.prototype.updateOrCreate = function (model, data, callback) { + if (!data.id) { + return this.create(model, data, callback); + } + var client = this.client; + this.save(model, data, function (error, obj) { + var key = 'id:' + model; + client.get(key, function (err, id) { + if (!id || data.id > parseInt(id, 10)) { + client.set([key, data.id], ok); + } else { + ok(); + } + }); + function ok() { + callback(error, obj); + } + }); +}; + +BridgeToRedis.prototype.exists = function (model, id, callback) { + this.client.exists(model + ':' + id, function (err, exists) { + if (callback) { + callback(err, exists); + } + }); +}; + +BridgeToRedis.prototype.find = function find(model, id, callback) { + this.client.hgetall(model + ':' + id, function (err, data) { + if (data && Object.keys(data).length > 0) { + data.id = id; + } else { + data = null; + } + callback(err, this.fromDb(model, data)); + }.bind(this)); +}; + +BridgeToRedis.prototype.destroy = function destroy(model, id, callback) { + var br = this; + var tr = br.client.transaction(); + + br.client.hgetall(model + ':' + id, function (err, data) { + if (err) return callback(err); + + tr.srem(['s:' + model, id]); + tr.del(model + ':' + id); + tr.run(function (err) { + if (err) return callback(err); + callback.removed = true; + + br.updateIndexes(model, id, {}, callback, data); + }); + }); +}; + +BridgeToRedis.prototype.possibleIndexes = function (model, filter) { + if (!filter || Object.keys(filter.where || {}).length === 0) return false; + + var foundIndex = []; + var noIndex = []; + Object.keys(filter.where).forEach(function (key) { + var i = this.indexes[model][key]; + if (i) { + var val = filter.where[key]; + if (i.name === 'Date') { + val = val && val.getTime ? val.getTime() : 0; + } + foundIndex.push('i:' + model + ':' + key + ':' + val); + } else { + noIndex.push(key); + } + }.bind(this)); + + return [foundIndex, noIndex]; +}; + +BridgeToRedis.prototype.all = function all(model, filter, callback) { + var ts = Date.now(); + var client = this.client; + var cmd; + var redis = this; + var sortCmd = []; + var props = this._models[model].properties; + var allNumeric = true; + var dest = 'temp' + (Date.now() * Math.random()); + var innerSetUsed = false; + var trans = this.client.transaction(); + + if (!filter) { + filter = {order: 'id'}; + } + + // WHERE + if (filter.where) { + var pi = this.possibleIndexes(model, filter); + var indexes = pi[0]; + var noIndexes = pi[1]; + + if (noIndexes.length) { + throw new Error(model + ': no indexes found for ' + + noIndexes.join(', ') + + ' impossible to sort and filter using redis adapter'); + } + + if (indexes && indexes.length) { + innerSetUsed = true; + if (indexes.length > 1) { + indexes.unshift(dest); + trans.sinterstore(indexes); + } else { + dest = indexes[0]; + } + } else { + throw new Error('No indexes for ' + filter.where); + } + } else { + dest = 's:' + model; + // no filtering, just sort/limit (if any) + } + + // only counting? + if (filter.getCount) { + trans.scard(dest, callback); + return trans.run(); + } + + // ORDER + var reverse = false; + if (!filter.order) { + filter.order = 'id'; + } + var orders = filter.order; + if (typeof filter.order === "string"){ + orders = [filter.order]; + } + + orders.forEach(function (key) { + var m = key.match(/\s+(A|DE)SC$/i); + if (m) { + key = key.replace(/\s+(A|DE)SC/i, ''); + if (m[1] === 'DE') reverse = true; + } + if (key !== 'id') { + if (props[key].type.name !== 'Number' && props[key].type.name !== 'Date') { + allNumeric = false; + } + } + sortCmd.push("BY", model + ":*->" + key); + }); + + // LIMIT + if (filter.limit){ + var offset = (filter.offset || 0), limit = filter.limit; + sortCmd.push("LIMIT", offset, limit); + } + + // we need ALPHA modifier when sorting string values + // the only case it's not required - we sort numbers + if (!allNumeric) { + sortCmd.push('ALPHA'); + } + + if (reverse) { + sortCmd.push('DESC'); + } + + sortCmd.unshift(dest); + sortCmd.push("GET", "#"); + cmd = "SORT " + sortCmd.join(" "); + var ttt = Date.now(); + trans.sort(sortCmd, function (err, ids){ + if (err) { + return callback(err, []); + } + var sortedKeys = ids.map(function (i) { + return model + ":" + i; + }); + handleKeys(err, sortedKeys); + }); + + if (dest.match(/^temp/)) { + trans.del(dest); + } + + trans.run(callback); + + function handleKeys(err, keys) { + var t2 = Date.now(); + var query = keys.map(function (key) { + return ['hgetall', key]; + }); + client.multi(query, function (err, replies) { + callback(err, (replies || []).map(function (r) { + return redis.fromDb(model, r); + })); + }); + } + + return; + + function numerically(a, b) { + return a[this[0]] - b[this[0]]; + } + + function literally(a, b) { + return a[this[0]] > b[this[0]]; + } + +}; + +BridgeToRedis.prototype.destroyAll = function destroyAll(model, callback) { + var br = this; + this.client.multi([ + ['KEYS', model + ':*'], + ['KEYS', '*:' + model + ':*'] + ], function (err, k) { + br.client.del(k[0].concat(k[1]).concat('s:' + model), callback); + }); + +}; + +BridgeToRedis.prototype.count = function count(model, callback, where) { + if (where && Object.keys(where).length) { + this.all(model, {where: where, getCount: true}, callback); + } else { + this.client.scard('s:' + model, callback); + } +}; + +BridgeToRedis.prototype.updateAttributes = function updateAttrs(model, id, data, cb) { + data.id = id; + this.save(model, data, cb); +}; + +function deleteNulls(data) { + Object.keys(data).forEach(function (key) { + if (data[key] === null) delete data[key]; + }); +} + +BridgeToRedis.prototype.disconnect = function disconnect() { + this.client.quit(); +}; + diff --git a/lib/adapters/riak.js b/lib/adapters/riak.js new file mode 100644 index 0000000..b6fc285 --- /dev/null +++ b/lib/adapters/riak.js @@ -0,0 +1,110 @@ +var safeRequire = require('../utils').safeRequire; + +/** + * Module dependencies + */ +var uuid = require('node-uuid'); +var riak = safeRequire('riak-js'); + +exports.initialize = function initializeSchema(schema, callback) { + schema.client = riak.getClient({ + host: schema.settings.host || '127.0.0.1', + port: schema.settings.port || 8091 + }); + schema.adapter = new Riak(schema.client); +}; + +function Riak(client) { + this._models = {}; + this.client = client; +} + +Riak.prototype.define = function (descr) { + this._models[descr.model.modelName] = descr; +}; + +Riak.prototype.save = function (model, data, callback) { + this.client.save(model, data.id, data, callback); +}; + +Riak.prototype.create = function (model, data, callback) { + data.id = uuid(); + this.save(model, data, function (err) { + if (callback) { + callback(err, data.id); + } + }); +}; + +Riak.prototype.exists = function (model, id, callback) { + this.client.exists(model, id, function (err, exists, meta) { + if (callback) { + callback(err, exists); + } + }); +}; + +Riak.prototype.find = function find(model, id, callback) { + this.client.get(model, id, function (err, data, meta) { + if (data && data.id) { + data.id = id; + } else { + data = null; + } + if (typeof callback === 'function') callback(err, data); + }); +}; + +Riak.prototype.destroy = function destroy(model, id, callback) { + this.client.remove(model, id, function (err) { + callback(err); + }); +}; + +Riak.prototype.all = function all(model, filter, callback) { + var opts = {}; + if (filter && filter.where) opts.where = filter.where; + this.client.getAll(model, function (err, result, meta) { + if (err) return callback(err, []); + /// return callback(err, result.map(function (x) { return {id: x}; })); + result = (result || []).map(function (row) { + var record = row.data; + record.id = row.meta.key; + console.log(record); + return record; + }); + + return callback(err, result); + }.bind(this)); +}; + +Riak.prototype.destroyAll = function destroyAll(model, callback) { + var self = this; + this.all(model, {}, function (err, recs) { + if (err) callback(err); + + removeOne(); + + function removeOne(error) { + err = err || error; + var rec = recs.pop(); + if (!rec) return callback(err && err.statusCode != '404' ? err : null); + console.log(rec.id); + self.client.remove(model, rec.id, removeOne); + } + + }); + +}; + +Riak.prototype.count = function count(model, callback) { + this.client.keys(model + ':*', function (err, keys) { + callback(err, err ? null : keys.length); + }); +}; + +Riak.prototype.updateAttributes = function updateAttrs(model, id, data, cb) { + data.id = id; + this.save(model, data, cb); +}; + diff --git a/lib/adapters/sqlite3.js b/lib/adapters/sqlite3.js new file mode 100644 index 0000000..58d5a30 --- /dev/null +++ b/lib/adapters/sqlite3.js @@ -0,0 +1,409 @@ +var safeRequire = require('../utils').safeRequire; + +/** + * Module dependencies + */ +var sqlite3 = safeRequire('sqlite3'); +var BaseSQL = require('../sql'); + +exports.initialize = function initializeSchema(schema, callback) { + if (!sqlite3) return; + var s = schema.settings; + var Database = sqlite3.verbose().Database; + var db = new Database(s.database); + + schema.client = db; + + schema.adapter = new SQLite3(schema.client); + if (s.database === ':memory:') { + schema.adapter.automigrate(callback); + } else { + process.nextTick(callback); + } +}; + +function SQLite3(client) { + this._models = {}; + this.client = client; +} + +require('util').inherits(SQLite3, BaseSQL); + +SQLite3.prototype.command = function () { + this.query('run', [].slice.call(arguments)); +}; + +SQLite3.prototype.queryAll = function () { + this.query('all', [].slice.call(arguments)); +}; + +SQLite3.prototype.queryOne = function () { + this.query('get', [].slice.call(arguments)); +}; + +SQLite3.prototype.query = function (method, args) { + var time = Date.now(); + var log = this.log; + var cb = args.pop(); + if (typeof cb === 'function') { + args.push(function (err, data) { + if (log) log(args[0], time); + cb.call(this, err, data); + }); + } else { + args.push(cb); + args.push(function (err, data) { + log(args[0], time); + }); + } + this.client[method].apply(this.client, args); +}; + +SQLite3.prototype.save = function (model, data, callback) { + var queryParams = []; + var sql = 'UPDATE ' + this.tableEscaped(model) + ' SET ' + + Object.keys(data).map(function (key) { + queryParams.push(data[key]); + return key + ' = ?'; + }).join(', ') + ' WHERE id = ' + data.id; + + this.command(sql, queryParams, function (err) { + callback(err); + }); +}; + +/** + * Must invoke callback(err, id) + */ +SQLite3.prototype.create = function (model, data, callback) { + data = data || {}; + var questions = []; + var values = Object.keys(data).map(function (key) { + questions.push('?'); + return data[key]; + }); + var sql = 'INSERT INTO ' + this.tableEscaped(model) + ' (' + Object.keys(data).join(',') + ') VALUES (' + sql += questions.join(','); + sql += ')'; + this.command(sql, values, function (err) { + callback(err, this && this.lastID); + }); +}; + +SQLite3.prototype.updateOrCreate = function (model, data, callback) { + data = data || {}; + var questions = []; + var values = Object.keys(data).map(function (key) { + questions.push('?'); + return data[key]; + }); + var sql = 'INSERT OR REPLACE INTO ' + this.tableEscaped(model) + ' (' + Object.keys(data).join(',') + ') VALUES (' + sql += questions.join(','); + sql += ')'; + this.command(sql, values, function (err) { + if (!err && this) { + data.id = this.lastID; + } + callback(err, data); + }); +}; + +SQLite3.prototype.toFields = function (model, data) { + var fields = []; + var props = this._models[model].properties; + Object.keys(data).forEach(function (key) { + if (props[key]) { + fields.push('`' + key.replace(/\./g, '`.`') + '` = ' + this.toDatabase(props[key], data[key])); + } + }.bind(this)); + return fields.join(','); +}; + +function dateToMysql(val) { + return val.getUTCFullYear() + '-' + + fillZeros(val.getUTCMonth() + 1) + '-' + + fillZeros(val.getUTCDate()) + ' ' + + fillZeros(val.getUTCHours()) + ':' + + fillZeros(val.getUTCMinutes()) + ':' + + fillZeros(val.getUTCSeconds()); + + function fillZeros(v) { + return v < 10 ? '0' + v : v; + } +} + +SQLite3.prototype.toDatabase = function (prop, val) { + if (!prop) return val; + if (prop.type.name === 'Number') return val; + if (val === null) return 'NULL'; + if (prop.type.name === 'Date') { + if (!val) return 'NULL'; + if (!val.toUTCString) { + val = new Date(val); + } + return val; + } + if (prop.type.name == "Boolean") return val ? 1 : 0; + return val.toString(); +}; + +SQLite3.prototype.fromDatabase = function (model, data) { + if (!data) return null; + var props = this._models[model].properties; + Object.keys(data).forEach(function (key) { + var val = data[key]; + if (props[key]) { + if (props[key].type.name === 'Date') { + val = new Date(parseInt(val)); + } + } + data[key] = val; + }); + return data; +}; + +SQLite3.prototype.escapeName = function (name) { + return '`' + name + '`'; +}; + +SQLite3.prototype.exists = function (model, id, callback) { + var sql = 'SELECT 1 FROM ' + this.tableEscaped(model) + ' WHERE id = ' + id + ' LIMIT 1'; + this.queryOne(sql, function (err, data) { + if (err) return callback(err); + callback(null, data && data['1'] === 1); + }); +}; + +SQLite3.prototype.find = function find(model, id, callback) { + var sql = 'SELECT * FROM ' + this.tableEscaped(model) + ' WHERE id = ' + id + ' LIMIT 1'; + this.queryOne(sql, function (err, data) { + if (data) { + data.id = id; + } else { + data = null; + } + callback(err, this.fromDatabase(model, data)); + }.bind(this)); +}; + +SQLite3.prototype.all = function all(model, filter, callback) { + + var sql = 'SELECT * FROM ' + this.tableEscaped(model); + var self = this; + var props = this._models[model].properties; + var queryParams = []; + + if (filter) { + + if (filter.where) { + sql += ' ' + buildWhere(filter.where); + } + + if (filter.order) { + sql += ' ' + buildOrderBy(filter.order); + } + + if (filter.limit) { + sql += ' ' + buildLimit(filter.limit, filter.offset || 0); + } + + } + + this.queryAll(sql, queryParams, function (err, data) { + if (err) { + return callback(err, []); + } + callback(null, data.map(function (obj) { + return self.fromDatabase(model, obj); + })); + }.bind(this)); + + return sql; + + function buildWhere(conds) { + var cs = []; + Object.keys(conds).forEach(function (key) { + var keyEscaped = '`' + key.replace(/\./g, '`.`') + '`' + if (conds[key] === null) { + cs.push(keyEscaped + ' IS NULL'); + } else if (conds[key].constructor.name === 'Object') { + var condType = Object.keys(conds[key])[0]; + var sqlCond = keyEscaped; + switch (condType) { + case 'gt': + sqlCond += ' > '; + break; + case 'gte': + sqlCond += ' >= '; + break; + case 'lt': + sqlCond += ' < '; + break; + case 'lte': + sqlCond += ' <= '; + break; + case 'between': + sqlCond += ' BETWEEN ? AND ?'; + queryParams.push(conds[key][condType][0]); + queryParams.push(conds[key][condType][1]); + break; + } + if (condType !== 'between') { + sqlCond += '?'; + queryParams.push(conds[key][condType]); + } + cs.push(sqlCond); + } else { + cs.push(keyEscaped + ' = ?'); + queryParams.push(self.toDatabase(props[key], conds[key])); + } + }); + return 'WHERE ' + cs.join(' AND '); + } + + function buildOrderBy(order) { + if (typeof order === 'string') order = [order]; + return 'ORDER BY ' + order.join(', '); + } + + function buildLimit(limit, offset) { + return 'LIMIT ' + (offset ? (offset + ', ' + limit) : limit); + } + +}; + +SQLite3.prototype.count = function count(model, callback, where) { + var self = this; + var props = this._models[model].properties; + var queryParams = []; + + this.queryOne('SELECT count(*) as cnt FROM ' + + this.tableEscaped(model) + ' ' + buildWhere(where), queryParams, function (err, res) { + if (err) return callback(err); + callback(err, err ? null : res.cnt); + }); + + function buildWhere(conds) { + var cs = []; + Object.keys(conds || {}).forEach(function (key) { + var keyEscaped = '`' + key.replace(/\./g, '`.`') + '`' + if (conds[key] === null) { + cs.push(keyEscaped + ' IS NULL'); + } else { + cs.push(keyEscaped + ' = ?'); + queryParams.push(self.toDatabase(props[key], conds[key])); + } + }); + return cs.length ? ' WHERE ' + cs.join(' AND ') : ''; + } +}; + +SQLite3.prototype.disconnect = function disconnect() { + this.client.close(); +}; + +SQLite3.prototype.autoupdate = function (cb) { + var self = this; + var wait = 0; + Object.keys(this._models).forEach(function (model) { + wait += 1; + self.queryAll('SHOW FIELDS FROM ' + this.tableEscaped(model), function (err, fields) { + self.alterTable(model, fields, done); + }); + }); + + function done(err) { + if (err) { + console.log(err); + } + if (--wait === 0 && cb) { + cb(); + } + } +}; + +SQLite3.prototype.alterTable = function (model, actualFields, done) { + var self = this; + var m = this._models[model]; + var propNames = Object.keys(m.properties); + var sql = []; + + // change/add new fields + propNames.forEach(function (propName) { + if (propName === 'id') return; + var found; + actualFields.forEach(function (f) { + if (f.Field === propName) { + found = f; + } + }); + + if (found) { + actualize(propName, found); + } else { + sql.push('ADD COLUMN `' + propName + '` ' + self.propertySettingsSQL(model, propName)); + } + }); + + // drop columns + actualFields.forEach(function (f) { + var notFound = !~propNames.indexOf(f.Field); + if (f.Field === 'id') return; + if (notFound || !m.properties[f.Field]) { + sql.push('DROP COLUMN `' + f.Field + '`'); + } + }); + + if (sql.length) { + this.command('ALTER TABLE ' + this.tableEscaped(model) + ' ' + sql.join(',\n'), done); + } else { + done(); + } + + function actualize(propName, oldSettings) { + var newSettings = m.properties[propName]; + if (newSettings && changed(newSettings, oldSettings)) { + sql.push('CHANGE COLUMN `' + propName + '` `' + propName + '` ' + self.propertySettingsSQL(model, propName)); + } + } + + function changed(newSettings, oldSettings) { + if (oldSettings.Null === 'YES' && (newSettings.allowNull === false || newSettings.null === false)) return true; + if (oldSettings.Null === 'NO' && !(newSettings.allowNull === false || newSettings.null === false)) return true; + if (oldSettings.Type.toUpperCase() !== datatype(newSettings)) return true; + return false; + } +}; + +SQLite3.prototype.propertiesSQL = function (model) { + var self = this; + var sql = ['`id` INTEGER PRIMARY KEY']; + Object.keys(this._models[model].properties).forEach(function (prop) { + if (prop === 'id') return; + sql.push('`' + prop + '` ' + self.propertySettingsSQL(model, prop)); + }); + return sql.join(',\n '); + +}; + +SQLite3.prototype.propertySettingsSQL = function (model, prop) { + var p = this._models[model].properties[prop]; + return datatype(p) + ' ' + + (p.allowNull === false || p['null'] === false ? 'NOT NULL' : 'NULL'); +}; + +function datatype(p) { + switch (p.type.name) { + case 'String': + return 'VARCHAR(' + (p.limit || 255) + ')'; + case 'Text': + return 'TEXT'; + case 'Number': + return 'INT(11)'; + case 'Date': + return 'DATETIME'; + case 'Boolean': + return 'TINYINT(1)'; + } +} + diff --git a/lib/hookable.js b/lib/hookable.js new file mode 100644 index 0000000..bc5faa1 --- /dev/null +++ b/lib/hookable.js @@ -0,0 +1,51 @@ +exports.Hookable = Hookable; + +function Hookable() { + // hookable class +}; + +Hookable.afterInitialize = null; +Hookable.beforeValidation = null; +Hookable.afterValidation = null; +Hookable.beforeSave = null; +Hookable.afterSave = null; +Hookable.beforeCreate = null; +Hookable.afterCreate = null; +Hookable.beforeUpdate = null; +Hookable.afterUpdate = null; +Hookable.beforeDestroy = null; +Hookable.afterDestroy = null; + +Hookable.prototype.trigger = function trigger(actionName, work, data) { + var capitalizedName = capitalize(actionName); + var afterHook = this.constructor["after" + capitalizedName]; + var beforeHook = this.constructor["before" + capitalizedName]; + var inst = this; + + // we only call "before" hook when we have actual action (work) to perform + if (work) { + if (beforeHook) { + // before hook should be called on instance with one param: callback + beforeHook.call(inst, function () { + // actual action also have one param: callback + work.call(inst, next); + }, data); + } else { + work.call(inst, next); + } + } else { + next(); + } + + function next(done) { + if (afterHook) { + afterHook.call(inst, done); + } else if (done) { + done.call(this); + } + } +}; + +function capitalize(string) { + return string.charAt(0).toUpperCase() + string.slice(1); +} diff --git a/lib/jutil.js b/lib/jutil.js new file mode 100644 index 0000000..61585de --- /dev/null +++ b/lib/jutil.js @@ -0,0 +1,9 @@ +exports.inherits = function (newClass, baseClass) { + Object.keys(baseClass).forEach(function (classMethod) { + newClass[classMethod] = baseClass[classMethod]; + }); + Object.keys(baseClass.prototype).forEach(function (instanceMethod) { + newClass.prototype[instanceMethod] = baseClass.prototype[instanceMethod]; + }); +}; + diff --git a/lib/list.js b/lib/list.js new file mode 100644 index 0000000..c6c95b2 --- /dev/null +++ b/lib/list.js @@ -0,0 +1,186 @@ + +module.exports = List; + +function List(data, type, parent) { + var list = this; + + Object.defineProperty(list, 'parent', { + writable: false, + enumerable: false, + configurable: false, + value: parent + }); + + Object.defineProperty(list, 'nextid', { + writable: true, + enumerable: false, + value: 1 + }); + + data = list.items = data || []; + var Item = list.ItemType = ListItem; + + if (typeof type === 'object' && type.constructor.name === 'Array') { + list.ItemType = type[0] || ListItem; + } + + data.forEach(function (item, i) { + data[i] = new Item(item, list); + Object.defineProperty(list, data[i].id, { + writable: true, + enumerable: false, + configurable: true, + value: data[i] + }); + if (list.nextid <= data[i].id) { + list.nextid = data[i].id + 1; + } + }); + + Object.defineProperty(list, 'length', { + enumerable: false, + configurable: true, + get: function () { + return list.items.length; + } + }); + + return list; + +} + +var _; +try { + _ = require('underscore'); +} catch (e) { + _ = false; +} + +if (_) { + var _import = [ + // collection methods + 'each', + 'map', + 'reduce', + 'reduceRight', + 'find', + 'filter', + 'reject', + 'all', + 'any', + 'include', + 'invoke', + 'pluck', + 'max', + 'min', + 'sortBy', + 'groupBy', + 'sortedIndex', + 'shuffle', + 'toArray', + 'size', + // array methods + 'first', + 'initial', + 'last', + 'rest', + 'compact', + 'flatten', + 'without', + 'union', + 'intersection', + 'difference', + 'uniq', + 'zip', + 'indexOf', + 'lastIndexOf', + 'range' + ]; + + _import.forEach(function (name) { + List.prototype[name] = function () { + var args = [].slice.call(arguments); + args.unshift(this.items); + return _[name].apply(_, args); + }; + }); +} + +List.prototype.toObject = function () { + return this.items; +}; + +List.prototype.toJSON = function () { + return this.items; +}; + +List.prototype.toString = function () { + return JSON.stringify(this.items); +}; + +List.prototype.autoincrement = function () { + return this.nextid++; +}; + +List.prototype.push = function (obj) { + var item = new ListItem(obj, this); + this.items.push(item); + return item; +}; + +List.prototype.remove = function (obj) { + var id = obj.id ? obj.id : obj; + var found = false; + this.items.forEach(function (o, i) { + if (id && o.id == id) { + found = i; + if (o.id !== id) { + console.log('WARNING! Type of id not matched'); + } + } + }); + if (found !== false) { + delete this[id]; + this.items.splice(found, 1); + } +}; + +List.prototype.forEach = function (cb) { + this.items.forEach(cb); +}; + +List.prototype.sort = function (cb) { + return this.items.sort(cb); +}; + +List.prototype.map = function (cb) { + if (typeof cb === 'function') return this.items.map(cb); + if (typeof cb === 'string') return this.items.map(function (el) { + if (typeof el[cb] === 'function') return el[cb](); + if (el.hasOwnProperty(cb)) return el[cb]; + }); +}; + +function ListItem(data, parent) { + for (var i in data) this[i] = data[i]; + Object.defineProperty(this, 'parent', { + writable: false, + enumerable: false, + configurable: true, + value: parent + }); + if (!this.id) { + this.id = parent.autoincrement(); + } + if (parent.ItemType) { + this.__proto__ = parent.ItemType.prototype; + if (parent.ItemType !== ListItem) { + parent.ItemType.apply(this); + } + } + + this.save = function (c) { + parent.parent.save(c); + }; +} + diff --git a/lib/railway.js b/lib/railway.js new file mode 100644 index 0000000..44d5e7a --- /dev/null +++ b/lib/railway.js @@ -0,0 +1,215 @@ +var fs = require('fs'); +var path = require('path'); +var Schema = require('./schema').Schema; + +var existsSync = fs.existsSync || path.existsSync; + +if (global.railway) { + railway.orm._schemas = []; +} + +module.exports = function init(root) { + var railway, app, models; + + if (typeof root !== 'object' || root.constructor.name !== 'Railway') { + railway = global.railway; + app = global.app; + models = app.models; + } else { + railway = root; + app = railway.app; + root = railway.root; + models = railway.models; + } + + railway.orm._schemas = []; + + var confFile = (root || app.root) + '/config/database'; + var config; + + if (existsSync(confFile + '.json')) { + try { + config = JSON.parse(fs.readFileSync(confFile + '.json', 'utf-8'))[app.set('env')]; + } catch (e) { + console.log('Could not parse config/database.json'); + throw e; + } + } else if (existsSync(confFile + '.yml')) { + try { + config = railway.utils.readYaml(confFile + '.yml')[app.set('env')]; + } catch (e) { + console.log('Could not parse config/database.yml'); + throw e; + } + config = {}; + } + // when driver name started with point - look for driver in app root (relative path) + if (config.driver && config.driver.match(/^\./)) { + config.driver = path.join(app.root, config.driver); + } + + var schema = new Schema(config && config.driver || 'memory', config); + schema.log = log; + railway.orm._schemas.push(schema); + +var context = prepareContext(models, railway, app, schema); + +// run schema first +var schemaFile = (root || app.root) + '/db/schema.'; +if (existsSync(schemaFile + 'js')) { + schemaFile += 'js'; +} else if (existsSync(schemaFile + 'coffee')) { + schemaFile += 'coffee'; +} else { + schemaFile = false; +} + +if (schemaFile) { + var code = fs.readFileSync(schemaFile).toString(); + if (schemaFile.match(/\.coffee$/)) { + code = require('coffee-script').compile(code); + } + var fn = new Function('context', 'require', 'with(context){(function(){' + code + '})()}'); + fn(context, require); +} + +// and freeze schemas +railway.orm._schemas.forEach(function (schema) { + schema.freeze(); +}); + +// check validations and display warning + +var displayWarning = false; +Object.keys(models).forEach(function (model) { + var Model = models[model]; + if (Model._validations) { + displayWarning = true; + } +}); + +if (displayWarning) { + var $ = railway.utils.stylize.$; + // require('util').puts($('WARNING:').bold.red + ' ' + $('I can see that you\'ve added validation to db/schema.js. However schema.js file is only used to describe database schema. Therefore validations configured in db/schema.js will be ignored.\nFor business logic (incl. validations) please create models as separate .js files here: app/models/*.js').yellow); +} + +function log(str, startTime) { + var $ = railway.utils.stylize.$; + var m = Date.now() - startTime; + railway.utils.debug(str + $(' [' + (m < 10 ? m : $(m).red) + ' ms]').bold); + app.emit('app-event', { + type: 'query', + param: str, + time: m + }); +} + +function prepareContext(models, railway, app, defSchema, done) { + var ctx = {app: app}, + _models = {}, + settings = {}, + cname, + schema, + wait = connected = 0, + nonJugglingSchema = false; + + done = done || function () {}; + + /** + * Multiple schemas support + * example: + * schema('redis', {url:'...'}, function () { + * describe models using redis connection + * ... + * }); + * schema(function () { + * describe models stored in memory + * ... + * }); + */ + ctx.schema = function () { + var name = argument('string'); + var opts = argument('object') || {}; + var def = argument('function') || function () {}; + schema = new Schema(name || opts.driver || 'memory', opts); + railway.orm._schemas.push(schema); + wait += 1; + ctx.gotSchema = true; + schema.on('log', log); + schema.on('connected', function () { + if (wait === ++connected) done(); + }); + def(); + schema = false; + }; + + /** + * Use custom schema driver + */ + ctx.customSchema = function () { + var def = argument('function') || function () {}; + nonJugglingSchema = true; + def(); + Object.keys(ctx.exports).forEach(function (m) { + ctx.define(m, ctx.exports[m]); + }); + nonJugglingSchema = false; + }; + ctx.exports = {}; + ctx.module = { exports: ctx.exports }; + + /** + * Define a class in current schema + */ + ctx.describe = ctx.define = function (className, callback) { + var m; + cname = className; + _models[cname] = {}; + settings[cname] = {}; + if (nonJugglingSchema) { + m = callback; + } else { + callback && callback(); + m = (schema || defSchema).define(className, _models[cname], settings[cname]); + } + if (global.railway) { + global[cname] = m; + } + return models[cname] = ctx[cname] = m; + }; + + /** + * Define a property in current class + */ + ctx.property = function (name, type, params) { + if (!params) params = {}; + if (typeof type !== 'function' && typeof type === 'object') { + params = type; + type = String; + } + params.type = type || String; + _models[cname][name] = params; + }; + + /** + * Set custom table name for current class + * @param name - name of table + */ + ctx.setTableName = function (name) { + if (cname) settings[cname].table = name; + }; + + ctx.Text = Schema.Text; + + return ctx; + + function argument(type) { + var r; + [].forEach.call(arguments.callee.caller.arguments, function (a) { + if (!r && typeof a === type) r = a; + }); + return r; + } +} + +}; diff --git a/lib/schema.js b/lib/schema.js new file mode 100644 index 0000000..c146421 --- /dev/null +++ b/lib/schema.js @@ -0,0 +1,395 @@ +/** + * Module dependencies + */ +var AbstractClass = require('./abstract-class').AbstractClass; +var util = require('util'); +var path = require('path'); +var fs = require('fs'); + +var existsSync = fs.existsSync || path.existsSync; + +/** + * Export public API + */ +exports.Schema = Schema; +// exports.AbstractClass = AbstractClass; + +/** + * Helpers + */ +var slice = Array.prototype.slice; + +/** + * Schema - adapter-specific classes factory. + * + * All classes in single schema shares same adapter type and + * one database connection + * + * @param name - type of schema adapter (mysql, mongoose, sequelize, redis) + * @param settings - any database-specific settings which we need to + * establish connection (of course it depends on specific adapter) + * + * - host + * - port + * - username + * - password + * - database + * - debug {Boolean} = false + * + * @example Schema creation, waiting for connection callback + * ``` + * var schema = new Schema('mysql', { database: 'myapp_test' }); + * schema.define(...); + * schema.on('connected', function () { + * // work with database + * }); + * ``` + */ +function Schema(name, settings) { + var schema = this; + // just save everything we get + this.name = name; + this.settings = settings; + + // Disconnected by default + this.connected = false; + + // create blank models pool + this.models = {}; + this.definitions = {}; + + // and initialize schema using adapter + // this is only one initialization entry point of adapter + // this module should define `adapter` member of `this` (schema) + var adapter; + if (existsSync(__dirname + '/adapters/' + name + '.js')) { + adapter = require('./adapters/' + name); + } else { + try { + adapter = require('jugglingdb-' + name); + } catch (e) { + throw new Error('Adapter ' + name + ' is not defined, try\n npm install ' + name); + } + } + + adapter.initialize(this, function () { + + // we have an adaper now? + if (!this.adapter) { + throw new Error('Adapter is not defined correctly: it should create `adapter` member of schema'); + } + + this.adapter.log = function (query, start) { + schema.log(query, start); + }; + + this.adapter.logger = function (query) { + var t1 = Date.now(); + var log = this.log; + return function (q) { + log(q || query, t1); + }; + }; + + this.connected = true; + this.emit('connected'); + + }.bind(this)); +}; + +util.inherits(Schema, require('events').EventEmitter); + +function Text() {} +Schema.Text = Text; +function JSON() {} +Schema.JSON = JSON; + +/** + * Define class + * + * @param {String} className + * @param {Object} properties - hash of class properties in format + * `{property: Type, property2: Type2, ...}` + * or + * `{property: {type: Type}, property2: {type: Type2}, ...}` + * @param {Object} settings - other configuration of class + * @return newly created class + * + * @example simple case + * ``` + * var User = schema.define('User', { + * email: String, + * password: String, + * birthDate: Date, + * activated: Boolean + * }); + * ``` + * @example more advanced case + * ``` + * var User = schema.define('User', { + * email: { type: String, limit: 150, index: true }, + * password: { type: String, limit: 50 }, + * birthDate: Date, + * registrationDate: {type: Date, default: function () { return new Date }}, + * activated: { type: Boolean, default: false } + * }); + * ``` + */ +Schema.prototype.define = function defineClass(className, properties, settings) { + var schema = this; + var args = slice.call(arguments); + + if (!className) throw new Error('Class name required'); + if (args.length == 1) properties = {}, args.push(properties); + if (args.length == 2) settings = {}, args.push(settings); + + standartize(properties, settings); + + // every class can receive hash of data as optional param + var NewClass = function ModelConstructor(data) { + if (!(this instanceof ModelConstructor)) { + return new ModelConstructor(data); + } + AbstractClass.call(this, data); + }; + + hiddenProperty(NewClass, 'schema', schema); + hiddenProperty(NewClass, 'modelName', className); + hiddenProperty(NewClass, 'cache', {}); + hiddenProperty(NewClass, 'mru', []); + + // inherit AbstractClass methods + for (var i in AbstractClass) { + NewClass[i] = AbstractClass[i]; + } + for (var j in AbstractClass.prototype) { + NewClass.prototype[j] = AbstractClass.prototype[j]; + } + + NewClass.getter = {}; + NewClass.setter = {}; + + // store class in model pool + this.models[className] = NewClass; + this.definitions[className] = { + properties: properties, + settings: settings + }; + + // pass controll to adapter + this.adapter.define({ + model: NewClass, + properties: properties, + settings: settings + }); + + NewClass.prototype.__defineGetter__('id', function () { + return this.__data.id; + }); + + properties.id = properties.id || { type: Number }; + + NewClass.forEachProperty = function (cb) { + Object.keys(properties).forEach(cb); + }; + + NewClass.registerProperty = function (attr) { + Object.defineProperty(NewClass.prototype, attr, { + get: function () { + if (NewClass.getter[attr]) { + return NewClass.getter[attr].call(this); + } else { + return this.__data[attr]; + } + }, + set: function (value) { + if (NewClass.setter[attr]) { + NewClass.setter[attr].call(this, value); + } else { + this.__data[attr] = value; + } + }, + configurable: true, + enumerable: true + }); + + NewClass.prototype.__defineGetter__(attr + '_was', function () { + return this.__dataWas[attr]; + }); + + Object.defineProperty(NewClass.prototype, '_' + attr, { + get: function () { + return this.__data[attr]; + }, + set: function (value) { + this.__data[attr] = value; + }, + configurable: true, + enumerable: false + }); + }; + + NewClass.forEachProperty(NewClass.registerProperty); + + return NewClass; + + function standartize(properties, settings) { + Object.keys(properties).forEach(function (key) { + var v = properties[key]; + if ( + typeof v === 'function' || + typeof v === 'object' && v && v.constructor.name === 'Array' + ) { + properties[key] = { type: v }; + } + }); + // TODO: add timestamps fields + // when present in settings: {timestamps: true} + // or {timestamps: {created: 'created_at', updated: false}} + // by default property names: createdAt, updatedAt + } + +}; + + +/** + * Define single property named `prop` on `model` + * + * @param {String} model - name of model + * @param {String} prop - name of propery + * @param {Object} params - property settings + */ +Schema.prototype.defineProperty = function (model, prop, params) { + this.definitions[model].properties[prop] = params; + this.models[model].registerProperty(prop); + if (this.adapter.defineProperty) { + this.adapter.defineProperty(model, prop, params); + } +}; + +/** + * Drop each model table and re-create. + * This method make sense only for sql adapters. + * + * @warning All data will be lost! Use autoupdate if you need your data. + */ +Schema.prototype.automigrate = function (cb) { + this.freeze(); + if (this.adapter.automigrate) { + this.adapter.automigrate(cb); + } else if (cb) { + cb(); + } +}; + +/** + * Update existing database tables. + * This method make sense only for sql adapters. + */ +Schema.prototype.autoupdate = function (cb) { + this.freeze(); + if (this.adapter.autoupdate) { + this.adapter.autoupdate(cb); + } else if (cb) { + cb(); + } +}; + +/** + * Check whether migrations needed + * This method make sense only for sql adapters. + */ +Schema.prototype.isActual = function (cb) { + this.freeze(); + if (this.adapter.isActual) { + this.adapter.isActual(cb); + } else if (cb) { + cb(null, true); + } +}; + +/** + * Log benchmarked message. Do not redefine this method, if you need to grab + * chema logs, use `schema.on('log', ...)` emitter event + * + * @private used by adapters + */ +Schema.prototype.log = function (sql, t) { + this.emit('log', sql, t); +}; + +/** + * Freeze schema. Behavior depends on adapter + */ +Schema.prototype.freeze = function freeze() { + if (this.adapter.freezeSchema) { + this.adapter.freezeSchema(); + } +} + +/** + * Return table name for specified `modelName` + * @param {String} modelName + */ +Schema.prototype.tableName = function (modelName) { + return this.definitions[modelName].settings.table = this.definitions[modelName].settings.table || modelName +}; + +/** + * Define foreign key + * @param {String} className + * @param {String} key - name of key field + */ +Schema.prototype.defineForeignKey = function defineForeignKey(className, key) { + // quit if key already defined + if (this.definitions[className].properties[key]) return; + + if (this.adapter.defineForeignKey) { + this.adapter.defineForeignKey(className, key, function (err, keyType) { + if (err) throw err; + this.definitions[className].properties[key] = {type: keyType}; + }.bind(this)); + } else { + this.definitions[className].properties[key] = {type: Number}; + } + this.models[className].registerProperty(key); +}; + +/** + * Close database connection + */ +Schema.prototype.disconnect = function disconnect() { + if (typeof this.adapter.disconnect === 'function') { + this.connected = false; + this.adapter.disconnect(); + } +}; + +/** + * Define hidden property + */ +function hiddenProperty(where, property, value) { + Object.defineProperty(where, property, { + writable: false, + enumerable: false, + configurable: false, + value: value + }); +} + +/** + * Define readonly property on object + * + * @param {Object} obj + * @param {String} key + * @param {Mixed} value + */ +function defineReadonlyProp(obj, key, value) { + Object.defineProperty(obj, key, { + writable: false, + enumerable: true, + configurable: true, + value: value + }); +} + diff --git a/lib/sql.js b/lib/sql.js new file mode 100644 index 0000000..6eea584 --- /dev/null +++ b/lib/sql.js @@ -0,0 +1,160 @@ +module.exports = BaseSQL; + +/** + * Base SQL class + */ +function BaseSQL() { +} + +BaseSQL.prototype.query = function () { + throw new Error('query method should be declared in adapter'); +}; + +BaseSQL.prototype.command = function (sql, callback) { + return this.query(sql, callback); +}; + +BaseSQL.prototype.queryOne = function (sql, callback) { + return this.query(sql, function (err, data) { + if (err) return callback(err); + callback(err, data[0]); + }); +}; + +BaseSQL.prototype.table = function (model) { + return this._models[model].model.schema.tableName(model); +}; + +BaseSQL.prototype.escapeName = function (name) { + throw new Error('escapeName method should be declared in adapter'); +}; + +BaseSQL.prototype.tableEscaped = function (model) { + return this.escapeName(this.table(model)); +}; + +BaseSQL.prototype.define = function (descr) { + if (!descr.settings) descr.settings = {}; + this._models[descr.model.modelName] = descr; +}; + +BaseSQL.prototype.defineProperty = function (model, prop, params) { + this._models[model].properties[prop] = params; +}; + +BaseSQL.prototype.save = function (model, data, callback) { + var sql = 'UPDATE ' + this.tableEscaped(model) + ' SET ' + this.toFields(model, data) + ' WHERE ' + this.escapeName('id') + ' = ' + data.id; + + this.query(sql, function (err) { + callback(err); + }); +}; + + +BaseSQL.prototype.exists = function (model, id, callback) { + var sql = 'SELECT 1 FROM ' + + this.tableEscaped(model) + ' WHERE ' + this.escapeName('id') + ' = ' + id + ' LIMIT 1'; + + this.query(sql, function (err, data) { + if (err) return callback(err); + callback(null, data.length === 1); + }); +}; + +BaseSQL.prototype.find = function find(model, id, callback) { + var sql = 'SELECT * FROM ' + + this.tableEscaped(model) + ' WHERE ' + this.escapeName('id') + ' = ' + id + ' LIMIT 1'; + + this.query(sql, function (err, data) { + if (data && data.length === 1) { + data[0].id = id; + } else { + data = [null]; + } + callback(err, this.fromDatabase(model, data[0])); + }.bind(this)); +}; + +BaseSQL.prototype.destroy = function destroy(model, id, callback) { + var sql = 'DELETE FROM ' + + this.tableEscaped(model) + ' WHERE ' + this.escapeName('id') + ' = ' + id; + + this.command(sql, function (err) { + callback(err); + }); +}; + +BaseSQL.prototype.destroyAll = function destroyAll(model, callback) { + this.command('DELETE FROM ' + this.tableEscaped(model), function (err) { + if (err) { + return callback(err, []); + } + callback(err); + }.bind(this)); +}; + +BaseSQL.prototype.count = function count(model, callback, where) { + var self = this; + var props = this._models[model].properties; + + this.queryOne('SELECT count(*) as cnt FROM ' + + this.tableEscaped(model) + ' ' + buildWhere(where), function (err, res) { + if (err) return callback(err); + callback(err, res && res.cnt); + }); + + function buildWhere(conds) { + var cs = []; + Object.keys(conds || {}).forEach(function (key) { + var keyEscaped = self.escapeName(key); + if (conds[key] === null) { + cs.push(keyEscaped + ' IS NULL'); + } else { + cs.push(keyEscaped + ' = ' + self.toDatabase(props[key], conds[key])); + } + }); + return cs.length ? ' WHERE ' + cs.join(' AND ') : ''; + } +}; + +BaseSQL.prototype.updateAttributes = function updateAttrs(model, id, data, cb) { + data.id = id; + this.save(model, data, cb); +}; + +BaseSQL.prototype.disconnect = function disconnect() { + this.client.end(); +}; + +BaseSQL.prototype.automigrate = function (cb) { + var self = this; + var wait = 0; + Object.keys(this._models).forEach(function (model) { + wait += 1; + self.dropTable(model, function () { + // console.log('drop', model); + self.createTable(model, function (err) { + // console.log('create', model); + if (err) console.log(err); + done(); + }); + }); + }); + if (wait === 0) cb(); + + function done() { + if (--wait === 0 && cb) { + cb(); + } + } +}; + +BaseSQL.prototype.dropTable = function (model, cb) { + this.command('DROP TABLE IF EXISTS ' + this.tableEscaped(model), cb); +}; + +BaseSQL.prototype.createTable = function (model, cb) { + this.command('CREATE TABLE ' + this.tableEscaped(model) + + ' (\n ' + this.propertiesSQL(model) + '\n)', cb); +}; + diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 0000000..dc86cd8 --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,11 @@ +exports.safeRequire = safeRequire; + +function safeRequire(module) { + try { + return require(module); + } catch (e) { + console.log('Run "npm install jugglingdb ' + module + '" command to use jugglingdb using ' + module + ' database engine'); + process.exit(1); + } +} + diff --git a/lib/validatable.js b/lib/validatable.js new file mode 100644 index 0000000..b6dc5d6 --- /dev/null +++ b/lib/validatable.js @@ -0,0 +1,529 @@ +exports.Validatable = Validatable; + +/** + * Validation encapsulated in this abstract class. + * + * Basically validation configurators is just class methods, which adds validations + * configs to AbstractClass._validations. Each of this validations run when + * `obj.isValid()` method called. + * + * Each configurator can accept n params (n-1 field names and one config). Config + * is {Object} depends on specific validation, but all of them has one common part: + * `message` member. It can be just string, when only one situation possible, + * e.g. `Post.validatesPresenceOf('title', { message: 'can not be blank' });` + * + * In more complicated cases it can be {Hash} of messages (for each case): + * `User.validatesLengthOf('password', { min: 6, max: 20, message: {min: 'too short', max: 'too long'}});` + */ +function Validatable() { + // validatable class +}; + +/** + * Validate presence. This validation fails when validated field is blank. + * + * Default error message "can't be blank" + * + * @example presence of title + * ``` + * Post.validatesPresenceOf('title'); + * ``` + * @example with custom message + * ``` + * Post.validatesPresenceOf('title', {message: 'Can not be blank'}); + * ``` + * + * @sync + * + * @nocode + * @see helper/validatePresence + */ +Validatable.validatesPresenceOf = getConfigurator('presence'); + +/** + * Validate length. Three kinds of validations: min, max, is. + * + * Default error messages: + * + * - min: too short + * - max: too long + * - is: length is wrong + * + * @example length validations + * ``` + * User.validatesLengthOf('password', {min: 7}); + * User.validatesLengthOf('email', {max: 100}); + * User.validatesLengthOf('state', {is: 2}); + * User.validatesLengthOf('nick', {min: 3, max: 15}); + * ``` + * @example length validations with custom error messages + * ``` + * User.validatesLengthOf('password', {min: 7, message: {min: 'too weak'}}); + * User.validatesLengthOf('state', {is: 2, message: {is: 'is not valid state name'}}); + * ``` + * + * @sync + * @nocode + * @see helper/validateLength + */ +Validatable.validatesLengthOf = getConfigurator('length'); + +/** + * Validate numericality. + * + * @example + * ``` + * User.validatesNumericalityOf('age', { message: { number: '...' }}); + * User.validatesNumericalityOf('age', {int: true, message: { int: '...' }}); + * ``` + * + * Default error messages: + * + * - number: is not a number + * - int: is not an integer + * + * @sync + * @nocode + * @see helper/validateNumericality + */ +Validatable.validatesNumericalityOf = getConfigurator('numericality'); + +/** + * Validate inclusion in set + * + * @example + * ``` + * User.validatesInclusionOf('gender', {in: ['male', 'female']}); + * User.validatesInclusionOf('role', { + * in: ['admin', 'moderator', 'user'], message: 'is not allowed' + * }); + * ``` + * + * Default error message: is not included in the list + * + * @sync + * @nocode + * @see helper/validateInclusion + */ +Validatable.validatesInclusionOf = getConfigurator('inclusion'); + +/** + * Validate exclusion + * + * @example `Company.validatesExclusionOf('domain', {in: ['www', 'admin']});` + * + * Default error message: is reserved + * + * @nocode + * @see helper/validateExclusion + */ +Validatable.validatesExclusionOf = getConfigurator('exclusion'); + +/** + * Validate format + * + * Default error message: is invalid + * + * @nocode + * @see helper/validateFormat + */ +Validatable.validatesFormatOf = getConfigurator('format'); + +/** + * Validate using custom validator + * + * Default error message: is invalid + * + * @nocode + * @see helper/validateCustom + */ +Validatable.validate = getConfigurator('custom'); + +/** + * Validate using custom async validator + * + * Default error message: is invalid + * + * @async + * @nocode + * @see helper/validateCustom + */ +Validatable.validateAsync = getConfigurator('custom', {async: true}); + +/** + * Validate uniqueness + * + * Default error message: is not unique + * + * @async + * @nocode + * @see helper/validateUniqueness + */ +Validatable.validatesUniquenessOf = getConfigurator('uniqueness', {async: true}); + +// implementation of validators + +/** + * Presence validator + */ +function validatePresence(attr, conf, err) { + if (blank(this[attr])) { + err(); + } +} + +/** + * Length validator + */ +function validateLength(attr, conf, err) { + if (nullCheck.call(this, attr, conf, err)) return; + + var len = this[attr].length; + if (conf.min && len < conf.min) { + err('min'); + } + if (conf.max && len > conf.max) { + err('max'); + } + if (conf.is && len !== conf.is) { + err('is'); + } +} + +/** + * Numericality validator + */ +function validateNumericality(attr, conf, err) { + if (nullCheck.call(this, attr, conf, err)) return; + + if (typeof this[attr] !== 'number') { + return err('number'); + } + if (conf.int && this[attr] !== Math.round(this[attr])) { + return err('int'); + } +} + +/** + * Inclusion validator + */ +function validateInclusion(attr, conf, err) { + if (nullCheck.call(this, attr, conf, err)) return; + + if (!~conf.in.indexOf(this[attr])) { + err() + } +} + +/** + * Exclusion validator + */ +function validateExclusion(attr, conf, err) { + if (nullCheck.call(this, attr, conf, err)) return; + + if (~conf.in.indexOf(this[attr])) { + err() + } +} + +/** + * Format validator + */ +function validateFormat(attr, conf, err) { + if (nullCheck.call(this, attr, conf, err)) return; + + if (typeof this[attr] === 'string') { + if (!this[attr].match(conf['with'])) { + err(); + } + } else { + err(); + } +} + +/** + * Custom validator + */ +function validateCustom(attr, conf, err, done) { + conf.customValidator.call(this, err, done); +} + +/** + * Uniqueness validator + */ +function validateUniqueness(attr, conf, err, done) { + var cond = {where: {}}; + cond.where[attr] = this[attr]; + this.constructor.all(cond, function (error, found) { + if (found.length > 1) { + err(); + } else if (found.length === 1 && found[0].id != this.id) { + err(); + } + done(); + }.bind(this)); +} + +var validators = { + presence: validatePresence, + length: validateLength, + numericality: validateNumericality, + inclusion: validateInclusion, + exclusion: validateExclusion, + format: validateFormat, + custom: validateCustom, + uniqueness: validateUniqueness +}; + +function getConfigurator(name, opts) { + return function () { + configure(this, name, arguments, opts); + }; +} + +/** + * This method performs validation, triggers validation hooks. + * Before validation `obj.errors` collection cleaned. + * Each validation can add errors to `obj.errors` collection. + * If collection is not blank, validation failed. + * + * @warning This method can be called as sync only when no async validation + * configured. It's strongly recommended to run all validations as asyncronous. + * + * @param {Function} callback called with (valid) + * @return {Boolean} true if no async validation configured and all passed + * + * @example ExpressJS controller: render user if valid, show flash otherwise + * ``` + * user.isValid(function (valid) { + * if (valid) res.render({user: user}); + * else res.flash('error', 'User is not valid'), console.log(user.errors), res.redirect('/users'); + * }); + * ``` + */ +Validatable.prototype.isValid = function (callback) { + var valid = true, inst = this, wait = 0, async = false; + + // exit with success when no errors + if (!this.constructor._validations) { + cleanErrors(this); + if (callback) { + callback(valid); + } + return valid; + } + + Object.defineProperty(this, 'errors', { + enumerable: false, + configurable: true, + value: new Errors + }); + + this.trigger('validation', function (validationsDone) { + var inst = this; + this.constructor._validations.forEach(function (v) { + if (v[2] && v[2].async) { + async = true; + wait += 1; + validationFailed(inst, v, done); + } else { + if (validationFailed(inst, v)) { + valid = false; + } + } + + }); + + if (!async) { + validationsDone(); + } + + var asyncFail = false; + function done(fail) { + asyncFail = asyncFail || fail; + if (--wait === 0 && callback) { + validationsDone.call(inst, function () { + if( valid && !asyncFail ) cleanErrors(inst); + callback(valid && !asyncFail); + }); + } + } + + }); + + if (!async) { + if (valid) cleanErrors(this); + if (callback) callback(valid); + return valid; + } else { + // in case of async validation we should return undefined here, + // because not all validations are finished yet + return; + } + +}; + +function cleanErrors(inst) { + Object.defineProperty(inst, 'errors', { + enumerable: false, + configurable: true, + value: false + }); +} + +function validationFailed(inst, v, cb) { + var attr = v[0]; + var conf = v[1]; + var opts = v[2] || {}; + + if (typeof attr !== 'string') return false; + + // here we should check skip validation conditions (if, unless) + // that can be specified in conf + if (skipValidation(inst, conf, 'if')) return false; + if (skipValidation(inst, conf, 'unless')) return false; + + var fail = false; + var validator = validators[conf.validation]; + var validatorArguments = []; + validatorArguments.push(attr); + validatorArguments.push(conf); + validatorArguments.push(function onerror(kind) { + var message; + if (conf.message) { + message = conf.message; + } + if (!message && defaultMessages[conf.validation]) { + message = defaultMessages[conf.validation]; + } + if (!message) { + message = 'is invalid'; + } + if (kind) { + if (message[kind]) { + // get deeper + message = message[kind]; + } else if (defaultMessages.common[kind]) { + message = defaultMessages.common[kind]; + } + } + inst.errors.add(attr, message); + fail = true; + }); + if (cb) { + validatorArguments.push(function () { + cb(fail); + }); + } + validator.apply(inst, validatorArguments); + return fail; +} + +function skipValidation(inst, conf, kind) { + var doValidate = true; + if (typeof conf[kind] === 'function') { + doValidate = conf[kind].call(inst); + if (kind === 'unless') doValidate = !doValidate; + } else if (typeof conf[kind] === 'string') { + if (typeof inst[conf[kind]] === 'function') { + doValidate = inst[conf[kind]].call(inst); + if (kind === 'unless') doValidate = !doValidate; + } else if (inst.__data.hasOwnProperty(conf[kind])) { + doValidate = inst[conf[kind]]; + if (kind === 'unless') doValidate = !doValidate; + } else { + doValidate = kind === 'if'; + } + } + return !doValidate; +} + +var defaultMessages = { + presence: 'can\'t be blank', + length: { + min: 'too short', + max: 'too long', + is: 'length is wrong' + }, + common: { + blank: 'is blank', + 'null': 'is null' + }, + numericality: { + 'int': 'is not an integer', + 'number': 'is not a number' + }, + inclusion: 'is not included in the list', + exclusion: 'is reserved', + uniqueness: 'is not unique' +}; + +function nullCheck(attr, conf, err) { + var isNull = this[attr] === null || !(attr in this); + if (isNull) { + if (!conf.allowNull) { + err('null'); + } + return true; + } else { + if (blank(this[attr])) { + if (!conf.allowBlank) { + err('blank'); + } + return true; + } + } + return false; +} + +/** + * Return true when v is undefined, blank array, null or empty string + * otherwise returns false + * + * @param {Mix} v + * @returns {Boolean} whether `v` blank or not + */ +function blank(v) { + if (typeof v === 'undefined') return true; + if (v instanceof Array && v.length === 0) return true; + if (v === null) return true; + if (typeof v == 'string' && v === '') return true; + return false; +} + +function configure(cls, validation, args, opts) { + if (!cls._validations) { + Object.defineProperty(cls, '_validations', { + writable: true, + configurable: true, + enumerable: false, + value: [] + }); + } + args = [].slice.call(args); + var conf; + if (typeof args[args.length - 1] === 'object') { + conf = args.pop(); + } else { + conf = {}; + } + if (validation === 'custom' && typeof args[args.length - 1] === 'function') { + conf.customValidator = args.pop(); + } + conf.validation = validation; + args.forEach(function (attr) { + cls._validations.push([attr, conf, opts]); + }); +} + +function Errors() { +} + +Errors.prototype.add = function (field, message) { + if (!this[field]) { + this[field] = [message]; + } else { + this[field].push(message); + } +}; + diff --git a/package.json b/package.json new file mode 100644 index 0000000..9631744 --- /dev/null +++ b/package.json @@ -0,0 +1,88 @@ +{ + "name": "jugglingdb", + "description": "ORM for every database: redis, mysql, neo4j, mongodb, postgres, sqlite", + "version": "0.1.27-3", + "author": { + "name": "Anatoliy Chakkaev", + "email": "rpm1602@gmail.com" + }, + "contributors": [ + { + "name": "Anatoliy Chakkaev", + "email": "rpm1602@gmail.com" + }, + { + "name": "Julien Guimont", + "email": "julien.guimont@gmail.com" + }, + { + "name": "Joseph Junker", + "email": "joseph.jnk@gmail.com" + }, + { + "name": "Henri Bergius", + "email": "henri.bergius@iki.fi" + }, + { + "name": "redvulps", + "email": "fabopereira@gmail.com" + }, + { + "name": "Felipe Sateler", + "email": "fsateler@gmail.com" + }, + { + "name": "Amir M. Mahmoudi", + "email": "a@geeknux.com" + }, + { + "name": "Justinas Stankevičius", + "email": "justinas@justinas.me" + }, + { + "name": "Rick O'Toole", + "email": "patrick.n.otoole@gmail.com" + } + ], + "repository": { + "type": "git", + "url": "git://github.com/1602/jugglingdb.git" + }, + "main": "index.js", + "scripts": { + "test": "EXCEPT=cradle,neo4j nodeunit test/*_test*" + }, + "engines": [ + "node >= 0.4.12" + ], + "dependencies": { + "node-uuid": ">= 1.3.3" + }, + "devDependencies": { + "semicov": "*", + "coffee-script": ">= 1.2.0", + "nodeunit": ">= 0.6.4", + "redis": "= 0.7.2", + "hiredis": "latest", + "mongoose": "latest", + "mysql": ">= 2.0.0-alpha3", + "pg": "= 0.7.2", + "sqlite3": ">= 2.0.18", + "riak-js": ">= 0.4.1", + "neo4j": ">= 0.2.5", + "mongodb": ">= 0.9.9", + "felix-couchdb": ">= 1.0.3", + "cradle": ">= 0.6.3" + }, + "_npmUser": { + "name": "biggora", + "email": "aleksej@gordejev.lv" + }, + "_id": "jugglingdb@0.1.27-3", + "optionalDependencies": {}, + "_engineSupported": true, + "_npmVersion": "1.1.12", + "_nodeVersion": "v0.8.8", + "_defaultsLoaded": true, + "_from": "jugglingdb@0.1.x" +} diff --git a/support/ci/neo4j.sh b/support/ci/neo4j.sh new file mode 100644 index 0000000..98f90fc --- /dev/null +++ b/support/ci/neo4j.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +# travis-ci.org now provides neo4j server but it is not started on boot +which neo4j && sudo neo4j start +sleep 5 diff --git a/test/common_test.js b/test/common_test.js new file mode 100644 index 0000000..1dd955f --- /dev/null +++ b/test/common_test.js @@ -0,0 +1,1176 @@ +require('./spec_helper').init(exports); + +var Schema = require('../index').Schema; +var Text = Schema.Text; + +var schemas = { + // riak: {}, + mysql: { + database: 'myapp_test', + username: 'root' + }, + postgres: { + database: 'myapp_test', + username: 'postgres' + }, + sqlite3: { + database: ':memory:' + }, + neo4j: { url: 'http://localhost:7474/' }, + // mongoose: { url: 'mongodb://travis:test@localhost:27017/myapp' }, + mongodb: { url: 'mongodb://travis:test@localhost:27017/myapp' }, + redis2: {}, + memory: {}, + cradle: {} +}; + +var specificTest = getSpecificTests(); +var testPerformed = false; +var nbSchemaRequests = 0; + +Object.keys(schemas).forEach(function (schemaName) { + if (process.env.ONLY && process.env.ONLY !== schemaName) return; + if (process.env.EXCEPT && ~process.env.EXCEPT.indexOf(schemaName)) return; + performTestFor(schemaName); +}); + +if (process.env.ONLY && !testPerformed) { + performTestFor(process.env.ONLY); +} + +function performTestFor(schemaName) { + testPerformed = true; + context(schemaName, function () { + var schema = new Schema(schemaName, schemas[schemaName] || {}); + + it('should connect to database', function (test) { + if (schema.connected) return test.done(); + schema.on('connected', test.done); + }); + + schema.log = function (a) { + console.log(a); + nbSchemaRequests++; + }; + + testOrm(schema); + if (specificTest[schemaName]) specificTest[schemaName](schema); + }); +} + +function testOrm(schema) { + var requestsAreCounted = schema.name !== 'mongodb'; + + var Post, User, Passport; + var start = Date.now(); + + it('should define class', function (test) { + + User = schema.define('User', { + name: { type: String, index: true }, + email: { type: String, index: true }, + bio: Text, + approved: Boolean, + joinedAt: Date, + age: Number, + passwd: { type: String, index: true }, + settings: { type: Schema.JSON }, + extra: Object + }); + + Post = schema.define('Post', { + title: { type: String, length: 255, index: true }, + subject: { type: String }, + content: { type: Text }, + date: { type: Date, default: function () { return new Date }, index: true }, + published: { type: Boolean, default: false }, + likes: [], + related: [RelatedPost] + }, {table: 'posts'}); + + function RelatedPost() { } + RelatedPost.prototype.someMethod = function () { + return this.parent; + }; + + Post.validateAsync('title', function (err, done) { + process.nextTick(done); + }); + + User.hasMany(Post, {as: 'posts', foreignKey: 'userId'}); + // creates instance methods: + // user.posts(conds) + // user.posts.build(data) // like new Post({userId: user.id}); + // user.posts.create(data) // build and save + // user.posts.find + + // User.hasOne('latestPost', {model: Post, foreignKey: 'postId'}); + + // User.hasOne(Post, {as: 'latestPost', foreignKey: 'latestPostId'}); + // creates instance methods: + // user.latestPost() + // user.latestPost.build(data) + // user.latestPost.create(data) + + Post.belongsTo(User, {as: 'author', foreignKey: 'userId'}); + // creates instance methods: + // post.author(callback) -- getter when called with function + // post.author() -- sync getter when called without params + // post.author(user) -- setter when called with object + + Passport = schema.define('Passport', { + number: String + }); + + Passport.belongsTo(User, {as: 'owner', foreignKey: 'ownerId'}); + + var user = new User; + + test.ok(User instanceof Function); + + // class methods + test.ok(User.find instanceof Function); + test.ok(User.create instanceof Function); + + // instance methods + test.ok(user.save instanceof Function); + + schema.automigrate(function (err) { + if (err) { + console.log('Error while migrating'); + console.log(err); + } else { + test.done(); + } + }); + + }); + + it('should initialize object properly', function (test) { + var hw = 'Hello word', + now = Date.now(), + post = new Post({title: hw}), + anotherPost = Post({title: 'Resig style constructor'}); + + test.equal(post.title, hw); + test.ok(!post.propertyChanged('title'), 'property changed: title'); + post.title = 'Goodbye, Lenin'; + test.equal(post.title_was, hw); + test.ok(post.propertyChanged('title')); + test.strictEqual(post.published, false); + test.ok(post.date >= now); + test.ok(post.isNewRecord()); + test.ok(anotherPost instanceof Post); + test.ok(anotherPost.title, 'Resig style constructor'); + test.done(); + }); + + it('should be expoted to JSON', function (test) { + test.equal(JSON.stringify(new Post({id: 1, title: 'hello, json', date: 1})), + '{"title":"hello, json","subject":null,"content":null,"date":1,"published":false,"likes":[],"related":[],"id":1,"userId":null}'); + test.done(); + }); + + it('should create object', function (test) { + Post.create(function (err, post) { + if (err) throw err; + test.ok(post.id, 'Id present'); + test.ok(!post.title, 'Title is blank'); + Post.exists(post.id, function (err, exists) { + if (err) throw err; + test.ok(exists); + test.done(); + }); + }); + }); + + it('should create object without callback', function (test) { + var uniqueTitle = 'Unique title ' + Date.now(); + Post.create({title: uniqueTitle}); + + setTimeout(delayedCallback, 100); + + function delayedCallback() { + Post.all({where: {title: uniqueTitle}}, function (err, posts) { + test.equal(posts.length, 1); + test.done(); + }); + } + }); + + it('should save object', function (test) { + var title = 'Initial title', title2 = 'Hello world', + date = new Date; + + Post.create({ + title: title, + date: date + }, function (err, obj) { + test.ok(obj.id, 'Object id should present'); + test.equals(obj.title, title); + // test.equals(obj.date, date); + obj.title = title2; + test.ok(obj.propertyChanged('title'), 'Title changed'); + obj.save(function (err, obj) { + test.equal(obj.title, title2); + test.ok(!obj.propertyChanged('title')); + + var p = new Post({title: 1}); + p.title = 2; + p.save(function (err, obj) { + test.ok(!p.propertyChanged('title')); + p.title = 3; + test.ok(p.propertyChanged('title')); + test.equal(p.title_was, 2); + p.save(function () { + test.equal(p.title_was, 3); + test.ok(!p.propertyChanged('title')); + test.done(); + }); + }); + }); + }); + }); + + it('should create object with initial data', function (test) { + var title = 'Initial title', + date = new Date; + + Post.create({ + title: title, + date: date + }, function (err, obj) { + test.ok(obj.id); + test.equals(obj.title, title); + test.equals(obj.date, date); + Post.find(obj.id, function () { + test.equal(obj.title, title); + test.equal(obj.date.toString(), date.toString()); + test.done(); + }); + }); + }); + + it('should save only schema-defined field in database', function (test) { + Post.create({title: '1602', nonSchemaField: 'some value'}, function (err, post) { + test.ok(!post.nonSchemaField); + post.a = 1; + post.save(function () { + test.ok(post.a); + post.reload(function (err, psto) { + test.ok(!psto.a); + test.done(); + }); + }); + }); + }); + + /* + it('should not create new instances for the same object', function (test) { + var title = 'Initial title'; + Post.create({ title: title }, function (err, post) { + test.ok(post.id, 'Object should have id'); + test.equals(post.title, title); + Post.find(post.id, function (err, foundPost) { + if (err) throw err; + test.equal(post.title, title); + test.strictEqual(post, foundPost); + test.done(); + }); + }); + }); + */ + + it('should not re-instantiate object on saving', function (test) { + var title = 'Initial title'; + var post = new Post({title: title}); + post.save(function (err, savedPost) { + test.strictEqual(post, savedPost); + test.done(); + }); + }); + + it('should destroy object', function (test) { + Post.create(function (err, post) { + Post.exists(post.id, function (err, exists) { + test.ok(exists, 'Object exists'); + post.destroy(function () { + Post.exists(post.id, function (err, exists) { + if (err) console.log(err); + test.ok(!exists, 'Hey! ORM told me that object exists, but it looks like it doesn\'t. Something went wrong...'); + Post.find(post.id, function (err, obj) { + test.equal(obj, null, 'Param obj should be null'); + test.done(); + }); + }); + }); + }); + }); + }); + + it('should handle virtual attributes', function (test) { + var salt = 's0m3s3cr3t5a1t'; + + User.setter.passwd = function (password) { + this._passwd = calcHash(password, salt); + }; + + function calcHash(pass, salt) { + var crypto = require('crypto'); + var hash = crypto.createHash('sha256'); + hash.update(pass); + hash.update(salt); + return hash.digest('base64'); + } + + var u = new User; + u.passwd = 's3cr3t'; + test.equal(u.passwd, calcHash('s3cr3t', salt)); + test.done(); + }); + + // it('should serialize JSON type', function (test) { + // User.create({settings: {hello: 'world'}}, function (err, user) { + // test.ok(user.id); + // test.equal(user.settings.hello, 'world'); + // User.find(user.id, function (err, u) { + // console.log(u.settings); + // test.equal(u.settings.hello, 'world'); + // test.done(); + // }); + // }); + // }); + + it('should update single attribute', function (test) { + Post.create({title: 'title', content: 'content', published: true}, function (err, post) { + post.content = 'New content'; + post.updateAttribute('title', 'New title', function () { + test.equal(post.title, 'New title'); + test.ok(!post.propertyChanged('title')); + console.log('hahaha', post.content, post.__data.content); + test.equal(post.content, 'New content', 'dirty state saved'); + test.ok(post.propertyChanged('content')); + post.reload(function (err, post) { + test.equal(post.title, 'New title'); + test.ok(!post.propertyChanged('title'), 'title not changed'); + console.log(post.content); + test.equal(post.content, 'content', 'real value turned back'); + test.ok(!post.propertyChanged('content'), 'content unchanged'); + test.done(); + }); + }); + }); + }); + + var countOfposts, countOfpostsFiltered; + it('should fetch collection', function (test) { + Post.all(function (err, posts) { + countOfposts = posts.length; + test.ok(countOfposts > 0); + test.ok(posts[0] instanceof Post); + countOfpostsFiltered = posts.filter(function (p) { + console.log(p.title); + return p.title === 'title'; + }).length; + test.done(); + }); + }); + + it('should fetch count of records in collection', function (test) { + Post.count(function (err, count) { + console.log(countOfposts, count); + test.equal(countOfposts, count, 'unfiltered count'); + Post.count({title: 'title'}, function (err, count) { + console.log(countOfpostsFiltered, count, 'filtered count'); + test.equal(countOfpostsFiltered, count, 'filtered count'); + test.done(); + }); + }); + }); + + it('should find filtered set of records', function (test) { + var wait = 1; + + // exact match with string + Post.all({where: {title: 'New title'}}, function (err, res) { + var pass = true; + res.forEach(function (r) { + if (r.title != 'New title') pass = false; + }); + test.ok(res.length > 0, 'Exact match with string returns dataset'); + test.ok(pass, 'Exact match with string'); + done(); + }); + + // matching null + // Post.all({where: {title: null}}, function (err, res) { + + // var pass = true; + // res.forEach(function (r) { + // if (r.title != null) pass = false; + // }); + // test.ok(res.length > 0, 'Matching null returns dataset'); + // test.ok(pass, 'Matching null'); + // done(); + // }); + + function done() { + if (--wait === 0) { + test.done(); + } + } + + }); + + + it('should find records filtered with multiple attributes', function (test) { + Post.create({title: 'title', content: 'content', published: true, date: 1}, function (err, post) { + Post.all({where: {title: 'title', date: 1}}, function (err, res) { + test.ok(res.length > 0, 'Exact match with string returns dataset'); + test.done(); + }); + }); + }); + + it('should handle hasMany relationship', function (test) { + User.create(function (err, u) { + if (err) return console.log(err); + test.ok(u.posts, 'Method defined: posts'); + test.ok(u.posts.build, 'Method defined: posts.build'); + test.ok(u.posts.create, 'Method defined: posts.create'); + u.posts.create(function (err, post) { + if (err) return console.log(err); + // test.ok(post.author(), u.id); + u.posts(function (err, posts) { + test.equal(posts.pop().id, post.id); + test.done(); + }); + }); + }); + }); + + + if ( + !schema.name.match(/redis/) && + schema.name !== 'memory' && + schema.name !== 'neo4j' && + schema.name !== 'cradle' && + schema.name !== 'mongodb' + ) + it('hasMany should support additional conditions', function (test) { + + // Finding one post with an existing author associated + Post.all(function (err, posts) { + // We try to get the first post with a userId != NULL + for (var i = 0; i < posts.length; i++) { + var post = posts[i]; + if (post.userId !== null) { + // We could get the user with belongs to relationship but it is better if there is no interactions. + User.find(post.userId, function(err, user) { + user.posts({where: {id: post.id}}, function(err, posts) { + test.equal(posts.length, 1, 'There should be only 1 post.'); + test.done(); + }); + }); + break; + } + } + }); + }); + + + if ( + !schema.name.match(/redis/) && + schema.name !== 'memory' && + schema.name !== 'neo4j' && + schema.name !== 'cradle' + ) + it('hasMany should be cached', function (test) { + // Finding one post with an existing author associated + Post.all(function (err, posts) { + // We try to get the first post with a userId != NULL + for (var i = 0; i < posts.length; i++) { + var post = posts[i]; + if (post.userId !== null) { + // We could get the user with belongs to relationship but it is better if there is no interactions. + User.find(post.userId, function(err, user) { + User.create(function(err, voidUser) { + Post.create({userId: user.id}, function() { + + // There can't be any concurrency because we are counting requests + // We are first testing cases when user has posts + user.posts(function(err, data) { + var nbInitialRequests = nbSchemaRequests; + user.posts(function(err, data2) { + test.equal(data.length, 2, 'There should be 2 posts.'); + test.equal(data.length, data2.length, 'Posts should be the same, since we are loading on the same object.'); + requestsAreCounted && test.equal(nbInitialRequests, nbSchemaRequests, 'There should not be any request because value is cached.'); + + if (schema.name === 'mongodb') { // for the moment mongodb doesn\'t support additional conditions on hasMany relations (see above) + test.done(); + } else { + user.posts({where: {id: data[0].id}}, function(err, data) { + test.equal(data.length, 1, 'There should be only one post.'); + requestsAreCounted && test.equal(nbInitialRequests + 1, nbSchemaRequests, 'There should be one additional request since we added conditions.'); + + user.posts(function(err, data) { + test.equal(data.length, 2, 'Previous get shouldn\'t have changed cached value though, since there was additional conditions.'); + requestsAreCounted && test.equal(nbInitialRequests + 1, nbSchemaRequests, 'There should not be any request because value is cached.'); + + // We are now testing cases when user doesn't have any post + voidUser.posts(function(err, data) { + var nbInitialRequests = nbSchemaRequests; + voidUser.posts(function(err, data2) { + test.equal(data.length, 0, 'There shouldn\'t be any posts (1/2).'); + test.equal(data2.length, 0, 'There shouldn\'t be any posts (2/2).'); + requestsAreCounted && test.equal(nbInitialRequests, nbSchemaRequests, 'There should not be any request because value is cached.'); + + voidUser.posts(true, function(err, data3) { + test.equal(data3.length, 0, 'There shouldn\'t be any posts.'); + requestsAreCounted && test.equal(nbInitialRequests + 1, nbSchemaRequests, 'There should be one additional request since we forced refresh.'); + + test.done(); + }); + }); + }); + + }); + }); + } + + }); + }); + + }); + }); + }); + break; + } + } + }); + + }); + + // it('should handle hasOne relationship', function (test) { + // User.create(function (err, u) { + // if (err) return console.log(err); + // }); + // }); + + it('should support scopes', function (test) { + var wait = 2; + + test.ok(Post.scope, 'Scope supported'); + Post.scope('published', {where: {published: true}}); + test.ok(typeof Post.published === 'function'); + test.ok(Post.published._scope.where.published === true); + var post = Post.published.build(); + test.ok(post.published, 'Can build'); + test.ok(post.isNewRecord()); + Post.published.create(function (err, psto) { + if (err) return console.log(err); + test.ok(psto.published); + test.ok(!psto.isNewRecord()); + done(); + }); + + User.create(function (err, u) { + if (err) return console.log(err); + test.ok(typeof u.posts.published == 'function'); + test.ok(u.posts.published._scope.where.published); + console.log(u.posts.published._scope); + test.equal(u.posts.published._scope.where.userId, u.id); + done(); + }); + + function done() { + if (--wait === 0) test.done(); + }; + }); + + it('should destroy all records', function (test) { + Post.destroyAll(function (err) { + if (err) { + console.log('Error in destroyAll'); + console.log(err); + throw err; + } + Post.all(function (err, posts) { + test.equal(posts.length, 0); + Post.count(function (err, count) { + test.equal(count, 0); + test.done(); + }); + }); + }); + }); + + it('should return type of property', function (test) { + test.equal(Post.whatTypeName('title'), 'String'); + test.equal(Post.whatTypeName('content'), 'Text'); + var p = new Post; + test.equal(p.whatTypeName('title'), 'String'); + test.equal(p.whatTypeName('content'), 'Text'); + test.done(); + }); + + it('should handle ORDER clause', function (test) { + var titles = [ { title: 'Title A', subject: "B" }, + { title: 'Title Z', subject: "A" }, + { title: 'Title M', subject: "C" }, + { title: 'Title A', subject: "A" }, + { title: 'Title B', subject: "A" }, + { title: 'Title C', subject: "D" }]; + var isRedis = Post.schema.name === 'redis'; + var dates = isRedis ? [ 5, 9, 0, 17, 10, 9 ] : [ + new Date(1000 * 5 ), + new Date(1000 * 9), + new Date(1000 * 0), + new Date(1000 * 17), + new Date(1000 * 10), + new Date(1000 * 9) + ]; + titles.forEach(function (t, i) { + Post.create({title: t.title, subject: t.subject, date: dates[i]}, done); + }); + + var i = 0, tests = 0; + function done(err, obj) { + if (++i === titles.length) { + doFilterAndSortTest(); + doFilterAndSortReverseTest(); + doStringTest(); + doNumberTest(); + + if (schema.name == 'mongoose') { + doMultipleSortTest(); + doMultipleReverseSortTest(); + } + } + } + + function compare(a, b) { + if (a.title < b.title) return -1; + if (a.title > b.title) return 1; + return 0; + } + + // Post.schema.log = console.log; + + function doStringTest() { + tests += 1; + Post.all({order: 'title'}, function (err, posts) { + if (err) console.log(err); + test.equal(posts.length, 6); + titles.sort(compare).forEach(function (t, i) { + if (posts[i]) test.equal(posts[i].title, t.title); + }); + finished(); + }); + } + + function doNumberTest() { + tests += 1; + Post.all({order: 'date'}, function (err, posts) { + if (err) console.log(err); + test.equal(posts.length, 6); + dates.sort(numerically).forEach(function (d, i) { + if (posts[i]) + test.equal(posts[i].date.toString(), d.toString(), 'doNumberTest'); + }); + finished(); + }); + } + + function doFilterAndSortTest() { + tests += 1; + Post.all({where: {date: new Date(1000 * 9)}, order: 'title', limit: 3}, function (err, posts) { + if (err) console.log(err); + console.log(posts.length); + test.equal(posts.length, 2, 'Exactly 2 posts returned by query'); + [ 'Title C', 'Title Z' ].forEach(function (t, i) { + if (posts[i]) { + test.equal(posts[i].title, t, 'doFilterAndSortTest'); + } + }); + finished(); + }); + } + + function doFilterAndSortReverseTest() { + tests += 1; + Post.all({where: {date: new Date(1000 * 9)}, order: 'title DESC', limit: 3}, function (err, posts) { + if (err) console.log(err); + test.equal(posts.length, 2, 'Exactly 2 posts returned by query'); + [ 'Title Z', 'Title C' ].forEach(function (t, i) { + if (posts[i]) { + test.equal(posts[i].title, t, 'doFilterAndSortReverseTest'); + } + }); + finished(); + }); + } + + function doMultipleSortTest() { + tests += 1; + Post.all({order: "title ASC, subject ASC"}, function(err, posts) { + if (err) console.log(err); + test.equal(posts.length, 6); + test.equal(posts[0].title, "Title A"); + test.equal(posts[0].subject, "A"); + test.equal(posts[1].title, "Title A"); + test.equal(posts[1].subject, "B"); + test.equal(posts[5].title, "Title Z"); + finished(); + }); + } + + function doMultipleReverseSortTest() { + tests += 1; + Post.all({order: "title ASC, subject DESC"}, function(err, posts) { + if (err) console.log(err); + test.equal(posts.length, 6); + test.equal(posts[0].title, "Title A"); + test.equal(posts[0].subject, "B"); + test.equal(posts[1].title,"Title A"); + test.equal(posts[1].subject, "A"); + test.equal(posts[5].title, "Title Z"); + finished(); + }); + } + + var fin = 0; + function finished() { + if (++fin === tests) { + test.done(); + } + } + + // TODO: do mixed test, do real dates tests, ensure that dates stored in UNIX timestamp format + + function numerically(a, b) { + return a - b; + } + + }); + + if ( + !schema.name.match(/redis/) && + schema.name !== 'memory' && + schema.name !== 'neo4j' && + schema.name !== 'cradle' + ) + it('should allow advanced queying: lt, gt, lte, gte, between', function (test) { + Post.destroyAll(function () { + Post.create({date: new Date('Wed, 01 Feb 2012 13:56:12 GMT')}, done); + Post.create({date: new Date('Thu, 02 Feb 2012 13:56:12 GMT')}, done); + Post.create({date: new Date('Fri, 03 Feb 2012 13:56:12 GMT')}, done); + Post.create({date: new Date('Sat, 04 Feb 2012 13:56:12 GMT')}, done); + Post.create({date: new Date('Sun, 05 Feb 2012 13:56:12 GMT')}, done); + Post.create({date: new Date('Mon, 06 Feb 2012 13:56:12 GMT')}, done); + Post.create({date: new Date('Tue, 07 Feb 2012 13:56:12 GMT')}, done); + Post.create({date: new Date('Wed, 08 Feb 2012 13:56:12 GMT')}, done); + Post.create({date: new Date('Thu, 09 Feb 2012 13:56:12 GMT')}, done); + }); + + var posts = 9; + function done() { + if (--posts === 0) makeTest(); + } + + function makeTest() { + // gt + Post.all({where: {date: {gt: new Date('Tue, 07 Feb 2012 13:56:12 GMT')}}}, function (err, posts) { + test.equal(posts.length, 2, 'gt'); + ok(); + }); + + // gte + Post.all({where: {date: {gte: new Date('Tue, 07 Feb 2012 13:56:12 GMT')}}}, function (err, posts) { + test.equal(posts.length, 3, 'gte'); + ok(); + }); + + // lte + Post.all({where: {date: {lte: new Date('Tue, 07 Feb 2012 13:56:12 GMT')}}}, function (err, posts) { + test.equal(posts.length, 7, 'lte'); + ok(); + }); + + // lt + Post.all({where: {date: {lt: new Date('Tue, 07 Feb 2012 13:56:12 GMT')}}}, function (err, posts) { + test.equal(posts.length, 6, 'lt'); + ok(); + }); + + // between + Post.all({where: {date: {between: [new Date('Tue, 05 Feb 2012 13:56:12 GMT'), new Date('Tue, 09 Feb 2012 13:56:12 GMT')]}}}, function (err, posts) { + test.equal(posts.length, 5, 'between'); + ok(); + }); + } + + var tests = 5; + function ok() { + if (--tests === 0) test.done(); + } + }); + + + if ( + schema.name === 'mysql' || + schema.name === 'postgres' + ) + it('should allow IN or NOT IN', function (test) { + User.destroyAll(function () { + User.create({name: 'User A', age: 21}, done); + User.create({name: 'User B', age: 22}, done); + User.create({name: 'User C', age: 23}, done); + User.create({name: 'User D', age: 24}, done); + User.create({name: 'User E', age: 25}, done); + }); + + var users = 5; + function done() { + if (--users === 0) makeTest(); + } + + function makeTest() { + // IN with empty array should return nothing + User.all({where: {name: {inq: []}}}, function (err, users) { + test.equal(users.length, 0, 'IN with empty array returns nothing'); + ok(); + }); + + // NOT IN with empty array should return everything + User.all({where: {name: {nin: []}}}, function (err, users) { + test.equal(users.length, 5, 'NOT IN with empty array returns everything'); + ok(); + }); + + // IN [User A] returns user with name = User A + User.all({where: {name: {inq: ['User A']}}}, function (err, users) { + test.equal(users.length, 1, 'IN searching one existing value returns 1 user'); + test.equal(users[0].name, 'User A', 'IN [User A] returns user with name = User A'); + ok(); + }); + + // NOT IN [User A] returns users with name != User A + User.all({where: {name: {nin: ['User A']}}}, function (err, users) { + test.equal(users.length, 4, 'IN [User A] returns users with name != User A'); + ok(); + }); + + // IN [User A, User B] returns users with name = User A OR name = User B + User.all({where: {name: {inq: ['User A', 'User B']}}}, function (err, users) { + test.equal(users.length, 2, 'IN searching two existing values returns 2 users'); + ok(); + }); + + // NOT IN [User A, User B] returns users with name != User A AND name != User B + User.all({where: {name: {nin: ['User A', 'User B']}}}, function (err, users) { + test.equal(users.length, 3, 'NOT IN searching two existing values returns users with name != User A AND name != User B'); + ok(); + }); + + // IN works with numbers too + User.all({where: {age: {inq: [21, 22]}}}, function (err, users) { + test.equal(users.length, 2, 'IN works with numbers too'); + ok(); + }); + + // NOT IN works with numbers too + User.all({where: {age: {nin: [21, 22]}}}, function (err, users) { + test.equal(users.length, 3, 'NOT IN works with numbers too'); + ok(); + }); + } + + var tests = 8; + function ok() { + if (--tests === 0) test.done(); + } + }); + + it('should handle order clause with direction', function (test) { + var wait = 0; + var emails = [ + 'john@hcompany.com', + 'tom@hcompany.com', + 'admin@hcompany.com', + 'tin@hcompany.com', + 'mike@hcompany.com', + 'susan@hcompany.com', + 'test@hcompany.com' + ]; + User.destroyAll(function () { + emails.forEach(function (email) { + wait += 1; + User.create({email: email, name: 'Nick'}, done); + }); + }); + var tests = 2; + function done() { + process.nextTick(function () { + if (--wait === 0) { + doSortTest(); + doReverseSortTest(); + } + }); + } + + function doSortTest() { + User.all({order: 'email ASC', where: {name: 'Nick'}}, function (err, users) { + var _emails = emails.sort(); + users.forEach(function (user, i) { + test.equal(_emails[i], user.email, 'ASC sorting'); + }); + testDone(); + }); + } + + function doReverseSortTest() { + User.all({order: 'email DESC', where: {name: 'Nick'}}, function (err, users) { + var _emails = emails.sort().reverse(); + users.forEach(function (user, i) { + test.equal(_emails[i], user.email, 'DESC sorting'); + }); + testDone(); + }); + } + + function testDone() { + if (--tests === 0) test.done(); + } + }); + + it('should return id in find result even after updateAttributes', function (test) { + Post.create(function (err, post) { + var id = post.id; + test.ok(post.published === false); + post.updateAttributes({title: 'hey', published: true}, function () { + Post.find(id, function (err, post) { + test.ok(!!post.published, 'Update boolean field'); + test.ok(post.id); + test.done(); + }); + }); + }); + }); + + it('should handle belongsTo correctly', function (test) { + var passport = new Passport({ownerId: 16}); + // sync getter + test.equal(passport.owner(), 16); + // sync setter + passport.owner(18); + test.equal(passport.owner(), 18); + test.done(); + }); + + it('should query one record', function (test) { + test.expect(4); + Post.findOne(function (err, post) { + test.ok(post && post.id); + Post.findOne({ where: { title: 'hey' } }, function (err, post) { + if (err) { + console.log(err); + return test.done(); + } + test.equal(post && post.constructor.modelName, 'Post'); + test.equal(post && post.title, 'hey'); + Post.findOne({ where: { title: 'not exists' } }, function (err, post) { + test.ok(typeof post === 'undefined'); + test.done(); + }); + }); + }); + }); + + if ( + !schema.name.match(/redis/) && + schema.name !== 'memory' && + schema.name !== 'neo4j' && + schema.name !== 'cradle' + ) + it('belongsTo should be cached', function (test) { + User.findOne(function(err, user) { + + var passport = new Passport({ownerId: user.id}); + var passport2 = new Passport({ownerId: null}); + + // There can't be any concurrency because we are counting requests + // We are first testing cases when passport has an owner + passport.owner(function(err, data) { + var nbInitialRequests = nbSchemaRequests; + passport.owner(function(err, data2) { + test.equal(data.id, data2.id, 'The value should remain the same'); + requestsAreCounted && test.equal(nbInitialRequests, nbSchemaRequests, 'There should not be any request because value is cached.'); + + // We are now testing cases when passport has not an owner + passport2.owner(function(err, data) { + var nbInitialRequests2 = nbSchemaRequests; + passport2.owner(function(err, data2) { + test.equal(data, null, 'The value should be null since there is no owner'); + test.equal(data, data2, 'The value should remain the same (null)'); + requestsAreCounted && test.equal(nbInitialRequests2, nbSchemaRequests, 'There should not be any request because value is cached.'); + + passport2.owner(user.id); + passport2.owner(function(err, data3) { + test.equal(data3.id, user.id, 'Owner should now be the user.'); + requestsAreCounted && test.equal(nbInitialRequests2 + 1, nbSchemaRequests, 'If we changed owner id, there should be one more request.'); + + passport2.owner(true, function(err, data4) { + test.equal(data3.id, data3.id, 'The value should remain the same'); + requestsAreCounted && test.equal(nbInitialRequests2 + 2, nbSchemaRequests, 'If we forced refreshing, there should be one more request.'); + test.done(); + }); + }); + }); + }); + + }); + }); + }); + + }); + + if (schema.name !== 'mongoose' && schema.name !== 'neo4j') + it('should update or create record', function (test) { + var newData = { + id: 1, + title: 'New title (really new)', + content: 'Some example content (updated)' + }; + Post.updateOrCreate(newData, function (err, updatedPost) { + if (err) throw err; + test.ok(updatedPost); + if (!updatedPost) throw Error('No post!'); + + if (schema.name !== 'mongodb') { + test.equal(newData.id, updatedPost.toObject().id); + } + test.equal(newData.title, updatedPost.toObject().title); + test.equal(newData.content, updatedPost.toObject().content); + + Post.find(updatedPost.id, function (err, post) { + if (err) throw err; + if (!post) throw Error('No post!'); + if (schema.name !== 'mongodb') { + test.equal(newData.id, post.toObject().id); + } + test.equal(newData.title, post.toObject().title); + test.equal(newData.content, post.toObject().content); + Post.updateOrCreate({id: 100001, title: 'hey'}, function (err, post) { + if (schema.name !== 'mongodb') test.equal(post.id, 100001); + test.equal(post.title, 'hey'); + Post.find(post.id, function (err, post) { + if (!post) throw Error('No post!'); + test.done(); + }); + }); + }); + }); + }); + + it('should work with custom setters and getters', function (test) { + User.schema.defineForeignKey('User', 'passwd'); + User.setter.passwd = function (pass) { + this._passwd = pass + 'salt'; + }; + var u = new User({passwd: 'qwerty'}); + test.equal(u.passwd, 'qwertysalt'); + u.save(function (err, user) { + User.find(user.id, function (err, user) { + test.ok(user !== u); + test.equal(user.passwd, 'qwertysalt'); + User.all({where: {passwd: 'qwertysalt'}}, function (err, users) { + test.ok(users[0] !== user); + test.equal(users[0].passwd, 'qwertysalt'); + User.create({passwd: 'asalat'}, function (err, usr) { + test.equal(usr.passwd, 'asalatsalt'); + User.upsert({passwd: 'heyman'}, function (err, us) { + test.equal(us.passwd, 'heymansalt'); + User.find(us.id, function (err, user) { + test.equal(user.passwd, 'heymansalt'); + test.done(); + }); + }); + }); + }); + }); + }); + }); + + it('should work with typed and untyped nested collections', function (test) { + var post = new Post; + var like = post.likes.push({foo: 'bar'}); + test.equal(like.constructor.name, 'ListItem'); + var related = post.related.push({hello: 'world'}); + test.ok(related.someMethod); + post.save(function (err, p) { + test.equal(p.likes.nextid, 2); + p.likes.push({second: 2}); + p.likes.push({third: 3}); + p.save(function (err) { + Post.find(p.id, function (err, pp) { + test.equal(pp.likes.length, 3); + test.ok(pp.likes[3].third); + test.ok(pp.likes[2].second); + test.ok(pp.likes[1].foo); + pp.likes.remove(2); + test.equal(pp.likes.length, 2); + test.ok(!pp.likes[2]); + pp.likes.remove(pp.likes[1]); + test.equal(pp.likes.length, 1); + test.ok(!pp.likes[1]); + test.ok(pp.likes[3]); + pp.save(function () { + Post.find(p.id, function (err, pp) { + test.equal(pp.likes.length, 1); + test.ok(!pp.likes[1]); + test.ok(pp.likes[3]); + test.done(); + }); + }); + }); + }); + }); + }); + + it('all tests done', function (test) { + test.done(); + process.nextTick(allTestsDone); + }); + + function allTestsDone() { + schema.disconnect(); + console.log('Test done in %dms\n', Date.now() - start); + } + +} + +function getSpecificTests() { + var sp = {}; + + sp['neo4j'] = function (schema) { + + it('should create methods for searching by index', function (test) { + var Post = schema.models['Post']; + test.ok(typeof Post.findByTitle === 'function'); + Post.create({title: 'Catcher in the rye'}, function (err, post) { + if (err) return console.log(err); + test.ok(!post.isNewRecord()); + Post.findByTitle('Catcher in the rye', function (err, foundPost) { + if (err) return console.log(err); + if (foundPost) { + test.equal(post.id, foundPost.id); + test.done(); + } + }); + }); + }); + }; + + return sp; +} diff --git a/test/hookable_test.coffee b/test/hookable_test.coffee new file mode 100644 index 0000000..f54204a --- /dev/null +++ b/test/hookable_test.coffee @@ -0,0 +1,95 @@ +juggling = require('../index') +Schema = juggling.Schema +AbstractClass = juggling.AbstractClass +Hookable = juggling.Hookable + +require('./spec_helper').init module.exports + +schema = new Schema 'memory' +User = schema.define 'User', + email: String + name: String + password: String + state: String + age: Number + gender: String + domain: String + pendingPeriod: Number + createdByAdmin: Boolean + +it "should trigger after initialize", (test) -> + User.afterInitialize = -> + User.afterInitialize = null + test.done() + user = new User + + +it "should trigger before create", (test) -> + User.beforeCreate = () -> + User.beforeCreate = null + test.done() + User.create -> test.ok "saved" + +it "should trigger after create", (test) -> + User.afterCreate = (next) -> + User.afterCreate = null + next() + + User.create -> + test.ok "saved" + test.done() + +it 'should trigger before save', (test) -> + test.expect(3) + User.beforeSave = (next) -> + User.beforeSave = null + @name = 'mr. ' + @name + next() + user = new User name: 'Jonathan' + + user.save -> + test.equals User.schema.adapter.cache.User[user.id].name, user.name + test.equals user.name, 'mr. Jonathan' + test.ok 'saved' + test.done() + +it 'should trigger after save', (test) -> + User.afterSave = (next) -> + User.afterSave = null + next() + + user = new User + user.save -> + test.ok "saved" + test.done() + +it "should trigger before update", (test) -> + User.beforeUpdate = () -> + User.beforeUpdate = null + test.done() + User.create {}, (err, user) -> + user.updateAttributes email:"1@1.com", -> test.ok "updated" + +it "should trigger after update", (test) -> + User.afterUpdate = () -> + User.afterUpdate = null + test.done() + User.create (err, user) -> + user.updateAttributes email: "1@1.com", -> test.ok "updated" + +it "should trigger before destroy", (test)-> + User.beforeDestroy = () -> + User.beforeDestroy = null + test.done() + User.create {}, (err, user) -> + user.destroy() + +it "should trigger after destroy", (test) -> + User.afterDestroy = () -> + User.afterDestroy = null + test.done() + User.create (err, user) -> + user.destroy() + +it 'allows me to modify attributes before saving', (test) -> + test.done() diff --git a/test/migration_test.coffee b/test/migration_test.coffee new file mode 100644 index 0000000..f4a2d52 --- /dev/null +++ b/test/migration_test.coffee @@ -0,0 +1,210 @@ +juggling = require('../index') +Schema = juggling.Schema +Text = Schema.Text + +DBNAME = process.env.DBNAME || 'myapp_test' +DBUSER = process.env.DBUSER || 'root' +DBPASS = '' +DBENGINE = process.env.DBENGINE || 'mysql' + +require('./spec_helper').init module.exports + +schema = new Schema DBENGINE, database: '', username: DBUSER, password: DBPASS +schema.log = (q) -> console.log q + +query = (sql, cb) -> + schema.adapter.query sql, cb + +User = schema.define 'User', + email: { type: String, null: false, index: true } + name: String + bio: Text + password: String + birthDate: Date + pendingPeriod: Number + createdByAdmin: Boolean +, indexes: + index1: + columns: 'email, createdByAdmin' + +withBlankDatabase = (cb) -> + db = schema.settings.database = DBNAME + query 'DROP DATABASE IF EXISTS ' + db, (err) -> + query 'CREATE DATABASE ' + db, (err) -> + query 'USE '+ db, cb + +getFields = (model, cb) -> + query 'SHOW FIELDS FROM ' + model, (err, res) -> + if err + cb err + else + fields = {} + res.forEach (field) -> fields[field.Field] = field + cb err, fields + +getIndexes = (model, cb) -> + query 'SHOW INDEXES FROM ' + model, (err, res) -> + if err + console.log err + cb err + else + indexes = {} + res.forEach (index) -> + indexes[index.Key_name] = index if index.Seq_in_index == '1' + cb err, indexes + +it 'should run migration', (test) -> + withBlankDatabase (err) -> + schema.automigrate -> + getFields 'User', (err, fields) -> + test.deepEqual fields, + id: + Field: 'id' + Type: 'int(11)' + Null: 'NO' + Key: 'PRI' + Default: null + Extra: 'auto_increment' + email: + Field: 'email' + Type: 'varchar(255)' + Null: 'NO' + Key: '' + Default: null + Extra: '' + name: + Field: 'name' + Type: 'varchar(255)' + Null: 'YES' + Key: '' + Default: null + Extra: '' + bio: + Field: 'bio' + Type: 'text' + Null: 'YES' + Key: '' + Default: null + Extra: '' + password: + Field: 'password' + Type: 'varchar(255)' + Null: 'YES' + Key: '' + Default: null + Extra: '' + birthDate: + Field: 'birthDate' + Type: 'datetime' + Null: 'YES' + Key: '' + Default: null + Extra: '' + pendingPeriod: + Field: 'pendingPeriod' + Type: 'int(11)' + Null: 'YES' + Key: '' + Default: null + Extra: '' + createdByAdmin: + Field: 'createdByAdmin' + Type: 'tinyint(1)' + Null: 'YES' + Key: '' + Default: null + Extra: '' + + test.done() + +it 'should autoupgrade', (test) -> + userExists = (cb) -> + query 'SELECT * FROM User', (err, res) -> + cb(not err and res[0].email == 'test@example.com') + + User.create email: 'test@example.com', (err, user) -> + test.ok not err + userExists (yep) -> + test.ok yep + User.defineProperty 'email', type: String + User.defineProperty 'name', type: String, limit: 50 + User.defineProperty 'newProperty', type: Number + User.defineProperty 'pendingPeriod', false + schema.autoupdate (err) -> + getFields 'User', (err, fields) -> + # change nullable for email + test.equal fields.email.Null, 'YES', 'Email is not null' + # change type of name + test.equal fields.name.Type, 'varchar(50)', 'Name is not varchar(50)' + # add new column + test.ok fields.newProperty, 'New column was not added' + if fields.newProperty + test.equal fields.newProperty.Type, 'int(11)', 'New column type is not int(11)' + # drop column + test.ok not fields.pendingPeriod, 'drop column' + + # user still exists + userExists (yep) -> + test.ok yep + test.done() + +it 'should check actuality of schema', (test) -> + # drop column + User.schema.isActual (err, ok) -> + test.ok ok, 'schema is actual' + User.defineProperty 'email', false + User.schema.isActual (err, ok) -> + test.ok not ok, 'schema is not actual' + test.done() + +it 'should add single-column index', (test) -> + User.defineProperty 'email', type: String, index: { kind: 'FULLTEXT', type: 'HASH'} + User.schema.autoupdate (err) -> + return console.log(err) if err + getIndexes 'User', (err, ixs) -> + test.ok ixs.email && ixs.email.Column_name == 'email' + console.log(ixs) + test.equal ixs.email.Index_type, 'BTREE', 'default index type' + test.done() + +it 'should change type of single-column index', (test) -> + User.defineProperty 'email', type: String, index: { type: 'BTREE' } + User.schema.isActual (err, ok) -> + test.ok ok, 'schema is actual' + User.schema.autoupdate (err) -> + return console.log(err) if err + getIndexes 'User', (err, ixs) -> + test.ok ixs.email && ixs.email.Column_name == 'email' + test.equal ixs.email.Index_type, 'BTREE' + test.done() + +it 'should remove single-column index', (test) -> + User.defineProperty 'email', type: String, index: false + User.schema.autoupdate (err) -> + return console.log(err) if err + getIndexes 'User', (err, ixs) -> + test.ok !ixs.email + test.done() + +it 'should update multi-column index when order of columns changed', (test) -> + User.schema.adapter._models.User.settings.indexes.index1.columns = 'createdByAdmin, email' + User.schema.isActual (err, ok) -> + test.ok not ok, 'schema is not actual' + User.schema.autoupdate (err) -> + return console.log(err) if err + getIndexes 'User', (err, ixs) -> + test.equals ixs.index1.Column_name, 'createdByAdmin' + test.done() + + +it 'test', (test) -> + User.defineProperty 'email', type: String, index: true + User.schema.autoupdate (err) -> + User.schema.autoupdate (err) -> + User.schema.autoupdate (err) -> + test.done() + +it 'should disconnect when done', (test) -> + schema.disconnect() + test.done() + diff --git a/test/performance.coffee b/test/performance.coffee new file mode 100644 index 0000000..0119b8e --- /dev/null +++ b/test/performance.coffee @@ -0,0 +1,81 @@ +Schema = require('../index').Schema +Text = Schema.Text + +require('./spec_helper').init exports + +schemas = + neo4j: + url: 'http://localhost:7474/' + mongoose: + url: 'mongodb://localhost/test' + redis: {} + memory: {} + cradle: {} + +testOrm = (schema) -> + + User = Post = 'unknown' + maxUsers = 100 + maxPosts = 50000 + users = [] + + it 'should define simple', (test) -> + + User = schema.define 'User', { + name: String, + bio: Text, + approved: Boolean, + joinedAt: Date, + age: Number + } + + Post = schema.define 'Post', + title: { type: String, length: 255, index: true } + content: { type: Text } + date: { type: Date, detault: Date.now } + published: { type: Boolean, default: false } + + User.hasMany(Post, {as: 'posts', foreignKey: 'userId'}) + Post.belongsTo(User, {as: 'author', foreignKey: 'userId'}) + + test.done() + + it 'should create users', (test) -> + wait = maxUsers + done = (e, u) -> + users.push(u) + test.done() if --wait == 0 + User.create(done) for i in [1..maxUsers] + + it 'should create bunch of data', (test) -> + wait = maxPosts + done = -> test.done() if --wait == 0 + rnd = (title) -> + { + userId: users[Math.floor(Math.random() * maxUsers)].id + title: 'Post number ' + (title % 5) + } + Post.create(rnd(num), done) for num in [1..maxPosts] + + it 'do some queries using foreign keys', (test) -> + wait = 4 + done = -> test.done() if --wait == 0 + ts = Date.now() + query = (num) -> + users[num].posts { title: 'Post number 3' }, (err, collection) -> + console.log('User ' + num + ':', collection.length, 'posts in', Date.now() - ts,'ms') + done() + query num for num in [0..4] + + return + + it 'should destroy all data', (test) -> + Post.destroyAll -> + User.destroyAll(test.done) + +Object.keys(schemas).forEach (schemaName) -> + return if process.env.ONLY && process.env.ONLY != schemaName + context schemaName, -> + schema = new Schema schemaName, schemas[schemaName] + testOrm(schema) + diff --git a/test/spec_helper.js b/test/spec_helper.js new file mode 100644 index 0000000..d0c13d6 --- /dev/null +++ b/test/spec_helper.js @@ -0,0 +1,54 @@ +if (!process.env.TRAVIS) { + var semicov = require('semicov'); + semicov.init('lib'); + process.on('exit', semicov.report); +} + +try { + global.sinon = require('sinon'); +} catch (e) { + // ignore +} + +var group_name = false, EXT_EXP; +function it (should, test_case) { + check_external_exports(); + if (group_name) { + EXT_EXP[group_name][should] = test_case; + } else { + EXT_EXP[should] = test_case; + } +} + +global.it = it; + +function context(name, tests) { + check_external_exports(); + EXT_EXP[name] = {}; + group_name = name; + tests({ + before: function (f) { + it('setUp', f); + }, + after: function (f) { + it('tearDown', f); + } + }); + group_name = false; +} + +global.context = context; + +exports.init = function (external_exports) { + EXT_EXP = external_exports; + if (external_exports.done) { + external_exports.done(); + } +}; + +function check_external_exports() { + if (!EXT_EXP) throw new Error( + 'Before run this, please ensure that ' + + 'require("spec_helper").init(exports); called'); +} + diff --git a/test/validations_test.coffee b/test/validations_test.coffee new file mode 100644 index 0000000..72e9b33 --- /dev/null +++ b/test/validations_test.coffee @@ -0,0 +1,90 @@ +juggling = require('../index') +Schema = juggling.Schema +AbstractClass = juggling.AbstractClass +Validatable = juggling.Validatable + +require('./spec_helper').init module.exports + +schema = new Schema 'memory' +User = schema.define 'User', + email: String + name: String + password: String + state: String + age: Number + gender: String + domain: String + pendingPeriod: Number + createdByAdmin: Boolean + createdByScript: Boolean + updatedAt: Date + +validAttributes = + name: 'Anatoliy' + email: 'email@example.com' + state: '' + age: 26 + gender: 'male' + domain: '1602' + createdByAdmin: false + createdByScript: true + +getValidAttributes = -> + name: 'Anatoliy' + email: 'email@example.com' + state: '' + age: 26 + gender: 'male' + domain: '1602' + createdByAdmin: false + createdByScript: true + +it 'should validate presence', (test) -> + User.validatesPresenceOf 'email', 'name' + + user = new User + test.ok not user.isValid(), 'User is not valid' + test.ok user.errors.email, 'Attr email in errors' + test.ok user.errors.name, 'Attr name in errors' + + user.name = 'Anatoliy' + test.ok not user.isValid(), 'User is still not valid' + test.ok user.errors.email, 'Attr email still in errors' + test.ok not user.errors.name, 'Attr name valid' + + user.email = 'anatoliy@localhost' + test.ok user.isValid(), 'User is valid' + test.ok not user.errors, 'No errors' + test.ok not user.errors.email, 'Attr email valid' + test.ok not user.errors.name, 'Attr name valid' + test.done() + +it 'should allow to skip validations', (test) -> + User.validatesPresenceOf 'pendingPeriod', if: 'createdByAdmin' + User.validatesLengthOf 'domain', is: 2, unless: 'createdByScript' + + user = new User validAttributes + test.ok user.isValid() + + user.createdByAdmin = true + test.ok not user.isValid() + test.ok user.errors.pendingPeriod.length + + user.pendingPeriod = 1 + test.ok user.isValid() + + user.createdByScript = false + test.ok not user.isValid() + test.ok user.errors.domain.length + + user.domain = '12' + test.ok user.isValid() + + User.validatesLengthOf 'domain', is: 3, unless: -> @domain != 'xyz' + test.ok user.isValid() + + user.domain = 'xyz' + test.ok not user.isValid() # is: 3 passed, but is: 2 failed + + test.done() +