diff --git a/lib/connectors/memory.js b/lib/connectors/memory.js index 829cca34..97687cf2 100644 --- a/lib/connectors/memory.js +++ b/lib/connectors/memory.js @@ -17,6 +17,7 @@ exports.initialize = function initializeDataSource(dataSource, callback) { }; exports.Memory = Memory; +exports.applyFilter = applyFilter; function Memory(m, settings) { if (m instanceof Memory) { diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 856e252d..6062a7c8 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -7,6 +7,8 @@ var i8n = require('inflection'); var defineScope = require('./scope.js').defineScope; var mergeQuery = require('./scope.js').mergeQuery; var ModelBaseClass = require('./model.js'); +var applyFilter = require('./connectors/memory').applyFilter; +var ValidationError = require('./validations.js').ValidationError; exports.Relation = Relation; exports.RelationDefinition = RelationDefinition; @@ -15,7 +17,8 @@ var RelationTypes = { belongsTo: 'belongsTo', hasMany: 'hasMany', hasOne: 'hasOne', - hasAndBelongsToMany: 'hasAndBelongsToMany' + hasAndBelongsToMany: 'hasAndBelongsToMany', + embedsMany: 'embedsMany' }; exports.RelationTypes = RelationTypes; @@ -24,13 +27,15 @@ exports.HasManyThrough = HasManyThrough; exports.HasOne = HasOne; exports.HasAndBelongsToMany = HasAndBelongsToMany; exports.BelongsTo = BelongsTo; +exports.EmbedsMany = EmbedsMany; var RelationClasses = { belongsTo: BelongsTo, hasMany: HasMany, hasManyThrough: HasManyThrough, hasOne: HasOne, - hasAndBelongsToMany: HasAndBelongsToMany + hasAndBelongsToMany: HasAndBelongsToMany, + embedsMany: EmbedsMany }; function normalizeType(type) { @@ -75,6 +80,7 @@ function RelationDefinition(definition) { this.properties = definition.properties || {}; this.options = definition.options || {}; this.scope = definition.scope; + this.embed = definition.embed === true; } RelationDefinition.prototype.toJSON = function () { @@ -290,6 +296,23 @@ function HasOne(definition, modelInstance) { util.inherits(HasOne, Relation); +/** + * EmbedsMany subclass + * @param {RelationDefinition|Object} definition + * @param {Object} modelInstance + * @returns {EmbedsMany} + * @constructor + * @class EmbedsMany + */ +function EmbedsMany(definition, modelInstance) { + if (!(this instanceof EmbedsMany)) { + return new EmbedsMany(definition, modelInstance); + } + assert(definition.type === RelationTypes.embedsMany); + Relation.apply(this, arguments); +} + +util.inherits(EmbedsMany, Relation); /*! * Find the relation by foreign key @@ -1342,3 +1365,283 @@ HasOne.prototype.related = function (refresh, params) { self.resetCache(); } }; + +RelationDefinition.embedsMany = function hasMany(modelFrom, modelTo, params) { + var thisClassName = modelFrom.modelName; + params = params || {}; + if (typeof modelTo === 'string') { + params.as = modelTo; + if (params.model) { + modelTo = params.model; + } else { + var modelToName = i8n.singularize(modelTo).toLowerCase(); + modelTo = lookupModel(modelFrom.dataSource.modelBuilder.models, modelToName); + } + } + + if (modelTo.dataSource.name !== 'memory') { + throw new Error('Invalid embedded model: `' + modelTo.modelName + '` (memory connector only)'); + } + + var accessorName = params.as || (i8n.camelize(modelTo.modelName, true) + 'List'); + var relationName = params.property || i8n.camelize(modelTo.pluralModelName, true); + var fk = modelTo.dataSource.idName(modelTo.modelName) || 'id'; + var idName = modelFrom.dataSource.idName(modelFrom.modelName) || 'id'; + + var definition = new RelationDefinition({ + name: relationName, + type: RelationTypes.embedsMany, + modelFrom: modelFrom, + keyFrom: idName, + keyTo: fk, + modelTo: modelTo, + multiple: true, + properties: params.properties, + scope: params.scope, + options: params.options, + embed: true + }); + + modelFrom.dataSource.defineProperty(modelFrom.modelName, relationName, { + type: [modelTo], default: function() { return []; } + }); + + // require explicit/unique ids unless autoId === true + if (definition.options.autoId === false) { + modelTo.validatesPresenceOf(idName); + modelFrom.validate(relationName, function(err) { + var embeddedList = this[relationName] || []; + var ids = embeddedList.map(function(m) { return m[idName]; }); + var uniqueIds = ids.filter(function(id, pos) { + return ids.indexOf(id) === pos; + }); + if (ids.length !== uniqueIds.length) { + this.errors.add(relationName, 'Contains duplicate `' + idName + '`', 'uniqueness'); + err(false); + } + }, { code: 'uniqueness' }) + } + + var scopeMethods = { + findById: scopeMethod(definition, 'findById'), + destroy: scopeMethod(definition, 'destroyById'), + updateById: scopeMethod(definition, 'updateById'), + exists: scopeMethod(definition, 'exists') + }; + + var findByIdFunc = scopeMethods.findById; + modelFrom.prototype['__findById__' + relationName] = findByIdFunc; + + var destroyByIdFunc = scopeMethods.destroy; + modelFrom.prototype['__destroyById__' + relationName] = destroyByIdFunc; + + var updateByIdFunc = scopeMethods.updateById; + modelFrom.prototype['__updateById__' + relationName] = updateByIdFunc; + + scopeMethods.create = scopeMethod(definition, 'create'); + scopeMethods.build = scopeMethod(definition, 'build'); + + // Mix the property and scoped methods into the prototype class + var scopeDefinition = defineScope(modelFrom.prototype, modelTo, accessorName, function () { + return {}; + }, scopeMethods); + + scopeDefinition.related = scopeMethod(definition, 'related'); // bound to definition +}; + +EmbedsMany.prototype.related = function(receiver, scopeParams, condOrRefresh, cb) { + var name = this.definition.name; + var self = receiver; + + var actualCond = {}; + var actualRefresh = false; + if (arguments.length === 3) { + cb = condOrRefresh; + } else if (arguments.length === 4) { + if (typeof condOrRefresh === 'boolean') { + actualRefresh = condOrRefresh; + } else { + actualCond = condOrRefresh; + actualRefresh = true; + } + } else { + throw new Error('Method can be only called with one or two arguments'); + } + + var embeddedList = self[name] || []; + + var params = mergeQuery(actualCond, scopeParams); + if (params.where) { + embeddedList = embeddedList ? embeddedList.filter(applyFilter(params)) : embeddedList; + } + + process.nextTick(function() { cb(null, embeddedList); }); +}; + +EmbedsMany.prototype.findById = function (fkId, cb) { + var pk = this.definition.keyFrom; + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + + var embeddedList = modelInstance[relationName] || []; + + fkId = fkId.toString(); // in case of explicit id + + var find = function(id) { + for (var i = 0; i < embeddedList.length; i++) { + var item = embeddedList[i]; + if (item[pk].toString() === fkId) return item; + } + return null; + }; + + var item = find(fkId); + item = (item instanceof modelTo) ? item : null; + + if (typeof cb === 'function') { + process.nextTick(function() { + cb(null, item); + }); + }; + + return item; // sync +}; + +EmbedsMany.prototype.exists = function (fkId, cb) { + var modelTo = this.definition.modelTo; + var inst = this.findById(fkId, function (err, inst) { + if (cb) cb(err, inst instanceof modelTo); + }); + return inst instanceof modelTo; // sync +}; + +EmbedsMany.prototype.updateById = function (fkId, data, cb) { + if (typeof data === 'function') { + cb = data; + data = {}; + } + + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + + var embeddedList = modelInstance[relationName] || []; + + var inst = this.findById(fkId); + + if (inst instanceof modelTo) { + if (typeof data === 'object') { + for (var key in data) { + inst[key] = data[key]; + } + } + var err = inst.isValid() ? null : new ValidationError(inst); + if (err && typeof cb === 'function') { + return process.nextTick(function() { + cb(err, inst); + }); + } + + if (typeof cb === 'function') { + modelInstance.updateAttribute(relationName, + embeddedList, function(err) { + cb(err, inst); + }); + } + } else if (typeof cb === 'function') { + process.nextTick(function() { + cb(null, null); // not found + }); + } + return inst; // sync +}; + +EmbedsMany.prototype.destroyById = function (fkId, cb) { + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + + var embeddedList = modelInstance[relationName] || []; + + var inst = this.findById(fkId); + + if (inst instanceof modelTo) { + var index = embeddedList.indexOf(inst); + if (index > -1) embeddedList.splice(index, 1); + if (typeof cb === 'function') { + modelInstance.updateAttribute(relationName, + embeddedList, function(err) { + cb(err, inst); + }); + } + } else if (typeof cb === 'function') { + process.nextTick(function() { + cb(null, null); // not found + }); + } + return inst; // sync +}; + +EmbedsMany.prototype.create = function (targetModelData, cb) { + var pk = this.definition.keyFrom; + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + var autoId = this.definition.options.autoId !== false; + + var embeddedList = modelInstance[relationName] || []; + + if (typeof targetModelData === 'function' && !cb) { + cb = targetModelData; + targetModelData = {}; + } + targetModelData = targetModelData || {}; + + if (typeof targetModelData[pk] !== 'number' && autoId) { + var ids = embeddedList.map(function(m) { + return (typeof m[pk] === 'number' ? m[pk] : 0); + }); + targetModelData[pk] = (Math.max(ids) || 0) + 1; + } + + this.definition.applyProperties(this.modelInstance, targetModelData); + + var inst = new modelTo(targetModelData); + var err = inst.isValid() ? null : new ValidationError(inst); + + if (err) { + return process.nextTick(function() { + cb(err, embeddedList); + }); + } else if (this.definition.options.prepend) { + embeddedList.unshift(inst); + } else { + embeddedList.push(inst); + } + + modelInstance.updateAttribute(relationName, + embeddedList, function(err, modelInst) { + cb(err, modelInst[relationName]); + }); +}; + +EmbedsMany.prototype.build = HasOne.prototype.build = function(targetModelData) { + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + targetModelData = targetModelData || {}; + + this.definition.applyProperties(this.modelInstance, targetModelData); + + var embeddedList = modelInstance[relationName] || []; + + var inst = new modelTo(targetModelData); + + if (this.definition.options.prepend) { + embeddedList.unshift(inst); + } else { + embeddedList.push(inst); + } + + return inst; +}; diff --git a/lib/relations.js b/lib/relations.js index c0985f2f..493fdb92 100644 --- a/lib/relations.js +++ b/lib/relations.js @@ -161,3 +161,7 @@ RelationMixin.hasAndBelongsToMany = function hasAndBelongsToMany(modelTo, params RelationMixin.hasOne = function hasMany(modelTo, params) { RelationDefinition.hasOne(this, modelTo, params); }; + +RelationMixin.embedsMany = function hasMany(modelTo, params) { + RelationDefinition.embedsMany(this, modelTo, params); +}; diff --git a/lib/scope.js b/lib/scope.js index c1c90eba..129e0b9f 100644 --- a/lib/scope.js +++ b/lib/scope.js @@ -18,7 +18,7 @@ function ScopeDefinition(definition) { ScopeDefinition.prototype.related = function(receiver, scopeParams, condOrRefresh, cb) { var name = this.name; var self = receiver; - + var actualCond = {}; var actualRefresh = false; var saveOnCache = true; @@ -224,6 +224,8 @@ function defineScope(cls, targetClass, name, params, methods) { var where = (this._scope && this._scope.where) || {}; targetClass.destroyAll(where, cb); } + + return definition; } /*! diff --git a/test/relations.test.js b/test/relations.test.js index a6580649..a455b41a 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -4,6 +4,7 @@ var should = require('./init.js'); var db, Book, Chapter, Author, Reader; var Category, Product; var Picture, PictureLink; +var Person, Address; describe('relations', function () { @@ -1198,5 +1199,258 @@ describe('relations', function () { should.equal(Article.prototype.tags._targetClass, 'Tag'); }); }); + + describe('embedsMany', function () { + before(function (done) { + db = getSchema(); + Person = db.define('Person', {name: String}); + Address = db.define('Address', {street: String}); + Address.validatesPresenceOf('street'); + + db.automigrate(function () { + Person.destroyAll(done); + }); + }); + + it('can be declared', function (done) { + Person.embedsMany(Address); + db.automigrate(done); + }); + + it('should have setup embedded accessor/scope', function() { + var p = new Person({ name: 'Fred' }); + p.addresses.should.be.an.array; + p.addresses.should.have.length(0); + p.addressList.should.be.a.function; + p.addressList.findById.should.be.a.function; + p.addressList.updateById.should.be.a.function; + p.addressList.destroy.should.be.a.function; + p.addressList.exists.should.be.a.function; + p.addressList.create.should.be.a.function; + p.addressList.build.should.be.a.function; + }); + + it('should create embedded items on scope', function(done) { + Person.create({ name: 'Fred' }, function(err, p) { + p.addressList.create({ street: 'Street 1' }, function(err, addresses) { + should.not.exist(err); + addresses.should.have.length(1); + addresses[0].id.should.equal(1); + addresses[0].street.should.equal('Street 1'); + done(); + }); + }); + }); + + it('should create embedded items on scope', function(done) { + Person.findOne(function(err, p) { + p.addressList.create({ street: 'Street 2' }, function(err, addresses) { + should.not.exist(err); + addresses.should.have.length(2); + addresses[0].id.should.equal(1); + addresses[0].street.should.equal('Street 1'); + addresses[1].id.should.equal(2); + addresses[1].street.should.equal('Street 2'); + done(); + }); + }); + }); + + it('should return embedded items from scope', function(done) { + Person.findOne(function(err, p) { + p.addressList(function(err, addresses) { + should.not.exist(err); + addresses.should.have.length(2); + addresses[0].id.should.equal(1); + addresses[0].street.should.equal('Street 1'); + addresses[1].id.should.equal(2); + addresses[1].street.should.equal('Street 2'); + done(); + }); + }); + }); + + it('should filter embedded items on scope', function(done) { + Person.findOne(function(err, p) { + p.addressList({ where: { street: 'Street 2' } }, function(err, addresses) { + should.not.exist(err); + addresses.should.have.length(1); + addresses[0].id.should.equal(2); + addresses[0].street.should.equal('Street 2'); + done(); + }); + }); + }); + + it('should validate embedded items', function(done) { + Person.findOne(function(err, p) { + p.addressList.create({}, function(err, addresses) { + should.exist(err); + err.name.should.equal('ValidationError'); + err.details.codes.street.should.eql(['presence']); + addresses.should.have.length(2); + done(); + }); + }); + }); + + it('should find embedded items by id', function(done) { + Person.findOne(function(err, p) { + p.addressList.findById(2, function(err, address) { + address.should.be.instanceof(Address); + address.id.should.equal(2); + address.street.should.equal('Street 2'); + done(); + }); + }); + }); + + it('should check if item exists', function(done) { + Person.findOne(function(err, p) { + p.addressList.exists(2, function(err, exists) { + should.not.exist(err); + exists.should.be.true; + done(); + }); + }); + }); + + it('should update embedded items by id', function(done) { + Person.findOne(function(err, p) { + p.addressList.updateById(2, { street: 'New Street' }, function(err, address) { + address.should.be.instanceof(Address); + address.id.should.equal(2); + address.street.should.equal('New Street'); + done(); + }); + }); + }); + + it('should validate the update of embedded items', function(done) { + Person.findOne(function(err, p) { + p.addressList.updateById(2, { street: null }, function(err, address) { + err.name.should.equal('ValidationError'); + err.details.codes.street.should.eql(['presence']); + done(); + }); + }); + }); + + it('should find embedded items by id - verify', function(done) { + Person.findOne(function(err, p) { + p.addressList.findById(2, function(err, address) { + address.should.be.instanceof(Address); + address.id.should.equal(2); + address.street.should.equal('New Street'); + done(); + }); + }); + }); + + it('should remove embedded items by id', function(done) { + Person.findOne(function(err, p) { + p.addresses.should.have.length(2); + p.addressList.destroy(1, function(err) { + should.not.exist(err); + p.addresses.should.have.length(1); + done(); + }); + }); + }); + + it('should have embedded items - verify', function(done) { + Person.findOne(function(err, p) { + p.addresses.should.have.length(1); + done(); + }); + }); + + }); + + describe('embedsMany - explicit ids', function () { + before(function (done) { + db = getSchema(); + Person = db.define('Person', {name: String}); + Address = db.define('Address', {id: { type: String, id: true }, street: String}); + Address.validatesPresenceOf('street'); + + db.automigrate(function () { + Person.destroyAll(done); + }); + }); + + it('can be declared', function (done) { + Person.embedsMany(Address, { options: { autoId: false } }); + db.automigrate(done); + }); + + it('should create embedded items on scope', function(done) { + Person.create({ name: 'Fred' }, function(err, p) { + p.addressList.create({ id: 'home', street: 'Street 1' }, function(err, addresses) { + should.not.exist(err); + p.addressList.create({ id: 'work', street: 'Work Street 2' }, function(err, addresses) { + addresses.should.have.length(2); + addresses[0].id.should.equal('home'); + addresses[0].street.should.equal('Street 1'); + addresses[1].id.should.equal('work'); + addresses[1].street.should.equal('Work Street 2'); + done(); + }); + }); + }); + }); + + it('should find embedded items by id', function(done) { + Person.findOne(function(err, p) { + p.addressList.findById('work', function(err, address) { + address.should.be.instanceof(Address); + address.id.should.equal('work'); + address.street.should.equal('Work Street 2'); + done(); + }); + }); + }); + + it('should check for duplicate ids', function(done) { + Person.findOne(function(err, p) { + p.addressList.create({ id: 'home', street: 'Invalid' }, function(err, addresses) { + should.exist(err); + err.name.should.equal('ValidationError'); + err.details.codes.addresses.should.eql(['uniqueness']); + done(); + }); + }); + }); + + it('should update embedded items by id', function(done) { + Person.findOne(function(err, p) { + p.addressList.updateById('home', { street: 'New Street' }, function(err, address) { + address.should.be.instanceof(Address); + address.id.should.equal('home'); + address.street.should.equal('New Street'); + done(); + }); + }); + }); + + it('should remove embedded items by id', function(done) { + Person.findOne(function(err, p) { + p.addresses.should.have.length(2); + p.addressList.destroy('home', function(err) { + should.not.exist(err); + p.addresses.should.have.length(1); + done(); + }); + }); + }); + + it('should have embedded items - verify', function(done) { + Person.findOne(function(err, p) { + p.addresses.should.have.length(1); + done(); + }); + }); + + }); });