diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 84ebc44e..63592e8c 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -20,6 +20,7 @@ var RelationTypes = { hasOne: 'hasOne', hasAndBelongsToMany: 'hasAndBelongsToMany', referencesMany: 'referencesMany', + embedsOne: 'embedsOne', embedsMany: 'embedsMany' }; @@ -30,6 +31,7 @@ exports.HasOne = HasOne; exports.HasAndBelongsToMany = HasAndBelongsToMany; exports.BelongsTo = BelongsTo; exports.ReferencesMany = ReferencesMany; +exports.EmbedsOne = EmbedsOne; exports.EmbedsMany = EmbedsMany; var RelationClasses = { @@ -39,6 +41,7 @@ var RelationClasses = { hasOne: HasOne, hasAndBelongsToMany: HasAndBelongsToMany, referencesMany: ReferencesMany, + embedsOne: EmbedsOne, embedsMany: EmbedsMany }; @@ -384,6 +387,24 @@ function HasOne(definition, modelInstance) { util.inherits(HasOne, Relation); +/** + * EmbedsOne subclass + * @param {RelationDefinition|Object} definition + * @param {Object} modelInstance + * @returns {EmbedsMany} + * @constructor + * @class EmbedsOne + */ +function EmbedsOne(definition, modelInstance) { + if (!(this instanceof EmbedsOne)) { + return new EmbedsMany(definition, modelInstance); + } + assert(definition.type === RelationTypes.embedsOne); + Relation.apply(this, arguments); +} + +util.inherits(EmbedsOne, Relation); + /** * EmbedsMany subclass * @param {RelationDefinition|Object} definition @@ -1539,6 +1560,170 @@ HasOne.prototype.related = function (refresh, params) { } }; +RelationDefinition.embedsOne = function (modelFrom, modelTo, params) { + params = params || {}; + modelTo = lookupModelTo(modelFrom, modelTo, params); + + var thisClassName = modelFrom.modelName; + var relationName = params.as || (i8n.camelize(modelTo.modelName, true) + 'Item'); + var propertyName = params.property || i8n.camelize(modelTo.modelName, true); + var idName = modelTo.dataSource.idName(modelTo.modelName) || 'id'; + + if (relationName === propertyName) { + propertyName = '_' + propertyName; + debug('EmbedsOne property cannot be equal to relation name: ' + + 'forcing property %s for relation %s', propertyName, relationName); + } + + var definition = modelFrom.relations[relationName] = new RelationDefinition({ + name: relationName, + type: RelationTypes.embedsOne, + modelFrom: modelFrom, + keyFrom: propertyName, + keyTo: idName, + modelTo: modelTo, + multiple: false, + properties: params.properties, + scope: params.scope, + options: params.options, + embed: true + }); + + var opts = { type: modelTo }; + + if (params.default === true) { + opts.default = function() { return new modelTo(); }; + } else if (typeof params.default === 'object') { + opts.default = (function(def) { + return function() { return new modelTo(def); }; + }(params.default)); + } + + modelFrom.dataSource.defineProperty(modelFrom.modelName, propertyName, opts); + + // validate the embedded instance + if (definition.options.validate) { + modelFrom.validate(relationName, function(err) { + var inst = this[propertyName]; + if (inst instanceof modelTo) { + if (!inst.isValid()) { + var first = Object.keys(inst.errors)[0]; + var msg = 'is invalid: `' + first + '` ' + inst.errors[first]; + this.errors.add(relationName, msg, 'invalid'); + err(false); + } + } + }); + } + + // Define a property for the scope so that we have 'this' for the scoped methods + Object.defineProperty(modelFrom.prototype, relationName, { + enumerable: true, + configurable: true, + get: function() { + var relation = new EmbedsOne(definition, this); + var relationMethod = relation.related.bind(relation) + relationMethod.create = relation.create.bind(relation); + relationMethod.build = relation.build.bind(relation); + relationMethod.destroy = relation.destroy.bind(relation); + relationMethod._targetClass = definition.modelTo.modelName; + return relationMethod; + } + }); + + // FIXME: [rfeng] Wrap the property into a function for remoting + // so that it can be accessed as /api/// + // For example, /api/orders/1/customer + var fn = function() { + var f = this[relationName]; + f.apply(this, arguments); + }; + modelFrom.prototype['__get__' + relationName] = fn; + + return definition; +}; + +EmbedsOne.prototype.related = function (refresh, params) { + var modelTo = this.definition.modelTo; + var modelInstance = this.modelInstance; + var propertyName = this.definition.keyFrom; + + if (arguments.length === 1) { + params = refresh; + refresh = false; + } else if (arguments.length > 2) { + throw new Error('Method can\'t be called with more than two arguments'); + } + + if (params instanceof ModelBaseClass) { // acts as setter + if (params instanceof modelTo) { + this.definition.applyProperties(modelInstance, params); + modelInstance.setAttribute(propertyName, params); + } + return; + } + + var embeddedInstance = modelInstance[propertyName]; + if (typeof params === 'function') { // acts as async getter + var cb = params; + process.nextTick(function() { + cb(null, embeddedInstance); + }); + } else if (params === undefined) { // acts as sync getter + return embeddedInstance; + } +}; + +EmbedsOne.prototype.create = function (targetModelData, cb) { + var modelTo = this.definition.modelTo; + var propertyName = this.definition.keyFrom; + var modelInstance = this.modelInstance; + + if (typeof targetModelData === 'function' && !cb) { + cb = targetModelData; + targetModelData = {}; + } + + targetModelData = targetModelData || {}; + + var inst = this.build(targetModelData); + + var err = inst.isValid() ? null : new ValidationError(inst); + + if (err) { + return process.nextTick(function() { + cb(err); + }); + } + + modelInstance.updateAttribute(propertyName, + inst, function(err) { + cb(err, err ? null : inst); + }); +}; + +EmbedsOne.prototype.build = function (targetModelData) { + var modelTo = this.definition.modelTo; + var modelInstance = this.modelInstance; + + targetModelData = targetModelData || {}; + + this.definition.applyProperties(modelInstance, targetModelData); + + return new modelTo(targetModelData); +}; + +EmbedsOne.prototype.destroy = function (cb) { + var modelInstance = this.modelInstance; + var propertyName = this.definition.keyFrom; + modelInstance.unsetAttribute(propertyName, true); + if (typeof cb === 'function') { + modelInstance.save(function(err) { + cb(err); + }); + } +}; + RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) { params = params || {}; modelTo = lookupModelTo(modelFrom, modelTo, params, true); @@ -1602,7 +1787,7 @@ RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) var id = item[idName] || '(blank)'; var first = Object.keys(item.errors)[0]; var msg = 'contains invalid item: `' + id + '`'; - msg += ' (' + first + ' ' + item.errors[first] + ')'; + msg += ' (`' + first + '` ' + item.errors[first] + ')'; self.errors.add(propertyName, msg, 'invalid'); } } else { @@ -1716,15 +1901,11 @@ EmbedsMany.prototype.related = function(receiver, scopeParams, condOrRefresh, cb var modelInstance = this.modelInstance; var actualCond = {}; - var actualRefresh = false; if (arguments.length === 3) { cb = condOrRefresh; } else if (arguments.length === 4) { - if (typeof condOrRefresh === 'boolean') { - actualRefresh = condOrRefresh; - } else { + if (typeof condOrRefresh === 'object') { actualCond = condOrRefresh; - actualRefresh = true; } } else { throw new Error('Method can be only called with one or two arguments'); @@ -1923,7 +2104,7 @@ EmbedsMany.prototype.build = function(targetModelData) { } } - this.definition.applyProperties(this.modelInstance, targetModelData); + this.definition.applyProperties(modelInstance, targetModelData); var inst = new modelTo(targetModelData); diff --git a/lib/relations.js b/lib/relations.js index ba9a36d7..790433fc 100644 --- a/lib/relations.js +++ b/lib/relations.js @@ -158,14 +158,18 @@ RelationMixin.hasAndBelongsToMany = function hasAndBelongsToMany(modelTo, params return RelationDefinition.hasAndBelongsToMany(this, modelTo, params); }; -RelationMixin.hasOne = function hasMany(modelTo, params) { +RelationMixin.hasOne = function hasOne(modelTo, params) { return RelationDefinition.hasOne(this, modelTo, params); }; -RelationMixin.referencesMany = function hasMany(modelTo, params) { +RelationMixin.referencesMany = function referencesMany(modelTo, params) { return RelationDefinition.referencesMany(this, modelTo, params); }; -RelationMixin.embedsMany = function hasMany(modelTo, params) { +RelationMixin.embedsOne = function embedsOne(modelTo, params) { + return RelationDefinition.embedsOne(this, modelTo, params); +}; + +RelationMixin.embedsMany = function embedsMany(modelTo, params) { return RelationDefinition.embedsMany(this, modelTo, params); }; diff --git a/test/relations.test.js b/test/relations.test.js index 871ecb54..564102cb 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1320,6 +1320,131 @@ describe('relations', function () { }); }); + describe('embedsOne', function () { + + var person; + var Other; + + before(function () { + db = getSchema(); + Person = db.define('Person', {name: String}); + Passport = db.define('Passport', + {name:{type:'string', required: true}}, + {idInjection: false} + ); + Other = db.define('Other', {name: String}); + }); + + it('can be declared using embedsOne method', function () { + Person.embedsOne(Passport, { + default: {name: 'Anonymous'}, // a bit contrived + options: {validate: true} + }); + }); + + it('should have setup a property and accessor', function() { + var p = new Person(); + p.passport.should.be.an.object; // because of default + p.passportItem.should.be.a.function; + p.passportItem.create.should.be.a.function; + p.passportItem.build.should.be.a.function; + p.passportItem.destroy.should.be.a.function; + }); + + it('should return an instance with default values', function() { + var p = new Person(); + p.passport.toObject().should.eql({name: 'Anonymous'}); + p.passportItem().should.equal(p.passport); + p.passportItem(function(err, passport) { + should.not.exist(err); + passport.should.equal(p.passport); + }); + }); + + it('should embed a model instance', function() { + var p = new Person(); + p.passportItem(new Passport({name: 'Fred'})); + p.passport.toObject().should.eql({name: 'Fred'}); + p.passport.should.be.an.instanceOf(Passport); + }); + + it('should not embed an invalid model type', function() { + var p = new Person(); + p.passportItem(new Other()); + p.passport.toObject().should.eql({name: 'Anonymous'}); + p.passport.should.be.an.instanceOf(Passport); + }); + + it('should create an embedded item on scope', function(done) { + Person.create({name: 'Fred'}, function(err, p) { + should.not.exist(err); + p.passportItem.create({name: 'Fredric'}, function(err, passport) { + should.not.exist(err); + p.passport.toObject().should.eql({name: 'Fredric'}); + p.passport.should.be.an.instanceOf(Passport); + done(); + }); + }); + }); + + it('should get an embedded item on scope', function(done) { + Person.findOne(function(err, p) { + should.not.exist(err); + var passport = p.passportItem(); + passport.toObject().should.eql({name: 'Fredric'}); + passport.should.be.an.instanceOf(Passport); + passport.should.equal(p.passport); + done(); + }); + }); + + it('should validate an embedded item on scope - on creation', function(done) { + var p = new Person({name: 'Fred'}); + p.passportItem.create({}, function(err, passport) { + should.exist(err); + err.name.should.equal('ValidationError'); + var msg = 'The `Passport` instance is not valid.'; + msg += ' Details: `name` can\'t be blank.'; + err.message.should.equal(msg); + done(); + }); + }); + + it('should validate an embedded item on scope - on update', function(done) { + Person.findOne(function(err, p) { + var passport = p.passportItem(); + passport.name = null; + p.save(function(err) { + should.exist(err); + err.name.should.equal('ValidationError'); + var msg = 'The `Person` instance is not valid.'; + msg += ' Details: `passportItem` is invalid: `name` can\'t be blank.'; + err.message.should.equal(msg); + done(); + }); + }); + }); + + it('should destroy an embedded item on scope', function(done) { + Person.findOne(function(err, p) { + p.passportItem.destroy(function(err) { + should.not.exist(err); + should.equal(p.passport, null); + done(); + }); + }); + }); + + it('should get an embedded item on scope - verify', function(done) { + Person.findOne(function(err, p) { + should.not.exist(err); + should.equal(p.passport, null); + done(); + }); + }); + + }); + describe('embedsMany', function () { var address1, address2; @@ -1591,7 +1716,7 @@ describe('relations', function () { Person.create({ name: 'Wilma', addresses: addresses }, function(err, p) { err.name.should.equal('ValidationError'); var expected = 'The `Person` instance is not valid. '; - expected += 'Details: `addresses` contains invalid item: `work` (street can\'t be blank).'; + expected += 'Details: `addresses` contains invalid item: `work` (`street` can\'t be blank).'; err.message.should.equal(expected); done(); });