From c19aca906d76046650a16595bf7e4427bed74adc Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 19 Aug 2014 22:05:27 +0200 Subject: [PATCH 01/18] Implement DAO unsetAttribute --- lib/dao.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/dao.js b/lib/dao.js index 3d76be09..883b10e0 100644 --- a/lib/dao.js +++ b/lib/dao.js @@ -1113,6 +1113,15 @@ DataAccessObject.prototype.setAttributes = function setAttributes(data) { Model.emit('set', inst); }; +DataAccessObject.prototype.unsetAttribute = function unsetAttribute(name, nullify) { + if (nullify) { + this[name] = this.__data[name] = null; + } else { + delete this[name]; + delete this.__data[name]; + } +}; + /** * Update set of attributes. * Performs validation before updating. From 2b262c04f6eb822d66bfd75ba8d736405e310ace Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 19 Aug 2014 22:06:55 +0200 Subject: [PATCH 02/18] Coerce embedded model types --- lib/model.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/model.js b/lib/model.js index b1c28a99..736f3667 100644 --- a/lib/model.js +++ b/lib/model.js @@ -175,7 +175,8 @@ ModelBaseClass.prototype._initProperties = function (data, options) { // Handle complex types (JSON/Object) var type = properties[p].type; - if (! BASE_TYPES[type.name]) { + if (!BASE_TYPES[type.name]) { + if (typeof self.__data[p] !== 'object' && self.__data[p]) { try { self.__data[p] = JSON.parse(self.__data[p] + ''); @@ -183,7 +184,14 @@ ModelBaseClass.prototype._initProperties = function (data, options) { self.__data[p] = String(self.__data[p]); } } - if (type.name === 'Array' || Array.isArray(type)) { + + if (type.prototype instanceof ModelBaseClass) { + if (!(self.__data[p] instanceof type) + && typeof self.__data[p] === 'object' + && self.__data[p] !== null ) { + self.__data[p] = new type(self.__data[p]); + } + } else if (type.name === 'Array' || Array.isArray(type)) { if (!(self.__data[p] instanceof List) && self.__data[p] !== undefined && self.__data[p] !== null ) { From 7c901af6d95d2c6a55e538a74d92357c88ef6067 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 19 Aug 2014 22:10:35 +0200 Subject: [PATCH 03/18] Implemented embedsOne --- lib/relation-definition.js | 195 +++++++++++++++++++++++++++++++++++-- lib/relations.js | 10 +- test/relations.test.js | 127 +++++++++++++++++++++++- 3 files changed, 321 insertions(+), 11 deletions(-) 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(); }); From 6f815526b0f3136f9ecfdf15018890f6c104b883 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 19 Aug 2014 22:15:30 +0200 Subject: [PATCH 04/18] Validate embedded models by default --- lib/relation-definition.js | 4 ++-- test/relations.test.js | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 63592e8c..b0378a4c 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1602,7 +1602,7 @@ RelationDefinition.embedsOne = function (modelFrom, modelTo, params) { modelFrom.dataSource.defineProperty(modelFrom.modelName, propertyName, opts); // validate the embedded instance - if (definition.options.validate) { + if (definition.options.validate !== false) { modelFrom.validate(relationName, function(err) { var inst = this[propertyName]; if (inst instanceof modelTo) { @@ -1775,7 +1775,7 @@ RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) } // validate all embedded items - if (definition.options.validate) { + if (definition.options.validate !== false) { modelFrom.validate(propertyName, function(err) { var self = this; var embeddedList = this[propertyName] || []; diff --git a/test/relations.test.js b/test/relations.test.js index 564102cb..cf875581 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1337,8 +1337,7 @@ describe('relations', function () { it('can be declared using embedsOne method', function () { Person.embedsOne(Passport, { - default: {name: 'Anonymous'}, // a bit contrived - options: {validate: true} + default: {name: 'Anonymous'} // a bit contrived }); }); @@ -1640,7 +1639,7 @@ describe('relations', function () { }); it('can be declared', function (done) { - Person.embedsMany(Address, { options: { autoId: false, validate: true } }); + Person.embedsMany(Address, { options: { autoId: false } }); db.automigrate(done); }); From 0290109cdbfa3b0895c7563ef9dadb8f0c62619f Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Tue, 19 Aug 2014 15:23:37 -0700 Subject: [PATCH 05/18] Fix test cases --- test/relations.test.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/test/relations.test.js b/test/relations.test.js index cf875581..55f0ae58 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1373,10 +1373,12 @@ describe('relations', function () { p.passport.toObject().should.eql({name: 'Anonymous'}); p.passport.should.be.an.instanceOf(Passport); }); - + + var personId; it('should create an embedded item on scope', function(done) { Person.create({name: 'Fred'}, function(err, p) { should.not.exist(err); + personId = p.id; p.passportItem.create({name: 'Fredric'}, function(err, passport) { should.not.exist(err); p.passport.toObject().should.eql({name: 'Fredric'}); @@ -1387,7 +1389,7 @@ describe('relations', function () { }); it('should get an embedded item on scope', function(done) { - Person.findOne(function(err, p) { + Person.findById(personId, function(err, p) { should.not.exist(err); var passport = p.passportItem(); passport.toObject().should.eql({name: 'Fredric'}); @@ -1410,7 +1412,7 @@ describe('relations', function () { }); it('should validate an embedded item on scope - on update', function(done) { - Person.findOne(function(err, p) { + Person.findById(personId, function(err, p) { var passport = p.passportItem(); passport.name = null; p.save(function(err) { @@ -1425,7 +1427,7 @@ describe('relations', function () { }); it('should destroy an embedded item on scope', function(done) { - Person.findOne(function(err, p) { + Person.findById(personId, function(err, p) { p.passportItem.destroy(function(err) { should.not.exist(err); should.equal(p.passport, null); @@ -1435,7 +1437,7 @@ describe('relations', function () { }); it('should get an embedded item on scope - verify', function(done) { - Person.findOne(function(err, p) { + Person.findById(personId, function(err, p) { should.not.exist(err); should.equal(p.passport, null); done(); From a446551a59781cd296af1a6c278401dc512f8b72 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Wed, 20 Aug 2014 14:03:38 +0200 Subject: [PATCH 06/18] Fix relations for RDBMS connectors (mysql, postgresql) - fix tests (explicit model/property definitions) - fix include vs. RDBMS model strictness --- lib/dao.js | 2 +- lib/include.js | 4 +++- lib/model.js | 25 +++++++++++++++++++------ lib/relation-definition.js | 3 +-- lib/utils.js | 4 ++-- test/relations.test.js | 18 ++++++++++-------- 6 files changed, 36 insertions(+), 20 deletions(-) diff --git a/lib/dao.js b/lib/dao.js index 3d76be09..82b0585f 100644 --- a/lib/dao.js +++ b/lib/dao.js @@ -338,7 +338,7 @@ DataAccessObject.findByIds = function(ids, cond, cb) { } var filter = { where: {} }; - filter.where[pk] = { inq: ids }; + filter.where[pk] = { inq: [].concat(ids) }; mergeQuery(filter, cond || {}); this.find(filter, function(err, results) { cb(err, err ? results : utils.sortObjectsByIds(pk, ids, results)); diff --git a/lib/include.js b/lib/include.js index 5fe6548c..dacf78af 100644 --- a/lib/include.js +++ b/lib/include.js @@ -42,7 +42,7 @@ function Inclusion() { */ Inclusion.include = function (objects, include, cb) { var self = this; - + if (!include || (Array.isArray(include) && include.length === 0) || (isPlainObject(include) && Object.keys(include).length === 0)) { // The objects are empty @@ -123,6 +123,7 @@ Inclusion.include = function (objects, include, cb) { return callback(); } } + var inst = (obj instanceof self) ? obj : new self(obj); // Calling the relation method on the instance inst[relationName](function (err, result) { @@ -131,6 +132,7 @@ Inclusion.include = function (objects, include, cb) { } else { defineCachedRelations(obj); obj.__cachedRelations[relationName] = result; + if(obj === inst) { obj.__data[relationName] = result; obj.setStrict(false); diff --git a/lib/model.js b/lib/model.js index b1c28a99..dfcb0ef0 100644 --- a/lib/model.js +++ b/lib/model.js @@ -12,6 +12,7 @@ var jutil = require('./jutil'); var List = require('./list'); var Hookable = require('./hooks'); var validations = require('./validations.js'); +var _extend = util._extend; // Set up an object for quick lookup var BASE_TYPES = { @@ -56,7 +57,7 @@ ModelBaseClass.prototype._initProperties = function (data, options) { // Convert the data to be plain object to avoid pollutions data = data.toObject(false); } - var properties = ctor.definition.properties; + var properties = _extend({}, ctor.definition.properties); data = data || {}; options = options || {}; @@ -130,27 +131,39 @@ ModelBaseClass.prototype._initProperties = function (data, options) { self.__data[p] = propVal; } } else if (ctor.relations[p]) { + if (!properties[p]) { + var modelTo = ctor.relations[p].modelTo || ModelBaseClass; + var multiple = ctor.relations[p].multiple; + var typeName = multiple ? 'Array' : modelTo.modelName; + var propType = multiple ? [modelTo] : modelTo; + properties[p] = { name: typeName, type: propType }; + this.setStrict(false); + } + // Relation if (ctor.relations[p].type === 'belongsTo' && propVal != null) { // If the related model is populated self.__data[ctor.relations[p].keyFrom] = propVal[ctor.relations[p].keyTo]; + } else if (!self.__data[p] && propVal != null) { + self.__data[p] = propVal; } self.__cachedRelations[p] = propVal; } else { // Un-managed property - if (strict === false) { - self[p] = self.__data[p] = propVal; + if (strict === false || self.__cachedRelations[p]) { + self[p] = self.__data[p] = propVal || self.__cachedRelations[p]; } else if (strict === 'throw') { throw new Error('Unknown property: ' + p); } } } - + keys = Object.keys(properties); size = keys.length; for (k = 0; k < size; k++) { p = keys[k]; + // var prop propVal = self.__data[p]; // Set default values @@ -172,10 +185,10 @@ ModelBaseClass.prototype._initProperties = function (data, options) { self.__data[p] = def; } } - + // Handle complex types (JSON/Object) var type = properties[p].type; - if (! BASE_TYPES[type.name]) { + if (!BASE_TYPES[type.name]) { if (typeof self.__data[p] !== 'object' && self.__data[p]) { try { self.__data[p] = JSON.parse(self.__data[p] + ''); diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 84ebc44e..22804c3f 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -954,7 +954,7 @@ HasManyThrough.prototype.exists = function (acInst, done) { var keys = throughKeys(definition); var fk1 = keys[0]; var fk2 = keys[1]; - + query[fk1] = this.modelInstance[pk1]; query[fk2] = (acInst instanceof definition.modelTo) ? acInst[pk2] : acInst; @@ -1175,7 +1175,6 @@ BelongsTo.prototype.related = function (refresh, params) { cachedValue = self.getCache(); } if (params instanceof ModelBaseClass) { // acts as setter - modelTo = params.constructor; modelInstance[fk] = params[pk]; if (discriminator) { diff --git a/lib/utils.js b/lib/utils.js index 6afb9129..c774ecae 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -210,12 +210,12 @@ function isPlainObject(obj) { function sortObjectsByIds(idName, ids, objects, strict) { ids = ids.map(function(id) { - return (typeof id === 'object') ? id.toString() : id; + return (typeof id === 'object') ? String(id) : id; }); var indexOf = function(x) { var isObj = (typeof x[idName] === 'object'); // ObjectID - var id = isObj ? x[idName].toString() : x[idName]; + var id = isObj ? String(x[idName]) : x[idName]; return ids.indexOf(id); }; diff --git a/test/relations.test.js b/test/relations.test.js index 871ecb54..d227eb1d 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -189,7 +189,7 @@ describe('relations', function () { }); describe('hasMany through', function () { - var Physician, Patient, Appointment; + var Physician, Patient, Appointment, Address; before(function (done) { db = getSchema(); @@ -199,13 +199,15 @@ describe('relations', function () { default: function () { return new Date(); }}}); - + Address = db.define('Address', {name: String}); + Physician.hasMany(Patient, {through: Appointment}); Patient.hasMany(Physician, {through: Appointment}); + Patient.belongsTo(Address); Appointment.belongsTo(Patient); Appointment.belongsTo(Physician); - db.automigrate(['Physician', 'Patient', 'Appointment'], function (err) { + db.automigrate(['Physician', 'Patient', 'Appointment', 'Address'], function (err) { done(err); }); }); @@ -277,11 +279,10 @@ describe('relations', function () { }); it('should allow to use include syntax on related data', function (done) { - var Address = db.define('Address', {name: String}); - Patient.belongsTo(Address); Physician.create(function (err, physician) { physician.patients.create({name: 'a'}, function (err, patient) { Address.create({name: 'z'}, function (err, address) { + should.not.exist(err); patient.address(address); patient.save(function() { verify(physician, address.id); @@ -353,6 +354,7 @@ describe('relations', function () { var id; Physician.create(function (err, physician) { physician.patients.create({name: 'a'}, function (err, ch) { + should.not.exist(err); id = ch.id; physician.patients.create({name: 'z'}, function () { physician.patients.create({name: 'c'}, function () { @@ -1169,7 +1171,7 @@ describe('relations', function () { var Person, Passport; it('can be declared with scope and properties', function (done) { - Person = db.define('Person', {name: String, age: Number}); + Person = db.define('Person', {name: String, age: Number, passportNotes: String}); Passport = db.define('Passport', {name: String, notes: String}); Passport.belongsTo(Person, { properties: { notes: 'passportNotes' }, @@ -1644,7 +1646,7 @@ describe('relations', function () { db = getSchema(); Category = db.define('Category', {name: String}); Product = db.define('Product', {name: String}); - Link = db.define('Link', {name: String}); + Link = db.define('Link', {name: String, notes: String}); }); it('can be declared', function (done) { @@ -1878,7 +1880,7 @@ describe('relations', function () { Author = db.define('Author', {name: String}); Reader = db.define('Reader', {name: String}); - Link = db.define('Link'); // generic model + Link = db.define('Link', {name: String, notes: String}); // generic model Link.validatesPresenceOf('linkedId'); Link.validatesPresenceOf('linkedType'); From 1d5079d8113f3a0d0b61d900766d0102f0b44f0c Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Wed, 20 Aug 2014 15:37:40 +0200 Subject: [PATCH 07/18] Implement update() on embedsOne scope --- lib/relation-definition.js | 30 +++++++++++++++++++++++++++++- test/relations.test.js | 22 ++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index b0378a4c..7a9a1d31 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1625,6 +1625,7 @@ RelationDefinition.embedsOne = function (modelFrom, modelTo, params) { var relationMethod = relation.related.bind(relation) relationMethod.create = relation.create.bind(relation); relationMethod.build = relation.build.bind(relation); + relationMethod.update = relation.update.bind(relation); relationMethod.destroy = relation.destroy.bind(relation); relationMethod._targetClass = definition.modelTo.modelName; return relationMethod; @@ -1705,12 +1706,39 @@ EmbedsOne.prototype.create = function (targetModelData, cb) { EmbedsOne.prototype.build = function (targetModelData) { var modelTo = this.definition.modelTo; var modelInstance = this.modelInstance; + var propertyName = this.definition.keyFrom; targetModelData = targetModelData || {}; this.definition.applyProperties(modelInstance, targetModelData); - return new modelTo(targetModelData); + var embeddedInstance = new modelTo(targetModelData); + modelInstance[propertyName] = embeddedInstance; + + return embeddedInstance; +}; + +EmbedsOne.prototype.update = function (targetModelData, cb) { + var modelTo = this.definition.modelTo; + var modelInstance = this.modelInstance; + var propertyName = this.definition.keyFrom; + + var isInst = targetModelData instanceof ModelBaseClass; + var data = isInst ? targetModelData.toObject() : targetModelData; + + var embeddedInstance = modelInstance[propertyName]; + if (embeddedInstance instanceof modelTo) { + embeddedInstance.setAttributes(data); + if (typeof cb === 'function') { + modelInstance.save(function(err, inst) { + cb(err, inst ? inst[propertyName] : embeddedInstance); + }); + } + } else if (!embeddedInstance && cb) { + this.create(data, db); + } else if (!embeddedInstance) { + this.build(data); + } }; EmbedsOne.prototype.destroy = function (cb) { diff --git a/test/relations.test.js b/test/relations.test.js index 55f0ae58..cd522018 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1426,6 +1426,28 @@ describe('relations', function () { }); }); + it('should update an embedded item on scope', function(done) { + Person.findById(personId, function(err, p) { + p.passportItem.update({name: 'Freddy'}, function(err, passport) { + should.not.exist(err); + var passport = p.passportItem(); + passport.toObject().should.eql({name: 'Freddy'}); + passport.should.be.an.instanceOf(Passport); + passport.should.equal(p.passport); + done(); + }); + }); + }); + + it('should get an embedded item on scope - verify', function(done) { + Person.findById(personId, function(err, p) { + should.not.exist(err); + var passport = p.passportItem(); + passport.toObject().should.eql({name: 'Freddy'}); + done(); + }); + }); + it('should destroy an embedded item on scope', function(done) { Person.findById(personId, function(err, p) { p.passportItem.destroy(function(err) { From 123b2558a133aa013d10f87f96c37f098522f020 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Wed, 20 Aug 2014 15:54:47 +0200 Subject: [PATCH 08/18] Implemented hasOne update() --- lib/relation-definition.js | 19 +++++++++++++++++++ test/relations.test.js | 29 ++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 84ebc44e..f913cc6b 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1353,6 +1353,8 @@ RelationDefinition.hasOne = function (modelFrom, modelTo, params) { var relationMethod = relation.related.bind(relation) relationMethod.create = relation.create.bind(relation); relationMethod.build = relation.build.bind(relation); + relationMethod.update = relation.update.bind(relation); + relationMethod.destroy = relation.destroy.bind(relation); relationMethod._targetClass = definition.modelTo.modelName; return relationMethod; } @@ -1416,6 +1418,23 @@ HasOne.prototype.create = function (targetModelData, cb) { }); }; +HasOne.prototype.update = function (targetModelData, cb) { + var definition = this.definition; + this.fetch(function(err, inst) { + if (inst instanceof ModelBaseClass) { + inst.updateAttributes(targetModelData, cb); + } else { + cb(new Error('HasOne relation ' + definition.name + + ' is empty')); + } + }); +}; + +HasOne.prototype.destroy = function (cb) { + + +}; + /** * Create a target model instance * @param {Object} targetModelData The target model data diff --git a/test/relations.test.js b/test/relations.test.js index 871ecb54..b517e236 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1206,6 +1206,7 @@ describe('relations', function () { describe('hasOne', function () { var Supplier, Account; + var supplierId; before(function () { db = getSchema(); @@ -1220,9 +1221,9 @@ describe('relations', function () { }); it('can be used to query data', function (done) { - // Supplier.hasOne(Account); db.automigrate(function () { Supplier.create({name: 'Supplier 1'}, function (e, supplier) { + supplierId = supplier.id; should.not.exist(e); should.exist(supplier); supplier.account.create({accountNo: 'a01'}, function (err, account) { @@ -1242,6 +1243,32 @@ describe('relations', function () { it('should set targetClass on scope property', function() { should.equal(Supplier.prototype.account._targetClass, 'Account'); }); + + it('should update the related item on scope', function(done) { + Supplier.findById(supplierId, function(e, supplier) { + should.not.exist(e); + should.exist(supplier); + supplier.account.update({supplierName: 'Supplier A'}, function(err, act) { + should.not.exist(e); + act.supplierName.should.equal('Supplier A'); + done(); + }); + }); + }); + + it('should get the related item on scope', function(done) { + Supplier.findById(supplierId, function(e, supplier) { + should.not.exist(e); + should.exist(supplier); + supplier.account(function(err, act) { + should.not.exist(e); + should.exist(act); + act.supplierName.should.equal('Supplier A'); + done(); + }); + }); + }); + }); describe('hasAndBelongsToMany', function () { From 12ebaf77f29c063b1fed8b2210612989d1798860 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Wed, 20 Aug 2014 15:58:45 +0200 Subject: [PATCH 09/18] Implemented hasOne destroy() --- lib/relation-definition.js | 10 ++++++++-- test/relations.test.js | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index f913cc6b..4674173b 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1431,8 +1431,14 @@ HasOne.prototype.update = function (targetModelData, cb) { }; HasOne.prototype.destroy = function (cb) { - - + this.fetch(function(err, inst) { + if (inst instanceof ModelBaseClass) { + inst.destroy(cb); + } else { + cb(new Error('HasOne relation ' + definition.name + + ' is empty')); + } + }); }; /** diff --git a/test/relations.test.js b/test/relations.test.js index b517e236..bba27a0e 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1269,6 +1269,29 @@ describe('relations', function () { }); }); + it('should destroy the related item on scope', function(done) { + Supplier.findById(supplierId, function(e, supplier) { + should.not.exist(e); + should.exist(supplier); + supplier.account.destroy(function(err) { + should.not.exist(e); + done(); + }); + }); + }); + + it('should get the related item on scope - verify', function(done) { + Supplier.findById(supplierId, function(e, supplier) { + should.not.exist(e); + should.exist(supplier); + supplier.account(function(err, act) { + should.not.exist(e); + should.not.exist(act); + done(); + }); + }); + }); + }); describe('hasAndBelongsToMany', function () { From af99a8d34492725a61ef1944c2de3f21bd353f34 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Wed, 20 Aug 2014 16:46:54 +0200 Subject: [PATCH 10/18] Implemented belongsTo update/destroy on scope --- lib/relation-definition.js | 53 +++++++++++++++++++++++------ test/relations.test.js | 69 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 109 insertions(+), 13 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 4674173b..d6187691 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1084,9 +1084,11 @@ RelationDefinition.belongsTo = function (modelFrom, modelTo, params) { get: function() { var relation = new BelongsTo(definition, this); var relationMethod = relation.related.bind(relation); - relationMethod.create = relation.create.bind(relation); - relationMethod.build = relation.build.bind(relation); - if (definition.modelTo) { + relationMethod.update = relation.update.bind(relation); + relationMethod.destroy = relation.destroy.bind(relation); + if (!polymorphic) { + relationMethod.create = relation.create.bind(relation); + relationMethod.build = relation.build.bind(relation); relationMethod._targetClass = definition.modelTo.modelName; } return relationMethod; @@ -1139,6 +1141,36 @@ BelongsTo.prototype.build = function(targetModelData) { return new modelTo(targetModelData); }; +BelongsTo.prototype.update = function (targetModelData, cb) { + var definition = this.definition; + this.fetch(function(err, inst) { + if (inst instanceof ModelBaseClass) { + inst.updateAttributes(targetModelData, cb); + } else { + cb(new Error('BelongsTo relation ' + definition.name + + ' is empty')); + } + }); +}; + +BelongsTo.prototype.destroy = function (cb) { + var modelTo = this.definition.modelTo; + var modelInstance = this.modelInstance; + var fk = this.definition.keyFrom; + this.fetch(function(err, targetModel) { + if (targetModel instanceof ModelBaseClass) { + modelInstance[fk] = null; + modelInstance.save(function(err, targetModel) { + if (cb && err) return cb && cb(err); + cb && cb(err, targetModel); + }); + } else { + cb(new Error('BelongsTo relation ' + definition.name + + ' is empty')); + } + }); +}; + /** * Define the method for the belongsTo relation itself * It will support one of the following styles: @@ -1204,7 +1236,8 @@ BelongsTo.prototype.related = function (refresh, params) { var query = {where: {}}; query.where[pk] = modelInstance[fk]; - if (query.where[pk] === undefined) { + if (query.where[pk] === undefined + || query.where[pk] === null) { // Foreign key is undefined return process.nextTick(cb); } @@ -1420,9 +1453,9 @@ HasOne.prototype.create = function (targetModelData, cb) { HasOne.prototype.update = function (targetModelData, cb) { var definition = this.definition; - this.fetch(function(err, inst) { - if (inst instanceof ModelBaseClass) { - inst.updateAttributes(targetModelData, cb); + this.fetch(function(err, targetModel) { + if (targetModel instanceof ModelBaseClass) { + targetModel.updateAttributes(targetModelData, cb); } else { cb(new Error('HasOne relation ' + definition.name + ' is empty')); @@ -1431,9 +1464,9 @@ HasOne.prototype.update = function (targetModelData, cb) { }; HasOne.prototype.destroy = function (cb) { - this.fetch(function(err, inst) { - if (inst instanceof ModelBaseClass) { - inst.destroy(cb); + this.fetch(function(err, targetModel) { + if (targetModel instanceof ModelBaseClass) { + targetModel.destroy(cb); } else { cb(new Error('HasOne relation ' + definition.name + ' is empty')); diff --git a/test/relations.test.js b/test/relations.test.js index bba27a0e..eecf7730 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1110,6 +1110,8 @@ describe('relations', function () { describe('belongsTo', function () { var List, Item, Fear, Mind; + + var listId, itemId; it('can be declared in different ways', function () { List = db.define('List', {name: String}); @@ -1132,15 +1134,18 @@ describe('relations', function () { it('can be used to query data', function (done) { List.hasMany('todos', {model: Item}); db.automigrate(function () { - List.create(function (e, list) { + List.create({name: 'List 1'}, function (e, list) { + listId = list.id; should.not.exist(e); should.exist(list); - list.todos.create(function (err, todo) { + list.todos.create({name: 'Item 1'},function (err, todo) { + itemId = todo.id; todo.list(function (e, l) { should.not.exist(e); should.exist(l); l.should.be.an.instanceOf(List); todo.list().id.should.equal(l.id); + todo.list().name.should.equal('List 1'); done(); }); }); @@ -1162,6 +1167,55 @@ describe('relations', function () { }); }); }); + + it('should update related item on scope', function(done) { + Item.findById(itemId, function (e, todo) { + todo.list.update({name: 'List A'}, function(err, list) { + should.not.exist(err); + should.exist(list); + list.name.should.equal('List A'); + done(); + }); + }); + }); + + it('should get related item on scope', function(done) { + Item.findById(itemId, function (e, todo) { + todo.list(function(err, list) { + should.not.exist(err); + should.exist(list); + list.name.should.equal('List A'); + done(); + }); + }); + }); + + it('should destroy related item on scope', function(done) { + Item.findById(itemId, function (e, todo) { + todo.list.destroy(function(err) { + should.not.exist(err); + done(); + }); + }); + }); + + it('should get related item on scope - verify', function(done) { + Item.findById(itemId, function (e, todo) { + todo.list(function(err, list) { + should.not.exist(err); + should.not.exist(list); + done(); + }); + }); + }); + + it('should not have deleted related item', function(done) { + List.findById(listId, function (e, list) { + should.not.exist(e); + should.exist(list); + done(); + }); + }); }); @@ -1206,7 +1260,7 @@ describe('relations', function () { describe('hasOne', function () { var Supplier, Account; - var supplierId; + var supplierId, accountId; before(function () { db = getSchema(); @@ -1228,6 +1282,7 @@ describe('relations', function () { should.exist(supplier); supplier.account.create({accountNo: 'a01'}, function (err, account) { supplier.account(function (e, act) { + accountId = act.id; should.not.exist(e); should.exist(act); act.should.be.an.instanceOf(Account); @@ -1292,6 +1347,14 @@ describe('relations', function () { }); }); + it('should have deleted related item', function(done) { + Supplier.findById(supplierId, function (e, supplier) { + should.not.exist(e); + should.exist(supplier); + done(); + }); + }); + }); describe('hasAndBelongsToMany', function () { From 3b81b928fbfcddf19ca42456ad18076b190b760e Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 19 Aug 2014 22:05:27 +0200 Subject: [PATCH 11/18] Implement DAO unsetAttribute --- lib/dao.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/dao.js b/lib/dao.js index 82b0585f..58ca673f 100644 --- a/lib/dao.js +++ b/lib/dao.js @@ -1113,6 +1113,15 @@ DataAccessObject.prototype.setAttributes = function setAttributes(data) { Model.emit('set', inst); }; +DataAccessObject.prototype.unsetAttribute = function unsetAttribute(name, nullify) { + if (nullify) { + this[name] = this.__data[name] = null; + } else { + delete this[name]; + delete this.__data[name]; + } +}; + /** * Update set of attributes. * Performs validation before updating. From faed0510c1a751fe3dc5347e61897756717ea7f4 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 19 Aug 2014 22:06:55 +0200 Subject: [PATCH 12/18] Coerce embedded model types --- lib/model.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/model.js b/lib/model.js index dfcb0ef0..ee60ce72 100644 --- a/lib/model.js +++ b/lib/model.js @@ -189,6 +189,7 @@ ModelBaseClass.prototype._initProperties = function (data, options) { // Handle complex types (JSON/Object) var type = properties[p].type; if (!BASE_TYPES[type.name]) { + if (typeof self.__data[p] !== 'object' && self.__data[p]) { try { self.__data[p] = JSON.parse(self.__data[p] + ''); @@ -196,7 +197,14 @@ ModelBaseClass.prototype._initProperties = function (data, options) { self.__data[p] = String(self.__data[p]); } } - if (type.name === 'Array' || Array.isArray(type)) { + + if (type.prototype instanceof ModelBaseClass) { + if (!(self.__data[p] instanceof type) + && typeof self.__data[p] === 'object' + && self.__data[p] !== null ) { + self.__data[p] = new type(self.__data[p]); + } + } else if (type.name === 'Array' || Array.isArray(type)) { if (!(self.__data[p] instanceof List) && self.__data[p] !== undefined && self.__data[p] !== null ) { From 1ec5357a6c0d3fb96cf164c8402741bf4ae5dacc Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 19 Aug 2014 22:10:35 +0200 Subject: [PATCH 13/18] Implemented embedsOne --- lib/relation-definition.js | 195 +++++++++++++++++++++++++++++++++++-- lib/relations.js | 10 +- test/relations.test.js | 127 +++++++++++++++++++++++- 3 files changed, 321 insertions(+), 11 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 22804c3f..f0621046 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 @@ -1538,6 +1559,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); @@ -1601,7 +1786,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 { @@ -1715,15 +1900,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'); @@ -1922,7 +2103,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 d227eb1d..37b59c7a 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1322,6 +1322,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; @@ -1593,7 +1718,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(); }); From 20e5d3c01b8dc925ae66d0c37eae18256abf742f Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 19 Aug 2014 22:15:30 +0200 Subject: [PATCH 14/18] Validate embedded models by default --- lib/relation-definition.js | 4 ++-- test/relations.test.js | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index f0621046..670a5dfd 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1601,7 +1601,7 @@ RelationDefinition.embedsOne = function (modelFrom, modelTo, params) { modelFrom.dataSource.defineProperty(modelFrom.modelName, propertyName, opts); // validate the embedded instance - if (definition.options.validate) { + if (definition.options.validate !== false) { modelFrom.validate(relationName, function(err) { var inst = this[propertyName]; if (inst instanceof modelTo) { @@ -1774,7 +1774,7 @@ RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) } // validate all embedded items - if (definition.options.validate) { + if (definition.options.validate !== false) { modelFrom.validate(propertyName, function(err) { var self = this; var embeddedList = this[propertyName] || []; diff --git a/test/relations.test.js b/test/relations.test.js index 37b59c7a..e0788a7e 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1339,8 +1339,7 @@ describe('relations', function () { it('can be declared using embedsOne method', function () { Person.embedsOne(Passport, { - default: {name: 'Anonymous'}, // a bit contrived - options: {validate: true} + default: {name: 'Anonymous'} // a bit contrived }); }); @@ -1642,7 +1641,7 @@ describe('relations', function () { }); it('can be declared', function (done) { - Person.embedsMany(Address, { options: { autoId: false, validate: true } }); + Person.embedsMany(Address, { options: { autoId: false } }); db.automigrate(done); }); From 5088c4dd740203e2fd7d1d1010c9e8cd6143ec10 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Tue, 19 Aug 2014 15:23:37 -0700 Subject: [PATCH 15/18] Fix test cases --- test/relations.test.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/test/relations.test.js b/test/relations.test.js index e0788a7e..b20f3f35 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1375,10 +1375,12 @@ describe('relations', function () { p.passport.toObject().should.eql({name: 'Anonymous'}); p.passport.should.be.an.instanceOf(Passport); }); - + + var personId; it('should create an embedded item on scope', function(done) { Person.create({name: 'Fred'}, function(err, p) { should.not.exist(err); + personId = p.id; p.passportItem.create({name: 'Fredric'}, function(err, passport) { should.not.exist(err); p.passport.toObject().should.eql({name: 'Fredric'}); @@ -1389,7 +1391,7 @@ describe('relations', function () { }); it('should get an embedded item on scope', function(done) { - Person.findOne(function(err, p) { + Person.findById(personId, function(err, p) { should.not.exist(err); var passport = p.passportItem(); passport.toObject().should.eql({name: 'Fredric'}); @@ -1412,7 +1414,7 @@ describe('relations', function () { }); it('should validate an embedded item on scope - on update', function(done) { - Person.findOne(function(err, p) { + Person.findById(personId, function(err, p) { var passport = p.passportItem(); passport.name = null; p.save(function(err) { @@ -1427,7 +1429,7 @@ describe('relations', function () { }); it('should destroy an embedded item on scope', function(done) { - Person.findOne(function(err, p) { + Person.findById(personId, function(err, p) { p.passportItem.destroy(function(err) { should.not.exist(err); should.equal(p.passport, null); @@ -1437,7 +1439,7 @@ describe('relations', function () { }); it('should get an embedded item on scope - verify', function(done) { - Person.findOne(function(err, p) { + Person.findById(personId, function(err, p) { should.not.exist(err); should.equal(p.passport, null); done(); From a1c1a9c65819fa322cfc6ee5be8a10cdb2f5cba1 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Wed, 20 Aug 2014 11:42:14 -0700 Subject: [PATCH 16/18] Fix the embedsOne test cases --- test/relations.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/relations.test.js b/test/relations.test.js index b20f3f35..55b6350f 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1337,10 +1337,11 @@ describe('relations', function () { Other = db.define('Other', {name: String}); }); - it('can be declared using embedsOne method', function () { + it('can be declared using embedsOne method', function (done) { Person.embedsOne(Passport, { default: {name: 'Anonymous'} // a bit contrived }); + db.automigrate(done); }); it('should have setup a property and accessor', function() { From af329e3a6656278d42b31fea8ca78d0d813d3fa7 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Wed, 20 Aug 2014 14:22:47 -0700 Subject: [PATCH 17/18] Save the instance even the callback is not present --- lib/relation-definition.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 670a5dfd..541de1e3 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1594,7 +1594,9 @@ RelationDefinition.embedsOne = function (modelFrom, modelTo, params) { opts.default = function() { return new modelTo(); }; } else if (typeof params.default === 'object') { opts.default = (function(def) { - return function() { return new modelTo(def); }; + return function() { + return new modelTo(def); + }; }(params.default)); } @@ -1716,11 +1718,9 @@ 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); - }); - } + modelInstance.save(function (err, result) { + cb && cb(err, result); + }); }; RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) { From 6f680f92fc21bdfdac64c80575cfeeaf73cf4716 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Wed, 20 Aug 2014 15:08:33 -0700 Subject: [PATCH 18/18] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7e75fb17..cb4c961c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "loopback-datasource-juggler", - "version": "2.4.2", + "version": "2.5.0", "description": "LoopBack DataSoure Juggler", "keywords": [ "StrongLoop",