From 59a957b538be304dfac13f09960059680c730968 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Sun, 27 Jul 2014 16:30:45 +0200 Subject: [PATCH 01/20] Implemented embedsMany relation --- lib/connectors/memory.js | 1 + lib/relation-definition.js | 307 ++++++++++++++++++++++++++++++++++++- lib/relations.js | 4 + lib/scope.js | 4 +- test/relations.test.js | 254 ++++++++++++++++++++++++++++++ 5 files changed, 567 insertions(+), 3 deletions(-) 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(); + }); + }); + + }); }); From 1487a592c13afb0e4b239fafe89cb388ab6413b0 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Sun, 27 Jul 2014 16:54:01 +0200 Subject: [PATCH 02/20] Added validation for embedded items (optional) --- lib/relation-definition.js | 23 +++++++++++++++++++++++ test/relations.test.js | 15 ++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 6062a7c8..3d795bcf 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1422,6 +1422,29 @@ RelationDefinition.embedsMany = function hasMany(modelFrom, modelTo, params) { }, { code: 'uniqueness' }) } + // validate all embedded items + if (definition.options.validate) { + modelFrom.validate(relationName, function(err) { + var embeddedList = this[relationName] || []; + var hasErrors = false; + embeddedList.forEach(function(item) { + if (item instanceof modelTo) { + if (!item.isValid()) { + hasErrors = true; + var first = Object.keys(item.errors)[0]; + var msg = 'contains invalid item: `' + item[idName] + '`'; + msg += ' (' + first + ' ' + item.errors[first] + ')'; + this.errors.add(relationName, msg, 'invalid'); + } + } else { + hasErrors = true; + this.errors.add(relationName, 'Contains invalid item', 'invalid'); + } + }.bind(this)); + if (hasErrors) err(false); + }); + } + var scopeMethods = { findById: scopeMethod(definition, 'findById'), destroy: scopeMethod(definition, 'destroyById'), diff --git a/test/relations.test.js b/test/relations.test.js index a455b41a..5140dfbb 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1380,7 +1380,7 @@ describe('relations', function () { }); it('can be declared', function (done) { - Person.embedsMany(Address, { options: { autoId: false } }); + Person.embedsMany(Address, { options: { autoId: false, validate: true } }); db.automigrate(done); }); @@ -1451,6 +1451,19 @@ describe('relations', function () { }); }); + it('should validate all embedded items', function(done) { + var addresses = []; + addresses.push({ id: 'home', street: 'Home Street' }); + addresses.push({ id: 'work', street: '' }); + 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).'; + err.message.should.equal(expected); + done(); + }); + }); + }); }); From cd2cc68905f9431790543f604281dc9c47cd6330 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Sun, 27 Jul 2014 16:56:30 +0200 Subject: [PATCH 03/20] Minor fix --- lib/relation-definition.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 3d795bcf..001075a9 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1431,8 +1431,9 @@ RelationDefinition.embedsMany = function hasMany(modelFrom, modelTo, params) { if (item instanceof modelTo) { if (!item.isValid()) { hasErrors = true; + var id = item[idName] || '(blank)'; var first = Object.keys(item.errors)[0]; - var msg = 'contains invalid item: `' + item[idName] + '`'; + var msg = 'contains invalid item: `' + id + '`'; msg += ' (' + first + ' ' + item.errors[first] + ')'; this.errors.add(relationName, msg, 'invalid'); } From 43e11af942099f78cff44d2122d0b625b034989b Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Sun, 27 Jul 2014 17:16:25 +0200 Subject: [PATCH 04/20] Test build of embedsMany --- lib/relation-definition.js | 61 +++++++++++++++++++++----------------- test/relations.test.js | 23 ++++++++++++++ 2 files changed, 56 insertions(+), 28 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 001075a9..b022072a 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1614,6 +1614,39 @@ EmbedsMany.prototype.create = function (targetModelData, cb) { var modelInstance = this.modelInstance; var autoId = this.definition.options.autoId !== false; + if (typeof targetModelData === 'function' && !cb) { + cb = targetModelData; + targetModelData = {}; + } + targetModelData = targetModelData || {}; + + var embeddedList = modelInstance[relationName] || []; + + var inst = this.build(targetModelData); + + var err = inst.isValid() ? null : new ValidationError(inst); + + if (err) { + var index = embeddedList.indexOf(inst); + if (index > -1) embeddedList.splice(index, 1); + return process.nextTick(function() { + cb(err, embeddedList); + }); + } + + modelInstance.updateAttribute(relationName, + embeddedList, function(err, modelInst) { + cb(err, modelInst[relationName]); + }); +}; + +EmbedsMany.prototype.build = HasOne.prototype.build = function(targetModelData) { + 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) { @@ -1631,34 +1664,6 @@ EmbedsMany.prototype.create = function (targetModelData, cb) { 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) { diff --git a/test/relations.test.js b/test/relations.test.js index 5140dfbb..9807e6b4 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1464,6 +1464,29 @@ describe('relations', function () { }); }); + it('should build embedded items', function(done) { + Person.create({ name: 'Wilma' }, function(err, p) { + p.addressList.build({ id: 'home', street: 'Home' }); + p.addressList.build({ id: 'work', street: 'Work' }); + p.addresses.should.have.length(2); + p.save(function(err, p) { + done(); + }); + }); + }); + + it('should have embedded items - verify', function(done) { + Person.findOne({ where: { name: 'Wilma' } }, function(err, p) { + p.name.should.equal('Wilma'); + p.addresses.should.have.length(2); + p.addresses[0].id.should.equal('home'); + p.addresses[0].street.should.equal('Home'); + p.addresses[1].id.should.equal('work'); + p.addresses[1].street.should.equal('Work'); + done(); + }); + }); + }); }); From e1ecb4b95f4c91da15dc6b57d39973a0ede11954 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Sun, 27 Jul 2014 17:30:10 +0200 Subject: [PATCH 05/20] Require unique ids for embedded items --- lib/relation-definition.js | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index b022072a..719bf637 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1406,21 +1406,19 @@ RelationDefinition.embedsMany = function hasMany(modelFrom, modelTo, params) { 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' }) - } + // unique id is required + 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' }) // validate all embedded items if (definition.options.validate) { From cb43114ab7d9795b401abbed2dbfd7c074fcbc01 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Mon, 28 Jul 2014 16:15:37 -0700 Subject: [PATCH 06/20] Fix test cases --- lib/relation-definition.js | 4 --- test/relations.test.js | 52 ++++++++++++++++++++------------------ 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index d707a929..6198ce38 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1412,10 +1412,6 @@ RelationDefinition.embedsMany = function hasMany(modelFrom, modelTo, params) { } } - 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'; diff --git a/test/relations.test.js b/test/relations.test.js index 1a6b2ad5..99d4332d 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -581,7 +581,7 @@ describe('relations', function () { author.avatar(function (err, p) { should.not.exist(err); p.name.should.equal('Avatar'); - p.imageableId.should.equal(author.id); + p.imageableId.should.eql(author.id); p.imageableType.should.equal('Author'); done(); }); @@ -593,7 +593,7 @@ describe('relations', function () { reader.mugshot(function (err, p) { should.not.exist(err); p.name.should.equal('Mugshot'); - p.imageableId.should.equal(reader.id); + p.imageableId.should.eql(reader.id); p.imageableType.should.equal('Reader'); done(); }); @@ -655,7 +655,7 @@ describe('relations', function () { author.pictures.create({ name: 'Author Pic' }, function (err, p) { should.not.exist(err); should.exist(p); - p.imageableId.should.equal(author.id); + p.imageableId.should.eql(author.id); p.imageableType.should.equal('Author'); done(); }); @@ -667,7 +667,7 @@ describe('relations', function () { reader.pictures.create({ name: 'Reader Pic' }, function (err, p) { should.not.exist(err); should.exist(p); - p.imageableId.should.equal(reader.id); + p.imageableId.should.eql(reader.id); p.imageableType.should.equal('Reader'); done(); }); @@ -738,7 +738,7 @@ describe('relations', function () { Author.create({ name: 'Author 2' }, function(err, author) { var p = new Picture({ name: 'Sample' }); p.imageable(author); // assign - p.imageableId.should.equal(author.id); + p.imageableId.should.eql(author.id); p.imageableType.should.equal('Author'); p.save(done); }); @@ -1090,7 +1090,7 @@ describe('relations', function () { it('should find record on scope', function (done) { Passport.findOne(function (err, p) { - p.personId.should.equal(personCreated.id); + p.personId.should.eql(personCreated.id); p.person(function(err, person) { person.name.should.equal('Fred'); person.should.not.have.property('age'); @@ -1248,13 +1248,15 @@ describe('relations', function () { p.addressList.create.should.be.a.function; p.addressList.build.should.be.a.function; }); - + + var address1, address2; 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); + address1 = addresses[0]; + should.exist(address1.id); addresses[0].street.should.equal('Street 1'); done(); }); @@ -1266,10 +1268,12 @@ describe('relations', function () { 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'); + address1 = addresses[0]; + address2 = addresses[1]; + should.exist(address1.id); + address1.street.should.equal('Street 1'); + should.exist(address2.id); + address2.street.should.equal('Street 2'); done(); }); }); @@ -1280,9 +1284,9 @@ describe('relations', function () { p.addressList(function(err, addresses) { should.not.exist(err); addresses.should.have.length(2); - addresses[0].id.should.equal(1); + addresses[0].id.should.eql(address1.id); addresses[0].street.should.equal('Street 1'); - addresses[1].id.should.equal(2); + addresses[1].id.should.eql(address2.id); addresses[1].street.should.equal('Street 2'); done(); }); @@ -1294,7 +1298,7 @@ describe('relations', function () { 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].id.should.eql(address2.id); addresses[0].street.should.equal('Street 2'); done(); }); @@ -1315,9 +1319,9 @@ describe('relations', function () { it('should find embedded items by id', function(done) { Person.findOne(function(err, p) { - p.addressList.findById(2, function(err, address) { + p.addressList.findById(address2.id, function(err, address) { address.should.be.instanceof(Address); - address.id.should.equal(2); + address.id.should.eql(address2.id); address.street.should.equal('Street 2'); done(); }); @@ -1326,7 +1330,7 @@ describe('relations', function () { it('should check if item exists', function(done) { Person.findOne(function(err, p) { - p.addressList.exists(2, function(err, exists) { + p.addressList.exists(address2.id, function(err, exists) { should.not.exist(err); exists.should.be.true; done(); @@ -1336,9 +1340,9 @@ describe('relations', function () { it('should update embedded items by id', function(done) { Person.findOne(function(err, p) { - p.addressList.updateById(2, { street: 'New Street' }, function(err, address) { + p.addressList.updateById(address2.id, { street: 'New Street' }, function(err, address) { address.should.be.instanceof(Address); - address.id.should.equal(2); + address.id.should.eql(address2.id); address.street.should.equal('New Street'); done(); }); @@ -1347,7 +1351,7 @@ describe('relations', function () { it('should validate the update of embedded items', function(done) { Person.findOne(function(err, p) { - p.addressList.updateById(2, { street: null }, function(err, address) { + p.addressList.updateById(address2.id, { street: null }, function(err, address) { err.name.should.equal('ValidationError'); err.details.codes.street.should.eql(['presence']); done(); @@ -1357,9 +1361,9 @@ describe('relations', function () { it('should find embedded items by id - verify', function(done) { Person.findOne(function(err, p) { - p.addressList.findById(2, function(err, address) { + p.addressList.findById(address2.id, function(err, address) { address.should.be.instanceof(Address); - address.id.should.equal(2); + address.id.should.eql(address2.id); address.street.should.equal('New Street'); done(); }); @@ -1369,7 +1373,7 @@ describe('relations', function () { 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) { + p.addressList.destroy(address1.id, function(err) { should.not.exist(err); p.addresses.should.have.length(1); done(); From 6ed7a0a5f231bf6bed56a6b0e8e572fb6cbbe130 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 29 Jul 2014 10:51:33 +0200 Subject: [PATCH 07/20] Convenience embedsMany accessors: at(idx), get(id), set(id, data) --- lib/relation-definition.js | 48 +++++++++++++++++++++++++++++++++++++- lib/relations.js | 4 ++++ test/relations.test.js | 29 +++++++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 6198ce38..d14582b8 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -18,6 +18,7 @@ var RelationTypes = { hasMany: 'hasMany', hasOne: 'hasOne', hasAndBelongsToMany: 'hasAndBelongsToMany', + referencesMany: 'referencesMany', embedsMany: 'embedsMany' }; @@ -27,6 +28,7 @@ exports.HasManyThrough = HasManyThrough; exports.HasOne = HasOne; exports.HasAndBelongsToMany = HasAndBelongsToMany; exports.BelongsTo = BelongsTo; +exports.ReferencesMany = ReferencesMany; exports.EmbedsMany = EmbedsMany; var RelationClasses = { @@ -35,6 +37,7 @@ var RelationClasses = { hasManyThrough: HasManyThrough, hasOne: HasOne, hasAndBelongsToMany: HasAndBelongsToMany, + referencesMany: ReferencesMany, embedsMany: EmbedsMany }; @@ -314,6 +317,24 @@ function EmbedsMany(definition, modelInstance) { util.inherits(EmbedsMany, Relation); +/** + * ReferencesMany subclass + * @param {RelationDefinition|Object} definition + * @param {Object} modelInstance + * @returns {HasMany} + * @constructor + * @class HasMany + */ +function ReferencesMany(definition, modelInstance) { + if (!(this instanceof HasMany)) { + return new HasMany(definition, modelInstance); + } + assert(definition.type === RelationTypes.hasMany); + Relation.apply(this, arguments); +} + +util.inherits(ReferencesMany, Relation); + /*! * Find the relation by foreign key * @param {*} foreignKey The foreign key @@ -1477,7 +1498,10 @@ RelationDefinition.embedsMany = function hasMany(modelFrom, modelTo, params) { findById: scopeMethod(definition, 'findById'), destroy: scopeMethod(definition, 'destroyById'), updateById: scopeMethod(definition, 'updateById'), - exists: scopeMethod(definition, 'exists') + exists: scopeMethod(definition, 'exists'), + get: scopeMethod(definition, 'get'), + set: scopeMethod(definition, 'set'), + at: scopeMethod(definition, 'at') }; var findByIdFunc = scopeMethods.findById; @@ -1634,6 +1658,28 @@ EmbedsMany.prototype.destroyById = function (fkId, cb) { return inst; // sync }; +EmbedsMany.prototype.get = EmbedsMany.prototype.findById; +EmbedsMany.prototype.set = EmbedsMany.prototype.updateById; + +EmbedsMany.prototype.at = function (index, cb) { + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + + var embeddedList = modelInstance[relationName] || []; + + var item = embeddedList[parseInt(index)]; + item = (item instanceof modelTo) ? item : null; + + if (typeof cb === 'function') { + process.nextTick(function() { + cb(null, item); + }); + }; + + return item; // sync +}; + EmbedsMany.prototype.create = function (targetModelData, cb) { var pk = this.definition.keyFrom; var modelTo = this.definition.modelTo; diff --git a/lib/relations.js b/lib/relations.js index 493fdb92..d3e735ff 100644 --- a/lib/relations.js +++ b/lib/relations.js @@ -162,6 +162,10 @@ RelationMixin.hasOne = function hasMany(modelTo, params) { RelationDefinition.hasOne(this, modelTo, params); }; +RelationMixin.referencesMany = function hasMany(modelTo, params) { + RelationDefinition.referencesMany(this, modelTo, params); +}; + RelationMixin.embedsMany = function hasMany(modelTo, params) { RelationDefinition.embedsMany(this, modelTo, params); }; diff --git a/test/relations.test.js b/test/relations.test.js index 99d4332d..2a9e5526 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -5,6 +5,7 @@ var db, Book, Chapter, Author, Reader; var Category, Product; var Picture, PictureLink; var Person, Address; +var Link; describe('relations', function () { @@ -1370,6 +1371,20 @@ describe('relations', function () { }); }); + it('should have accessors: at, get, set', function(done) { + Person.findOne(function(err, p) { + p.addressList.at(0).id.should.equal(address1.id); + p.addressList.get(address1.id).id.should.equal(address1.id); + p.addressList.set(address1.id, { street: 'Changed 1' }); + p.addresses[0].street.should.equal('Changed 1'); + p.addressList.at(1).id.should.equal(address2.id); + p.addressList.get(address2.id).id.should.equal(address2.id); + p.addressList.set(address2.id, { street: 'Changed 2' }); + p.addresses[1].street.should.equal('Changed 2'); + done(); + }); + }); + it('should remove embedded items by id', function(done) { Person.findOne(function(err, p) { p.addresses.should.have.length(2); @@ -1510,6 +1525,20 @@ describe('relations', function () { }); }); + it('should have accessors: at, get, set', function(done) { + Person.findOne({ where: { name: 'Wilma' } }, function(err, p) { + p.name.should.equal('Wilma'); + p.addresses.should.have.length(2); + p.addressList.at(0).id.should.equal('home'); + p.addressList.get('home').id.should.equal('home'); + p.addressList.set('home', { id: 'den' }).id.should.equal('den'); + p.addressList.at(1).id.should.equal('work'); + p.addressList.get('work').id.should.equal('work'); + p.addressList.set('work', { id: 'factory' }).id.should.equal('factory'); + done(); + }); + }); + }); }); From da303b72a51d45094a9d4e6bf83a85ee1878b3fc Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 29 Jul 2014 10:54:28 +0200 Subject: [PATCH 08/20] Implemented more complex scenaro: embedsMany + relations The test case will denormalize data into the embedded object, and re-use the actual related object id as its own id. --- lib/dao.js | 37 ++++++++++-- lib/relation-definition.js | 32 ++++++++--- test/relations.test.js | 112 ++++++++++++++++++++++++++++++++++++- 3 files changed, 169 insertions(+), 12 deletions(-) diff --git a/lib/dao.js b/lib/dao.js index a1508eb1..2a03cc4f 100644 --- a/lib/dao.js +++ b/lib/dao.js @@ -1046,6 +1046,17 @@ DataAccessObject.prototype.remove = }.bind(this)); }, null, cb); }; + +/** + * Set a single attribute. + * Equivalent to `setAttributes({name: value})` + * + * @param {String} name Name of property + * @param {Mixed} value Value of property + */ +DataAccessObject.prototype.setAttribute = function setAttribute(name, value) { + this[name] = value; +}; /** * Update a single attribute. @@ -1062,7 +1073,27 @@ DataAccessObject.prototype.updateAttribute = function updateAttribute(name, valu }; /** - * Update saet of attributes. + * Update set of attributes. + * + * @trigger `change` hook + * @param {Object} data Data to update + */ +DataAccessObject.prototype.setAttributes = function setAttributes(data) { + if (typeof data !== 'object') return; + + var Model = this.constructor; + var inst = this; + + // update instance's properties + for (var key in data) { + inst.setAttribute(key, data[key]); + } + + Model.emit('set', inst); +}; + +/** + * Update set of attributes. * Performs validation before updating. * * @trigger `validation`, `save` and `update` hooks @@ -1086,9 +1117,7 @@ DataAccessObject.prototype.updateAttributes = function updateAttributes(data, cb } // update instance's properties - for (var key in data) { - inst[key] = data[key]; - } + inst.setAttributes(data); inst.isValid(function (valid) { if (!valid) { diff --git a/lib/relation-definition.js b/lib/relation-definition.js index d14582b8..0d34b473 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1057,6 +1057,11 @@ BelongsTo.prototype.related = function (refresh, params) { modelTo = params.constructor; modelInstance[fk] = params[pk]; if (discriminator) modelInstance[discriminator] = params.constructor.modelName; + + var data = {}; + this.definition.applyProperties(params, data); + modelInstance.setAttributes(data); + self.resetCache(params); } else if (typeof params === 'function') { // acts as async getter @@ -1525,7 +1530,9 @@ RelationDefinition.embedsMany = function hasMany(modelFrom, modelTo, params) { }; EmbedsMany.prototype.related = function(receiver, scopeParams, condOrRefresh, cb) { - var name = this.definition.name; + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; var self = receiver; var actualCond = {}; @@ -1543,14 +1550,29 @@ EmbedsMany.prototype.related = function(receiver, scopeParams, condOrRefresh, cb throw new Error('Method can be only called with one or two arguments'); } - var embeddedList = self[name] || []; + var embeddedList = self[relationName] || []; + + this.definition.applyScope(modelInstance, actualCond); var params = mergeQuery(actualCond, scopeParams); + if (params.where) { embeddedList = embeddedList ? embeddedList.filter(applyFilter(params)) : embeddedList; } - process.nextTick(function() { cb(null, embeddedList); }); + var returnRelated = function(list) { + if (params.include) { + modelTo.include(list, params.include, cb); + } else { + process.nextTick(function() { cb(null, list); }); + } + }; + + if (actualRefresh) { + + } + + returnRelated(embeddedList); }; EmbedsMany.prototype.findById = function (fkId, cb) { @@ -1722,10 +1744,6 @@ EmbedsMany.prototype.build = HasOne.prototype.build = function(targetModelData) var embeddedList = modelInstance[relationName] || []; - if (typeof targetModelData === 'function' && !cb) { - cb = targetModelData; - targetModelData = {}; - } targetModelData = targetModelData || {}; if (typeof targetModelData[pk] !== 'number' && autoId) { diff --git a/test/relations.test.js b/test/relations.test.js index 2a9e5526..e0c9c44d 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1221,6 +1221,9 @@ describe('relations', function () { }); describe('embedsMany', function () { + + var address1, address2; + before(function (done) { db = getSchema(); Person = db.define('Person', {name: String}); @@ -1250,7 +1253,6 @@ describe('relations', function () { p.addressList.build.should.be.a.function; }); - var address1, address2; 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) { @@ -1540,5 +1542,113 @@ describe('relations', function () { }); }); + + describe('embedsMany - relations, scope and properties', function () { + + var product1, product2; + + before(function (done) { + db = getSchema(); + Category = db.define('Category', {name: String}); + Product = db.define('Product', {name: String}); + Link = db.define('Link'); + + db.automigrate(function () { + Person.destroyAll(done); + }); + }); + + it('can be declared', function (done) { + Category.embedsMany(Link, { + as: 'items', // rename + scope: { include: 'product' } // always include + }); + Link.belongsTo(Product, { + foreignKey: 'id', // re-use the actual product id + properties: { id: 'id', name: 'name' }, // denormalize, transfer id + }); + db.automigrate(done); + }); + + it('should setup related items', function(done) { + Product.create({ name: 'Product 1' }, function(err, p) { + product1 = p; + Product.create({ name: 'Product 2' }, function(err, p) { + product2 = p; + done(); + }); + }); + }); + + it('should create item on scope', function(done) { + Category.create({ name: 'Category A' }, function(err, cat) { + var link = cat.items.build(); + link.product(product1); + var link = cat.items.build(); + link.product(product2); + cat.save(function(err, cat) { + var product = cat.items.at(0); + product.id.should.equal(product1.id); + product.name.should.equal(product1.name); + var product = cat.items.at(1); + product.id.should.equal(product2.id); + product.name.should.equal(product2.name); + done(); + }); + }); + }); + + it('should include related items on scope', function(done) { + Category.findOne(function(err, cat) { + cat.links.should.have.length(2); + + // denormalized properties: + cat.items.at(0).id.should.equal(product1.id); + cat.items.at(0).name.should.equal(product1.name); + cat.items.at(1).id.should.equal(product2.id); + cat.items.at(1).name.should.equal(product2.name); + + // lazy-loaded relations + should.not.exist(cat.items.at(0).product()); + should.not.exist(cat.items.at(1).product()); + + cat.items(function(err, items) { + cat.items.at(0).product().should.be.instanceof(Product); + cat.items.at(1).product().should.be.instanceof(Product); + cat.items.at(1).product().name.should.equal('Product 2'); + done(); + }); + }); + }); + + it('should remove embedded items by id', function(done) { + Category.findOne(function(err, cat) { + cat.links.should.have.length(2); + cat.items.destroy(product1.id, function(err) { + should.not.exist(err); + cat.links.should.have.length(1); + done(); + }); + }); + }); + + it('should find items on scope', function(done) { + Category.findOne(function(err, cat) { + cat.links.should.have.length(1); + cat.items.at(0).id.should.equal(product2.id); + cat.items.at(0).name.should.equal(product2.name); + + // lazy-loaded relations + should.not.exist(cat.items.at(0).product()); + + cat.items(function(err, items) { + cat.items.at(0).product().should.be.instanceof(Product); + cat.items.at(0).product().name.should.equal('Product 2'); + done(); + }); + }); + }); + + }); }); From 13cee9502cb6dc3c33ae8164140bbb85f06d1bd2 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 29 Jul 2014 13:57:49 +0200 Subject: [PATCH 09/20] Tests for polymorphic embedsMany --- lib/include.js | 8 +-- lib/relation-definition.js | 31 ++++----- test/relations.test.js | 137 +++++++++++++++++++++++++++++++++++-- 3 files changed, 150 insertions(+), 26 deletions(-) diff --git a/lib/include.js b/lib/include.js index 342a2bde..a72824b0 100644 --- a/lib/include.js +++ b/lib/include.js @@ -94,7 +94,7 @@ Inclusion.include = function (objects, include, cb) { subInclude = null; } var relation = relations[relationName]; - + if (!relation) { cb(new Error('Relation "' + relationName + '" is not defined for ' + self.modelName + ' model')); @@ -106,7 +106,7 @@ Inclusion.include = function (objects, include, cb) { cb(); return; } - + // Calling the relation method for each object async.each(objs, function (obj, callback) { if(relation.type === 'belongsTo') { @@ -133,11 +133,11 @@ Inclusion.include = function (objects, include, cb) { obj.__cachedRelations[relationName] = result; if(obj === inst) { obj.__data[relationName] = result; - obj.strict = false; + obj.__strict = false; } else { obj[relationName] = result; } - + if (subInclude && result) { var subItems = relation.multiple ? result : [result]; // Recursively include the related models diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 0d34b473..0a3952b1 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1443,7 +1443,7 @@ RelationDefinition.embedsMany = function hasMany(modelFrom, modelTo, params) { var fk = modelTo.dataSource.idName(modelTo.modelName) || 'id'; var idName = modelFrom.dataSource.idName(modelFrom.modelName) || 'id'; - var definition = new RelationDefinition({ + var definition = modelFrom.relations[accessorName] = new RelationDefinition({ name: relationName, type: RelationTypes.embedsMany, modelFrom: modelFrom, @@ -1463,17 +1463,20 @@ RelationDefinition.embedsMany = function hasMany(modelFrom, modelTo, params) { // unique id is required 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' }) + + if (!params.polymorphic) { + 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' }) + } // validate all embedded items if (definition.options.validate) { @@ -1568,10 +1571,6 @@ EmbedsMany.prototype.related = function(receiver, scopeParams, condOrRefresh, cb } }; - if (actualRefresh) { - - } - returnRelated(embeddedList); }; diff --git a/test/relations.test.js b/test/relations.test.js index e0c9c44d..b417112b 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1580,7 +1580,7 @@ describe('relations', function () { }); }); - it('should create item on scope', function(done) { + it('should create items on scope', function(done) { Category.create({ name: 'Category A' }, function(err, cat) { var link = cat.items.build(); link.product(product1); @@ -1588,10 +1588,10 @@ describe('relations', function () { link.product(product2); cat.save(function(err, cat) { var product = cat.items.at(0); - product.id.should.equal(product1.id); + product.id.should.eql(product1.id); product.name.should.equal(product1.name); var product = cat.items.at(1); - product.id.should.equal(product2.id); + product.id.should.eql(product2.id); product.name.should.equal(product2.name); done(); }); @@ -1603,9 +1603,9 @@ describe('relations', function () { cat.links.should.have.length(2); // denormalized properties: - cat.items.at(0).id.should.equal(product1.id); + cat.items.at(0).id.should.eql(product1.id); cat.items.at(0).name.should.equal(product1.name); - cat.items.at(1).id.should.equal(product2.id); + cat.items.at(1).id.should.eql(product2.id); cat.items.at(1).name.should.equal(product2.name); // lazy-loaded relations @@ -1635,7 +1635,7 @@ describe('relations', function () { it('should find items on scope', function(done) { Category.findOne(function(err, cat) { cat.links.should.have.length(1); - cat.items.at(0).id.should.equal(product2.id); + cat.items.at(0).id.should.eql(product2.id); cat.items.at(0).name.should.equal(product2.name); // lazy-loaded relations @@ -1650,5 +1650,130 @@ describe('relations', function () { }); }); + + describe('embedsMany - polymorphic relations', function () { + + var person1, person2; + + before(function (done) { + db = getSchema(); + Book = db.define('Book', {name: String}); + Author = db.define('Author', {name: String}); + Reader = db.define('Reader', {name: String}); + + Link = db.define('Link'); // generic model + Link.validatesPresenceOf('linkedId'); + Link.validatesPresenceOf('linkedType'); + + db.automigrate(function () { + Book.destroyAll(function() { + Author.destroyAll(function() { + Reader.destroyAll(done); + }); + }); + }); + }); + + it('can be declared', function (done) { + Book.embedsMany(Link, { as: 'people', + polymorphic: 'linked', + scope: { include: 'linked' } + }); + Link.belongsTo('linked', { + polymorphic: true, + properties: { name: 'name' } // denormalized + }); + db.automigrate(done); + }); + + it('should setup related items', function(done) { + Author.create({ name: 'Author 1' }, function(err, p) { + person1 = p; + Reader.create({ name: 'Reader 1' }, function(err, p) { + person2 = p; + done(); + }); + }); + }); + + it('should create items on scope', function(done) { + Book.create({ name: 'Book' }, function(err, book) { + var link = book.people.build({ notes: 'Something ...' }); + link.linked(person1); + var link = book.people.build(); + link.linked(person2); + book.save(function(err, book) { + should.not.exist(err); + + var link = book.people.at(0); + link.should.be.instanceof(Link); + link.id.should.equal(1); + link.linkedId.should.eql(person1.id); + link.linkedType.should.equal('Author'); + link.name.should.equal('Author 1'); + + var link = book.people.at(1); + link.should.be.instanceof(Link); + link.id.should.equal(2); + link.linkedId.should.eql(person2.id); + link.linkedType.should.equal('Reader'); + link.name.should.equal('Reader 1'); + + done(); + }); + }); + }); + + it('should include related items on scope', function(done) { + Book.findOne(function(err, book) { + book.links.should.have.length(2); + + var link = book.people.at(0); + link.should.be.instanceof(Link); + link.id.should.equal(1); + link.linkedId.should.eql(person1.id); + link.linkedType.should.equal('Author'); + link.notes.should.equal('Something ...'); + + var link = book.people.at(1); + link.should.be.instanceof(Link); + link.id.should.equal(2); + link.linkedId.should.eql(person2.id); + link.linkedType.should.equal('Reader'); + + // lazy-loaded relations + should.not.exist(book.people.at(0).linked()); + should.not.exist(book.people.at(1).linked()); + + book.people(function(err, people) { + people[0].linked().should.be.instanceof(Author); + people[0].linked().name.should.equal('Author 1'); + people[1].linked().should.be.instanceof(Reader); + people[1].linked().name.should.equal('Reader 1'); + done(); + }); + }); + }); + + it('should include nested related items on scope', function(done) { + Book.find({ include: 'people' }, function(err, books) { + var obj = books[0].toObject(); + + obj.should.have.property('links'); + obj.should.have.property('people'); + + obj.links.should.have.length(2); + obj.links[0].name.should.equal('Author 1'); + obj.links[1].name.should.equal('Reader 1'); + + obj.people.should.have.length(2); + obj.people[0].linked.name.should.equal('Author 1'); + obj.people[1].linked.name.should.equal('Reader 1'); + + done(); + }); + }); + + }); }); From 296bb0d73ed0ad5b18ebce89be40b50323e8bda2 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 29 Jul 2014 14:05:18 +0200 Subject: [PATCH 10/20] Minor touchups --- test/relations.test.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/relations.test.js b/test/relations.test.js index b417112b..3339bb94 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1588,6 +1588,7 @@ describe('relations', function () { link.product(product2); cat.save(function(err, cat) { var product = cat.items.at(0); + product.should.not.have.property('productId'); product.id.should.eql(product1.id); product.name.should.equal(product1.name); var product = cat.items.at(1); @@ -1756,6 +1757,12 @@ describe('relations', function () { }); it('should include nested related items on scope', function(done) { + + // There's some date duplication going on, so it might + // make sense to override toObject on a case-by-case basis + // to sort this out (delete links, keep people). + // In loopback, an afterRemote filter could do this as well. + Book.find({ include: 'people' }, function(err, books) { var obj = books[0].toObject(); @@ -1767,6 +1774,10 @@ describe('relations', function () { obj.links[1].name.should.equal('Reader 1'); obj.people.should.have.length(2); + + obj.people[0].name.should.equal('Author 1'); + obj.people[0].notes.should.equal('Something ...'); + obj.people[0].linked.name.should.equal('Author 1'); obj.people[1].linked.name.should.equal('Reader 1'); From b18384459a0fb565f1911617a2075b4e2378a1dc Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 29 Jul 2014 15:01:47 +0200 Subject: [PATCH 11/20] Implemented findByIds --- lib/dao.js | 42 +++++++++++++++++++++++++++++++++++++ lib/relation-definition.js | 7 ++++++- test/basic-querying.test.js | 39 ++++++++++++++++++++++++++++++++++ test/relations.test.js | 23 +++++++++++++++++++- 4 files changed, 109 insertions(+), 2 deletions(-) diff --git a/lib/dao.js b/lib/dao.js index 2a03cc4f..7f51ab90 100644 --- a/lib/dao.js +++ b/lib/dao.js @@ -14,6 +14,7 @@ var Relation = require('./relations.js'); var Inclusion = require('./include.js'); var List = require('./list.js'); var geo = require('./geo'); +var mergeQuery = require('./scope.js').mergeQuery; var Memory = require('./connectors/memory').Memory; var utils = require('./utils'); var fieldsToArray = utils.fieldsToArray; @@ -324,6 +325,47 @@ DataAccessObject.findById = function find(id, cb) { }.bind(this)); }; +DataAccessObject.findByIds = function(ids, cond, cb) { + if (typeof cond === 'function') { + cb = cond; + cond = {}; + } + + var pk = this.dataSource.idName(this.modelName) || 'id'; + if (ids.length === 0) { + process.nextTick(function() { cb(null, []); }); + return; + } + + var filter = { where: {} }; + filter.where[pk] = { inq: ids }; + mergeQuery(filter, cond || {}); + this.find(filter, function(err, results) { + cb(err, err ? results : this.sortByIds(ids, results)); + }.bind(this)); +}; + +DataAccessObject.sortByIds = function(ids, results) { + var pk = this.dataSource.idName(this.modelName) || 'id'; + ids = ids.map(function(id) { + return (typeof id === 'object') ? id.toString() : id; + }); + + results.sort(function(x, y) { + var idA = (typeof x[pk] === 'object') ? x[pk].toString() : x[pk]; + var idB = (typeof y[pk] === 'object') ? y[pk].toString() : y[pk]; + var a = ids.indexOf(idA); + var b = ids.indexOf(idB); + if (a === -1 || b === -1) return 1; // last + if (a !== b) { + if (a > b) return 1; + if (a < b) return -1; + } + }); + + return results; +}; + function convertNullToNotFoundError(ctx, cb) { if (ctx.result !== null) return cb(); diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 0a3952b1..59e8425a 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1425,7 +1425,7 @@ HasOne.prototype.related = function (refresh, params) { } }; -RelationDefinition.embedsMany = function hasMany(modelFrom, modelTo, params) { +RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) { var thisClassName = modelFrom.modelName; params = params || {}; if (typeof modelTo === 'string') { @@ -1764,3 +1764,8 @@ EmbedsMany.prototype.build = HasOne.prototype.build = function(targetModelData) return inst; }; + +RelationDefinition.referencesMany = function referencesMany(modelFrom, modelTo, params) { + +}; + diff --git a/test/basic-querying.test.js b/test/basic-querying.test.js index 2a235def..dd26928a 100644 --- a/test/basic-querying.test.js +++ b/test/basic-querying.test.js @@ -50,6 +50,45 @@ describe('basic-querying', function () { }); }); + + describe('findById', function () { + + before(function(done) { + var people = [ + { id: 1, name: 'a', vip: true }, + { id: 2, name: 'b' }, + { id: 3, name: 'c' }, + { id: 4, name: 'd', vip: true }, + { id: 5, name: 'e' }, + { id: 6, name: 'f' } + ]; + User.destroyAll(function() { + User.create(people, done); + }); + }); + + it('should query by ids', function (done) { + User.findByIds([3, 2, 1], function (err, users) { + should.exist(users); + should.not.exist(err); + var names = users.map(function(u) { return u.name; }); + names.should.eql(['c', 'b', 'a']); + done(); + }); + }); + + it('should query by ids and condition', function (done) { + User.findByIds([4, 3, 2, 1], + { where: { vip: true } }, function (err, users) { + should.exist(users); + should.not.exist(err); + var names = users.map(function(u) { return u.name; }); + names.should.eql(['d', 'a']); + done(); + }); + }); + + }); describe('find', function () { diff --git a/test/relations.test.js b/test/relations.test.js index 3339bb94..c37b082b 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1681,7 +1681,7 @@ describe('relations', function () { scope: { include: 'linked' } }); Link.belongsTo('linked', { - polymorphic: true, + polymorphic: true, // needs unique auto-id properties: { name: 'name' } // denormalized }); db.automigrate(done); @@ -1786,5 +1786,26 @@ describe('relations', function () { }); }); + + describe('referencesMany', function () { + + before(function (done) { + db = getSchema(); + Category = db.define('Category', {name: String}); + Product = db.define('Product', {name: String}); + + db.automigrate(function () { + Category.destroyAll(function() { + Product.destroyAll(done); + }); + }); + }); + + it('can be declared', function (done) { + Category.referencesMany(Product); + db.automigrate(done); + }); + + }); }); From 60fd39d31113bea59b55aa767a00dbc3391dd21e Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 29 Jul 2014 17:43:30 +0200 Subject: [PATCH 12/20] Added option: reference to enable embedsMany add/remove --- lib/include.js | 2 +- lib/relation-definition.js | 98 +++++++++++++++++++++++++++++++++++++- test/relations.test.js | 64 +++++++++++++++++++++++-- 3 files changed, 156 insertions(+), 8 deletions(-) diff --git a/lib/include.js b/lib/include.js index a72824b0..5fe6548c 100644 --- a/lib/include.js +++ b/lib/include.js @@ -133,7 +133,7 @@ Inclusion.include = function (objects, include, cb) { obj.__cachedRelations[relationName] = result; if(obj === inst) { obj.__data[relationName] = result; - obj.__strict = false; + obj.setStrict(false); } else { obj[relationName] = result; } diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 59e8425a..fc2bd48c 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -65,6 +65,7 @@ function RelationDefinition(definition) { } definition = definition || {}; this.name = definition.name; + this.accessor = definition.accessor || this.name; assert(this.name, 'Relation name is missing'); this.type = normalizeType(definition.type); assert(this.type, 'Invalid relation type: ' + definition.type); @@ -1444,6 +1445,7 @@ RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) var idName = modelFrom.dataSource.idName(modelFrom.modelName) || 'id'; var definition = modelFrom.relations[accessorName] = new RelationDefinition({ + accessor: accessorName, name: relationName, type: RelationTypes.embedsMany, modelFrom: modelFrom, @@ -1507,8 +1509,11 @@ RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) destroy: scopeMethod(definition, 'destroyById'), updateById: scopeMethod(definition, 'updateById'), exists: scopeMethod(definition, 'exists'), + add: scopeMethod(definition, 'add'), + remove: scopeMethod(definition, 'remove'), get: scopeMethod(definition, 'get'), set: scopeMethod(definition, 'set'), + unset: scopeMethod(definition, 'unset'), at: scopeMethod(definition, 'at') }; @@ -1520,6 +1525,12 @@ RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) var updateByIdFunc = scopeMethods.updateById; modelFrom.prototype['__updateById__' + relationName] = updateByIdFunc; + + var addFunc = scopeMethods.add; + modelFrom.prototype['__link__' + relationName] = addFunc; + + var removeFunc = scopeMethods.remove; + modelFrom.prototype['__unlink__' + relationName] = removeFunc; scopeMethods.create = scopeMethod(definition, 'create'); scopeMethods.build = scopeMethod(definition, 'build'); @@ -1660,7 +1671,7 @@ EmbedsMany.prototype.destroyById = function (fkId, cb) { var embeddedList = modelInstance[relationName] || []; - var inst = this.findById(fkId); + var inst = (fkId instanceof modelTo) ? fkId : this.findById(fkId); if (inst instanceof modelTo) { var index = embeddedList.indexOf(inst); @@ -1681,6 +1692,7 @@ EmbedsMany.prototype.destroyById = function (fkId, cb) { EmbedsMany.prototype.get = EmbedsMany.prototype.findById; EmbedsMany.prototype.set = EmbedsMany.prototype.updateById; +EmbedsMany.prototype.unset = EmbedsMany.prototype.destroyById; EmbedsMany.prototype.at = function (index, cb) { var modelTo = this.definition.modelTo; @@ -1734,7 +1746,7 @@ EmbedsMany.prototype.create = function (targetModelData, cb) { }); }; -EmbedsMany.prototype.build = HasOne.prototype.build = function(targetModelData) { +EmbedsMany.prototype.build = function(targetModelData) { var pk = this.definition.keyFrom; var modelTo = this.definition.modelTo; var relationName = this.definition.name; @@ -1765,6 +1777,88 @@ EmbedsMany.prototype.build = HasOne.prototype.build = function(targetModelData) return inst; }; +/** + * Add the target model instance to the 'embedsMany' relation + * @param {Object|ID} acInst The actual instance or id value + */ +EmbedsMany.prototype.add = function (acInst, cb) { + var self = this; + var definition = this.definition; + var modelTo = this.definition.modelTo; + var modelInstance = this.modelInstance; + + var options = definition.options; + var referenceDef = options.reference && modelTo.relations[options.reference]; + + if (!referenceDef) { + throw new Error('Invalid reference: ' + options.reference || '(none)'); + } + + var fk2 = referenceDef.keyTo; + var pk2 = referenceDef.modelTo.definition.idName() || 'id'; + + var query = {}; + + query[fk2] = (acInst instanceof referenceDef.modelTo) ? acInst[pk2] : acInst; + + var filter = { where: query }; + + referenceDef.applyScope(modelInstance, filter); + + referenceDef.modelTo.findOne(filter, function(err, ref) { + if (ref instanceof referenceDef.modelTo) { + var inst = self.build(); + inst[options.reference](ref); + modelInstance.save(function(err) { + cb(err, err ? null : inst); + }); + } else { + cb(null, null); + } + }); +}; + +/** + * Remove the target model instance from the 'embedsMany' relation + * @param {Object|ID) acInst The actual instance or id value + */ +EmbedsMany.prototype.remove = function (acInst, cb) { + var self = this; + var definition = this.definition; + var modelTo = this.definition.modelTo; + var modelInstance = this.modelInstance; + + var options = definition.options; + var referenceDef = options.reference && modelTo.relations[options.reference]; + + if (!referenceDef) { + throw new Error('Invalid reference: ' + options.reference || '(none)'); + } + + var fk2 = referenceDef.keyTo; + var pk2 = referenceDef.modelTo.definition.idName() || 'id'; + + var query = {}; + + query[fk2] = (acInst instanceof referenceDef.modelTo) ? acInst[pk2] : acInst; + + var filter = { where: query }; + + referenceDef.applyScope(modelInstance, filter); + + modelInstance[definition.accessor](filter, function(err, items) { + if (err) return cb(err); + + items.forEach(function(item) { + self.unset(item); + }); + + modelInstance.save(function(err) { + cb(err, err ? [] : items); + }); + }); +}; + RelationDefinition.referencesMany = function referencesMany(modelFrom, modelTo, params) { }; diff --git a/test/relations.test.js b/test/relations.test.js index c37b082b..a3d04a7d 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1545,13 +1545,13 @@ describe('relations', function () { describe('embedsMany - relations, scope and properties', function () { - var product1, product2; + var product1, product2, product3; before(function (done) { db = getSchema(); Category = db.define('Category', {name: String}); Product = db.define('Product', {name: String}); - Link = db.define('Link'); + Link = db.define('Link', {name: String}); db.automigrate(function () { Person.destroyAll(done); @@ -1561,13 +1561,16 @@ describe('relations', function () { it('can be declared', function (done) { Category.embedsMany(Link, { as: 'items', // rename - scope: { include: 'product' } // always include + scope: { include: 'product' }, // always include + options: { reference: 'product' } // optional, for add()/remove() }); Link.belongsTo(Product, { foreignKey: 'id', // re-use the actual product id properties: { id: 'id', name: 'name' }, // denormalize, transfer id }); - db.automigrate(done); + db.automigrate(function() { + Product.create({ name: 'Product 0' }, done); // offset ids for tests + }); }); it('should setup related items', function(done) { @@ -1575,7 +1578,10 @@ describe('relations', function () { product1 = p; Product.create({ name: 'Product 2' }, function(err, p) { product2 = p; - done(); + Product.create({ name: 'Product 3' }, function(err, p) { + product3 = p; + done(); + }); }); }); }); @@ -1588,6 +1594,7 @@ describe('relations', function () { link.product(product2); cat.save(function(err, cat) { var product = cat.items.at(0); + product.should.be.instanceof(Link); product.should.not.have.property('productId'); product.id.should.eql(product1.id); product.name.should.equal(product1.name); @@ -1604,6 +1611,7 @@ describe('relations', function () { cat.links.should.have.length(2); // denormalized properties: + cat.items.at(0).should.be.instanceof(Link); cat.items.at(0).id.should.eql(product1.id); cat.items.at(0).name.should.equal(product1.name); cat.items.at(1).id.should.eql(product2.id); @@ -1650,6 +1658,52 @@ describe('relations', function () { }); }); + it('should add related items to scope', function(done) { + Category.findOne(function(err, cat) { + cat.links.should.have.length(1); + cat.items.add(product3, function(err, link) { + link.should.be.instanceof(Link); + link.id.should.equal(product3.id); + link.name.should.equal('Product 3'); + + cat.links.should.have.length(2); + done(); + }); + }); + }); + + it('should find items on scope', function(done) { + Category.findOne(function(err, cat) { + cat.links.should.have.length(2); + + cat.items.at(0).should.be.instanceof(Link); + cat.items.at(0).id.should.eql(product2.id); + cat.items.at(0).name.should.equal(product2.name); + cat.items.at(1).id.should.eql(product3.id); + cat.items.at(1).name.should.equal(product3.name); + + done(); + }); + }); + + it('should remove embedded items by reference id', function(done) { + Category.findOne(function(err, cat) { + cat.links.should.have.length(2); + cat.items.remove(product2.id, function(err) { + should.not.exist(err); + cat.links.should.have.length(1); + done(); + }); + }); + }); + + it('should remove embedded items by reference id', function(done) { + Category.findOne(function(err, cat) { + cat.links.should.have.length(1); + done(); + }); + }); + }); describe('embedsMany - polymorphic relations', function () { From 1782b439f173c8a0f0237b1c2e16b16a50a8084e Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 29 Jul 2014 21:46:12 +0200 Subject: [PATCH 13/20] Implemented referencesMany --- lib/relation-definition.js | 336 ++++++++++++++++++++++++++++++++++++- test/relations.test.js | 215 ++++++++++++++++++++++++ 2 files changed, 542 insertions(+), 9 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index fc2bd48c..96c18c32 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -322,15 +322,15 @@ util.inherits(EmbedsMany, Relation); * ReferencesMany subclass * @param {RelationDefinition|Object} definition * @param {Object} modelInstance - * @returns {HasMany} + * @returns {ReferencesMany} * @constructor - * @class HasMany + * @class ReferencesMany */ function ReferencesMany(definition, modelInstance) { - if (!(this instanceof HasMany)) { - return new HasMany(definition, modelInstance); + if (!(this instanceof ReferencesMany)) { + return new ReferencesMany(definition, modelInstance); } - assert(definition.type === RelationTypes.hasMany); + assert(definition.type === RelationTypes.referencesMany); Relation.apply(this, arguments); } @@ -550,6 +550,7 @@ function scopeMethod(definition, methodName) { */ HasMany.prototype.findById = function (fkId, cb) { var modelTo = this.definition.modelTo; + var modelFrom = this.definition.modelFrom; var fk = this.definition.keyTo; var pk = this.definition.keyFrom; var modelInstance = this.modelInstance; @@ -579,7 +580,7 @@ HasMany.prototype.findById = function (fkId, cb) { if (inst[fk] && inst[fk].toString() === modelInstance[pk].toString()) { cb(null, inst); } else { - err = new Error('Key mismatch: ' + this.definition.modelFrom.modelName + '.' + pk + err = new Error('Key mismatch: ' + modelFrom.modelName + '.' + pk + ': ' + modelInstance[pk] + ', ' + modelTo.modelName + '.' + fk + ': ' + inst[fk]); err.statusCode = 400; @@ -1781,7 +1782,12 @@ EmbedsMany.prototype.build = function(targetModelData) { * Add the target model instance to the 'embedsMany' relation * @param {Object|ID} acInst The actual instance or id value */ -EmbedsMany.prototype.add = function (acInst, cb) { +EmbedsMany.prototype.add = function (acInst, data, cb) { + if (typeof data === 'function') { + cb = data; + data = {}; + } + var self = this; var definition = this.definition; var modelTo = this.definition.modelTo; @@ -1807,7 +1813,7 @@ EmbedsMany.prototype.add = function (acInst, cb) { referenceDef.modelTo.findOne(filter, function(err, ref) { if (ref instanceof referenceDef.modelTo) { - var inst = self.build(); + var inst = self.build(data || {}); inst[options.reference](ref); modelInstance.save(function(err) { cb(err, err ? null : inst); @@ -1860,6 +1866,318 @@ EmbedsMany.prototype.remove = function (acInst, cb) { }; RelationDefinition.referencesMany = function referencesMany(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); + } + } + + var relationName = params.as || i8n.camelize(modelTo.pluralModelName, true); + var fk = params.foreignKey || i8n.camelize(modelTo.modelName + '_ids', true); + var idName = modelTo.dataSource.idName(modelTo.modelName) || 'id'; + var idType = modelTo.getPropertyType(idName); + + var definition = modelFrom.relations[relationName] = new RelationDefinition({ + name: relationName, + type: RelationTypes.referencesMany, + modelFrom: modelFrom, + keyFrom: fk, + keyTo: idName, + modelTo: modelTo, + multiple: true, + properties: params.properties, + scope: params.scope, + options: params.options + }); + + modelFrom.dataSource.defineProperty(modelFrom.modelName, fk, { + type: [idType], default: function() { return []; } + }); + + modelFrom.validate(relationName, function(err) { + var ids = this[fk] || []; + var uniqueIds = ids.filter(function(id, pos) { + return ids.indexOf(id) === pos; + }); + if (ids.length !== uniqueIds.length) { + var msg = 'Contains duplicate `' + modelTo.modelName + '` instance'; + this.errors.add(relationName, msg, 'uniqueness'); + err(false); + } + }, { code: 'uniqueness' }) + + var scopeMethods = { + findById: scopeMethod(definition, 'findById'), + destroy: scopeMethod(definition, 'destroyById'), + updateById: scopeMethod(definition, 'updateById'), + exists: scopeMethod(definition, 'exists'), + add: scopeMethod(definition, 'add'), + remove: scopeMethod(definition, 'remove'), + at: scopeMethod(definition, 'at') + }; + + 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, relationName, function () { + return {}; + }, scopeMethods); + + scopeDefinition.related = scopeMethod(definition, 'related'); // bound to definition }; +ReferencesMany.prototype.related = function(receiver, scopeParams, condOrRefresh, cb) { + var fk = this.definition.keyFrom; + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + 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 ids = self[fk] || []; + + this.definition.applyScope(modelInstance, actualCond); + + var params = mergeQuery(actualCond, scopeParams); + + modelTo.findByIds(ids, params, cb); +}; + +ReferencesMany.prototype.findById = function (fkId, cb) { + var modelTo = this.definition.modelTo; + var modelFrom = this.definition.modelFrom; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + + var modelTo = this.definition.modelTo; + var pk = this.definition.keyTo; + var fk = this.definition.keyFrom; + + if (typeof fkId === 'object') { + fkId = fkId.toString(); // mongodb + } + + var ids = [fkId]; + + var filter = {}; + + this.definition.applyScope(modelInstance, filter); + + modelTo.findByIds(ids, filter, function (err, instances) { + if (err) { + return cb(err); + } + + var inst = instances[0]; + if (!inst) { + err = new Error('No instance with id ' + fkId + ' found for ' + modelTo.modelName); + err.statusCode = 404; + return cb(err); + } + + var currentIds = ids.map(function(id) { return id.toString(); }); + var id = (inst[pk] || '').toString(); // mongodb + + // Check if the foreign key is amongst the ids + if (currentIds.indexOf(id) > -1) { + cb(null, inst); + } else { + err = new Error('Key mismatch: ' + modelFrom.modelName + '.' + fk + + ': ' + modelInstance[fk] + + ', ' + modelTo.modelName + '.' + pk + ': ' + inst[pk]); + err.statusCode = 400; + cb(err); + } + }); +}; + +ReferencesMany.prototype.exists = function (fkId, cb) { + var fk = this.definition.keyFrom; + var ids = this.modelInstance[fk] || []; + var currentIds = ids.map(function(id) { return id.toString(); }); + var fkId = (fkId || '').toString(); // mongodb + process.nextTick(function() { cb(null, currentIds.indexOf(fkId) > -1) }); +}; + +ReferencesMany.prototype.updateById = function (fkId, data, cb) { + if (typeof data === 'function') { + cb = data; + data = {}; + } + + this.findById(fkId, function(err, inst) { + if (err) return cb(err); + inst.updateAttributes(data, cb); + }); +}; + +ReferencesMany.prototype.destroyById = function (fkId, cb) { + var self = this; + this.findById(fkId, function(err, inst) { + if (err) return cb(err); + self.remove(inst, function(err, ids) { + inst.destroy(cb); + }); + }); +}; + +ReferencesMany.prototype.at = function (index, cb) { + var fk = this.definition.keyFrom; + var ids = this.modelInstance[fk] || []; + this.findById(ids[index], cb); +}; + +ReferencesMany.prototype.create = function (targetModelData, cb) { + var definition = this.definition; + var modelTo = this.definition.modelTo; + var relationName = this.definition.name; + var modelInstance = this.modelInstance; + + var pk = this.definition.keyTo; + var fk = this.definition.keyFrom; + + if (typeof targetModelData === 'function' && !cb) { + cb = targetModelData; + targetModelData = {}; + } + targetModelData = targetModelData || {}; + + var ids = modelInstance[fk] || []; + + var inst = this.build(targetModelData); + + inst.save(function(err, inst) { + if (err) return cb(err, inst); + + var id = inst[pk]; + + if (typeof id === 'object') { + id = id.toString(); // mongodb + } + + if (definition.options.prepend) { + ids.unshift(id); + } else { + ids.push(id); + } + + modelInstance.updateAttribute(fk, + ids, function(err, modelInst) { + cb(err, inst); + }); + }); +}; + +ReferencesMany.prototype.build = function(targetModelData) { + var modelTo = this.definition.modelTo; + targetModelData = targetModelData || {}; + + this.definition.applyProperties(this.modelInstance, targetModelData); + + return new modelTo(targetModelData); +}; + +/** + * Add the target model instance to the 'embedsMany' relation + * @param {Object|ID} acInst The actual instance or id value + */ +ReferencesMany.prototype.add = function (acInst, cb) { + var self = this; + var definition = this.definition; + var modelTo = this.definition.modelTo; + var modelInstance = this.modelInstance; + + var pk = this.definition.keyTo; + var fk = this.definition.keyFrom; + + var insertId = function(id, done) { + if (typeof id === 'object') { + id = id.toString(); // mongodb + } + + var ids = modelInstance[fk] || []; + + if (definition.options.prepend) { + ids.unshift(id); + } else { + ids.push(id); + } + + modelInstance.updateAttribute(fk, ids, function(err, inst) { + done(err, inst[fk] || []); + }); + }; + + if (acInst instanceof modelTo) { + insertId(acInst[pk], cb); + } else { + var filter = { where: {} }; + filter.where[pk] = acInst; + + definition.applyScope(modelInstance, filter); + + modelTo.findOne(filter, function (err, inst) { + if (err || !inst) return cb(err, modelInstance[fk]); + insertId(inst[pk], cb); + }); + } +}; + +/** + * Remove the target model instance from the 'embedsMany' relation + * @param {Object|ID) acInst The actual instance or id value + */ +ReferencesMany.prototype.remove = function (acInst, cb) { + var definition = this.definition; + var modelInstance = this.modelInstance; + + var pk = this.definition.keyTo; + var fk = this.definition.keyFrom; + + var ids = modelInstance[fk] || []; + + var currentIds = ids.map(function(id) { return id.toString(); }); + + var id = (acInst instanceof definition.modelTo) ? acInst[pk] : acInst; + id = id.toString(); + + var index = currentIds.indexOf(id); + if (index > -1) { + ids.splice(index, 1); + modelInstance.updateAttribute(fk, ids, function(err, inst) { + cb(err, inst[fk] || []); + }); + } else { + process.nextTick(function() { cb(null, ids); }); + } +}; \ No newline at end of file diff --git a/test/relations.test.js b/test/relations.test.js index a3d04a7d..fb26a40c 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1843,6 +1843,8 @@ describe('relations', function () { describe('referencesMany', function () { + var product1, product2, product3; + before(function (done) { db = getSchema(); Category = db.define('Category', {name: String}); @@ -1859,6 +1861,219 @@ describe('relations', function () { Category.referencesMany(Product); db.automigrate(done); }); + + it('should setup test records', function (done) { + Product.create({ name: 'Product 1' }, function(err, p) { + product1 = p; + Product.create({ name: 'Product 3' }, function(err, p) { + product3 = p; + done(); + }); + }); + }); + + it('should create record on scope', function (done) { + Category.create({ name: 'Category A' }, function(err, cat) { + cat.productIds.should.be.an.array; + cat.productIds.should.have.length(0); + cat.products.create({ name: 'Product 2' }, function(err, p) { + should.not.exist(err); + cat.productIds.should.have.length(1); + cat.productIds.should.eql([p.id]); + p.name.should.equal('Product 2'); + product2 = p; + done(); + }); + }); + }); + + it('should not create duplicate record on scope', function (done) { + Category.findOne(function(err, cat) { + cat.productIds = [product2.id, product2.id]; + cat.save(function(err, p) { + should.exist(err); + err.name.should.equal('ValidationError'); + err.details.codes.products.should.eql(['uniqueness']); + var expected = 'The `Category` instance is not valid. '; + expected += 'Details: `products` Contains duplicate `Product` instance.'; + err.message.should.equal(expected); + done(); + }); + }); + }); + + it('should find items on scope', function (done) { + Category.findOne(function(err, cat) { + cat.productIds.should.eql([product2.id]); + cat.products(function(err, products) { + should.not.exist(err); + var p = products[0]; + p.id.should.eql(product2.id); + p.name.should.equal('Product 2'); + done(); + }); + }); + }); + + it('should find items on scope - findById', function (done) { + Category.findOne(function(err, cat) { + cat.productIds.should.eql([product2.id]); + cat.products.findById(product2.id, function(err, p) { + should.not.exist(err); + p.should.be.instanceof(Product); + p.id.should.eql(product2.id); + p.name.should.equal('Product 2'); + done(); + }); + }); + }); + + it('should check if a record exists on scope', function (done) { + Category.findOne(function(err, cat) { + cat.products.exists(product2.id, function(err, exists) { + should.not.exist(err); + should.exist(exists); + done(); + }); + }); + }); + + it('should update a record on scope', function (done) { + Category.findOne(function(err, cat) { + var attrs = { name: 'Product 2 - edit' }; + cat.products.updateById(product2.id, attrs, function(err, p) { + should.not.exist(err); + p.name.should.equal(attrs.name); + done(); + }); + }); + }); + + it('should get a record by index - at', function (done) { + Category.findOne(function(err, cat) { + cat.products.at(0, function(err, p) { + should.not.exist(err); + p.should.be.instanceof(Product); + p.id.should.eql(product2.id); + p.name.should.equal('Product 2 - edit'); + done(); + }); + }); + }); + + it('should add a record to scope - object', function (done) { + Category.findOne(function(err, cat) { + cat.products.add(product1, function(err, ids) { + should.not.exist(err); + cat.productIds.should.eql([product2.id, product1.id]); + ids.should.eql(cat.productIds); + done(); + }); + }); + }); + + it('should add a record to scope - object', function (done) { + Category.findOne(function(err, cat) { + cat.products.add(product3.id, function(err, ids) { + should.not.exist(err); + var expected = [product2.id, product1.id, product3.id]; + cat.productIds.should.eql(expected); + ids.should.eql(cat.productIds); + done(); + }); + }); + }); + + it('should find items on scope - findById', function (done) { + Category.findOne(function(err, cat) { + cat.products.findById(product3.id, function(err, p) { + should.not.exist(err); + p.id.should.eql(product3.id); + p.name.should.equal('Product 3'); + done(); + }); + }); + }); + + it('should find items on scope - filter', function (done) { + Category.findOne(function(err, cat) { + var filter = { where: { name: 'Product 1' } }; + cat.products(filter, function(err, products) { + should.not.exist(err); + products.should.have.length(1); + var p = products[0]; + p.id.should.eql(product1.id); + p.name.should.equal('Product 1'); + done(); + }); + }); + }); + + it('should remove items from scope', function (done) { + Category.findOne(function(err, cat) { + cat.products.remove(product1.id, function(err, ids) { + should.not.exist(err); + var expected = [product2.id, product3.id]; + cat.productIds.should.eql(expected); + ids.should.eql(cat.productIds); + done(); + }); + }); + }); + + it('should find items on scope - verify', function (done) { + Category.findOne(function(err, cat) { + var expected = [product2.id, product3.id]; + cat.productIds.should.eql(expected); + cat.products(function(err, products) { + should.not.exist(err); + products.should.have.length(2); + products[0].id.should.eql(product2.id); + products[1].id.should.eql(product3.id); + done(); + }); + }); + }); + + it('should include related items from scope', function(done) { + Category.find({ include: 'products' }, function(err, categories) { + categories.should.have.length(1); + var cat = categories[0].toObject(); + cat.name.should.equal('Category A'); + cat.products.should.have.length(2); + cat.products[0].id.should.eql(product2.id); + cat.products[1].id.should.eql(product3.id); + done(); + }); + }); + + it('should destroy items from scope - destroyById', function (done) { + Category.findOne(function(err, cat) { + cat.products.destroy(product2.id, function(err) { + should.not.exist(err); + var expected = [product3.id]; + cat.productIds.should.eql(expected); + Product.exists(product2.id, function(err, exists) { + should.not.exist(err); + should.exist(exists); + done(); + }); + }); + }); + }); + + it('should find items on scope - verify', function (done) { + Category.findOne(function(err, cat) { + var expected = [product3.id]; + cat.productIds.should.eql(expected); + cat.products(function(err, products) { + should.not.exist(err); + products.should.have.length(1); + products[0].id.should.eql(product3.id); + done(); + }); + }); + }); }); From 06f2b32c21c669cf93bb70194edba1c8945873e4 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 29 Jul 2014 21:56:59 +0200 Subject: [PATCH 14/20] Renamed EmbedsMany 'reference' option to 'belongsTo' --- lib/relation-definition.js | 34 +++++++++++++++++----------------- test/relations.test.js | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 96c18c32..bbafb8d0 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1794,27 +1794,27 @@ EmbedsMany.prototype.add = function (acInst, data, cb) { var modelInstance = this.modelInstance; var options = definition.options; - var referenceDef = options.reference && modelTo.relations[options.reference]; + var belongsTo = options.belongsTo && modelTo.relations[options.belongsTo]; - if (!referenceDef) { - throw new Error('Invalid reference: ' + options.reference || '(none)'); + if (!belongsTo) { + throw new Error('Invalid reference: ' + options.belongsTo || '(none)'); } - var fk2 = referenceDef.keyTo; - var pk2 = referenceDef.modelTo.definition.idName() || 'id'; + var fk2 = belongsTo.keyTo; + var pk2 = belongsTo.modelTo.definition.idName() || 'id'; var query = {}; - query[fk2] = (acInst instanceof referenceDef.modelTo) ? acInst[pk2] : acInst; + query[fk2] = (acInst instanceof belongsTo.modelTo) ? acInst[pk2] : acInst; var filter = { where: query }; - referenceDef.applyScope(modelInstance, filter); + belongsTo.applyScope(modelInstance, filter); - referenceDef.modelTo.findOne(filter, function(err, ref) { - if (ref instanceof referenceDef.modelTo) { + belongsTo.modelTo.findOne(filter, function(err, ref) { + if (ref instanceof belongsTo.modelTo) { var inst = self.build(data || {}); - inst[options.reference](ref); + inst[options.belongsTo](ref); modelInstance.save(function(err) { cb(err, err ? null : inst); }); @@ -1835,22 +1835,22 @@ EmbedsMany.prototype.remove = function (acInst, cb) { var modelInstance = this.modelInstance; var options = definition.options; - var referenceDef = options.reference && modelTo.relations[options.reference]; + var belongsTo = options.belongsTo && modelTo.relations[options.belongsTo]; - if (!referenceDef) { - throw new Error('Invalid reference: ' + options.reference || '(none)'); + if (!belongsTo) { + throw new Error('Invalid reference: ' + options.belongsTo || '(none)'); } - var fk2 = referenceDef.keyTo; - var pk2 = referenceDef.modelTo.definition.idName() || 'id'; + var fk2 = belongsTo.keyTo; + var pk2 = belongsTo.modelTo.definition.idName() || 'id'; var query = {}; - query[fk2] = (acInst instanceof referenceDef.modelTo) ? acInst[pk2] : acInst; + query[fk2] = (acInst instanceof belongsTo.modelTo) ? acInst[pk2] : acInst; var filter = { where: query }; - referenceDef.applyScope(modelInstance, filter); + belongsTo.applyScope(modelInstance, filter); modelInstance[definition.accessor](filter, function(err, items) { if (err) return cb(err); diff --git a/test/relations.test.js b/test/relations.test.js index fb26a40c..9319a2f6 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1562,7 +1562,7 @@ describe('relations', function () { Category.embedsMany(Link, { as: 'items', // rename scope: { include: 'product' }, // always include - options: { reference: 'product' } // optional, for add()/remove() + options: { belongsTo: 'product' } // optional, for add()/remove() }); Link.belongsTo(Product, { foreignKey: 'id', // re-use the actual product id From e888b8cff9ebc3e21a0e630bd439036850b405e3 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 29 Jul 2014 22:59:44 +0200 Subject: [PATCH 15/20] Allow custom scopeMethods option (obj/fn) for relation scopes --- lib/relation-definition.js | 34 ++++++++++++++++++++++++++++------ test/relations.test.js | 28 ++++++++++++++++++++++++---- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index bbafb8d0..b763c06e 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -52,7 +52,25 @@ function normalizeType(type) { } } return null; -} +}; + +function extendScopeMethods(definition, scopeMethods, ext) { + var relationClass = RelationClasses[definition.type]; + if (definition.type === RelationTypes.hasMany && definition.modelThrough) { + relationClass = RelationClasses.hasManyThrough; + } + if (typeof ext === 'function') { + ext.call(definition, scopeMethods, relationClass); + } else if (typeof ext === 'object') { + for (var key in ext) { + scopeMethods[key] = function () { + var relation = new relationClass(definition, this); + return ext[key].apply(relation, arguments); + }; + } + } + return scopeMethods; +}; /** * Relation definition class. Use to define relationships between models. @@ -518,7 +536,7 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { } return filter; - }, scopeMethods); + }, extendScopeMethods(definition, scopeMethods, params.scopeMethods)); }; @@ -1536,12 +1554,14 @@ RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) scopeMethods.create = scopeMethod(definition, 'create'); scopeMethods.build = scopeMethod(definition, 'build'); + scopeMethods.related = scopeMethod(definition, 'related'); // bound to definition + // Mix the property and scoped methods into the prototype class var scopeDefinition = defineScope(modelFrom.prototype, modelTo, accessorName, function () { return {}; - }, scopeMethods); + }, extendScopeMethods(definition, scopeMethods, params.scopeMethods)); - scopeDefinition.related = scopeMethod(definition, 'related'); // bound to definition + scopeDefinition.related = scopeMethods.related; }; EmbedsMany.prototype.related = function(receiver, scopeParams, condOrRefresh, cb) { @@ -1934,12 +1954,14 @@ RelationDefinition.referencesMany = function referencesMany(modelFrom, modelTo, scopeMethods.create = scopeMethod(definition, 'create'); scopeMethods.build = scopeMethod(definition, 'build'); + scopeMethods.related = scopeMethod(definition, 'related'); + // Mix the property and scoped methods into the prototype class var scopeDefinition = defineScope(modelFrom.prototype, modelTo, relationName, function () { return {}; - }, scopeMethods); + }, extendScopeMethods(definition, scopeMethods, params.scopeMethods)); - scopeDefinition.related = scopeMethod(definition, 'related'); // bound to definition + scopeDefinition.related = scopeMethods.related; // bound to definition }; ReferencesMany.prototype.related = function(receiver, scopeParams, condOrRefresh, cb) { diff --git a/test/relations.test.js b/test/relations.test.js index 9319a2f6..bcedc425 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1858,7 +1858,16 @@ describe('relations', function () { }); it('can be declared', function (done) { - Category.referencesMany(Product); + Category.referencesMany(Product, { scopeMethods: { + reverse: function(cb) { + var modelInstance = this.modelInstance; + var fk = this.definition.keyFrom; + var ids = modelInstance[fk] || []; + modelInstance.updateAttribute(fk, ids.reverse(), function(err, inst) { + cb(err, inst[fk] || []); + }); + } + } }); db.automigrate(done); }); @@ -1887,7 +1896,7 @@ describe('relations', function () { }); }); - it('should not create duplicate record on scope', function (done) { + it('should not allow duplicate record on scope', function (done) { Category.findOne(function(err, cat) { cat.productIds = [product2.id, product2.id]; cat.save(function(err, p) { @@ -2035,14 +2044,25 @@ describe('relations', function () { }); }); + it('should allow custom scope methods - reverse', function(done) { + Category.findOne(function(err, cat) { + cat.products.reverse(function(err, ids) { + var expected = [product3.id, product2.id]; + ids.should.eql(expected); + cat.productIds.should.eql(expected); + done(); + }); + }) + }); + it('should include related items from scope', function(done) { Category.find({ include: 'products' }, function(err, categories) { categories.should.have.length(1); var cat = categories[0].toObject(); cat.name.should.equal('Category A'); cat.products.should.have.length(2); - cat.products[0].id.should.eql(product2.id); - cat.products[1].id.should.eql(product3.id); + cat.products[0].id.should.eql(product3.id); + cat.products[1].id.should.eql(product2.id); done(); }); }); From 7a9b64f1bf1f2d377d3e9f9da0929d7acb4ff71d Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Tue, 29 Jul 2014 22:19:52 -0700 Subject: [PATCH 16/20] Fix the test failure for mongodb --- lib/relation-definition.js | 2 +- test/relations.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index b763c06e..69dc2117 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1901,7 +1901,7 @@ RelationDefinition.referencesMany = function referencesMany(modelFrom, modelTo, var relationName = params.as || i8n.camelize(modelTo.pluralModelName, true); var fk = params.foreignKey || i8n.camelize(modelTo.modelName + '_ids', true); var idName = modelTo.dataSource.idName(modelTo.modelName) || 'id'; - var idType = modelTo.getPropertyType(idName); + var idType = modelTo.definition.properties[idName].type; var definition = modelFrom.relations[relationName] = new RelationDefinition({ name: relationName, diff --git a/test/relations.test.js b/test/relations.test.js index bcedc425..245b9bec 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1663,7 +1663,7 @@ describe('relations', function () { cat.links.should.have.length(1); cat.items.add(product3, function(err, link) { link.should.be.instanceof(Link); - link.id.should.equal(product3.id); + link.id.should.eql(product3.id); link.name.should.equal('Product 3'); cat.links.should.have.length(2); From 5cee6a4b794e6a14abd3776e1bf010b6b02d026b Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Wed, 30 Jul 2014 13:22:20 +0200 Subject: [PATCH 17/20] Fixed embedsMany after LB integration --- lib/relation-definition.js | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 69dc2117..2167ecb5 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -128,6 +128,7 @@ RelationDefinition.prototype.toJSON = function () { * @param {Object} filter (where, order, limit, fields, ...) */ RelationDefinition.prototype.applyScope = function(modelInstance, filter) { + filter = filter || {}; filter.where = filter.where || {}; if ((this.type !== 'belongsTo' || this.type === 'hasOne') && typeof this.discriminator === 'string') { // polymorphic @@ -1537,19 +1538,19 @@ RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) }; var findByIdFunc = scopeMethods.findById; - modelFrom.prototype['__findById__' + relationName] = findByIdFunc; + modelFrom.prototype['__findById__' + accessorName] = findByIdFunc; var destroyByIdFunc = scopeMethods.destroy; - modelFrom.prototype['__destroyById__' + relationName] = destroyByIdFunc; + modelFrom.prototype['__destroyById__' + accessorName] = destroyByIdFunc; var updateByIdFunc = scopeMethods.updateById; - modelFrom.prototype['__updateById__' + relationName] = updateByIdFunc; + modelFrom.prototype['__updateById__' + accessorName] = updateByIdFunc; var addFunc = scopeMethods.add; - modelFrom.prototype['__link__' + relationName] = addFunc; + modelFrom.prototype['__link__' + accessorName] = addFunc; var removeFunc = scopeMethods.remove; - modelFrom.prototype['__unlink__' + relationName] = removeFunc; + modelFrom.prototype['__unlink__' + accessorName] = removeFunc; scopeMethods.create = scopeMethod(definition, 'create'); scopeMethods.build = scopeMethod(definition, 'build'); @@ -1614,17 +1615,15 @@ EmbedsMany.prototype.findById = function (fkId, cb) { 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; + if (item[pk].toString() === id) return item; } return null; }; - var item = find(fkId); + var item = find(fkId.toString()); // in case of explicit id item = (item instanceof modelTo) ? item : null; if (typeof cb === 'function') { @@ -1700,13 +1699,11 @@ EmbedsMany.prototype.destroyById = function (fkId, cb) { if (typeof cb === 'function') { modelInstance.updateAttribute(relationName, embeddedList, function(err) { - cb(err, inst); + cb(err); }); } } else if (typeof cb === 'function') { - process.nextTick(function() { - cb(null, null); // not found - }); + process.nextTick(cb); // not found } return inst; // sync }; @@ -1759,7 +1756,7 @@ EmbedsMany.prototype.create = function (targetModelData, cb) { return process.nextTick(function() { cb(err, embeddedList); }); - } + } modelInstance.updateAttribute(relationName, embeddedList, function(err, modelInst) { @@ -1782,7 +1779,11 @@ EmbedsMany.prototype.build = function(targetModelData) { var ids = embeddedList.map(function(m) { return (typeof m[pk] === 'number' ? m[pk] : 0); }); - targetModelData[pk] = (Math.max(ids) || 0) + 1; + if (ids.length > 0) { + targetModelData[pk] = Math.max.apply(null, ids) + 1; + } else { + targetModelData[pk] = 1; + } } this.definition.applyProperties(this.modelInstance, targetModelData); @@ -1880,7 +1881,7 @@ EmbedsMany.prototype.remove = function (acInst, cb) { }); modelInstance.save(function(err) { - cb(err, err ? [] : items); + cb(err); }); }); }; From e38c92af8787acd8cc996b4c6e77101c74f61e8e Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Wed, 30 Jul 2014 15:01:55 +0200 Subject: [PATCH 18/20] ReferencesMany fixes after LB integration tests --- lib/relation-definition.js | 37 +++++++++++++++++++++--------------- test/relations.test.js | 39 +++++++++++++++++--------------------- 2 files changed, 39 insertions(+), 37 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 2167ecb5..12669318 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -1503,6 +1503,7 @@ RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) // validate all embedded items if (definition.options.validate) { modelFrom.validate(relationName, function(err) { + var self = this; var embeddedList = this[relationName] || []; var hasErrors = false; embeddedList.forEach(function(item) { @@ -1513,13 +1514,13 @@ RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) var first = Object.keys(item.errors)[0]; var msg = 'contains invalid item: `' + id + '`'; msg += ' (' + first + ' ' + item.errors[first] + ')'; - this.errors.add(relationName, msg, 'invalid'); + self.errors.add(relationName, msg, 'invalid'); } } else { hasErrors = true; - this.errors.add(relationName, 'Contains invalid item', 'invalid'); + self.errors.add(relationName, 'Contains invalid item', 'invalid'); } - }.bind(this)); + }); if (hasErrors) err(false); }); } @@ -1751,16 +1752,14 @@ EmbedsMany.prototype.create = function (targetModelData, cb) { var err = inst.isValid() ? null : new ValidationError(inst); if (err) { - var index = embeddedList.indexOf(inst); - if (index > -1) embeddedList.splice(index, 1); - return process.nextTick(function() { - cb(err, embeddedList); + return process.nextTick(function() { + cb(err); }); } - modelInstance.updateAttribute(relationName, + modelInstance.updateAttribute(relationName, embeddedList, function(err, modelInst) { - cb(err, modelInst[relationName]); + cb(err, err ? null : inst); }); }; @@ -1952,6 +1951,12 @@ RelationDefinition.referencesMany = function referencesMany(modelFrom, modelTo, var updateByIdFunc = scopeMethods.updateById; modelFrom.prototype['__updateById__' + relationName] = updateByIdFunc; + var addFunc = scopeMethods.add; + modelFrom.prototype['__link__' + relationName] = addFunc; + + var removeFunc = scopeMethods.remove; + modelFrom.prototype['__unlink__' + relationName] = removeFunc; + scopeMethods.create = scopeMethod(definition, 'create'); scopeMethods.build = scopeMethod(definition, 'build'); @@ -2143,7 +2148,9 @@ ReferencesMany.prototype.add = function (acInst, cb) { var pk = this.definition.keyTo; var fk = this.definition.keyFrom; - var insertId = function(id, done) { + var insert = function(inst, done) { + var id = inst[pk]; + if (typeof id === 'object') { id = id.toString(); // mongodb } @@ -2156,13 +2163,13 @@ ReferencesMany.prototype.add = function (acInst, cb) { ids.push(id); } - modelInstance.updateAttribute(fk, ids, function(err, inst) { - done(err, inst[fk] || []); + modelInstance.updateAttribute(fk, ids, function(err) { + done(err, err ? null : inst); }); }; if (acInst instanceof modelTo) { - insertId(acInst[pk], cb); + insert(acInst, cb); } else { var filter = { where: {} }; filter.where[pk] = acInst; @@ -2170,8 +2177,8 @@ ReferencesMany.prototype.add = function (acInst, cb) { definition.applyScope(modelInstance, filter); modelTo.findOne(filter, function (err, inst) { - if (err || !inst) return cb(err, modelInstance[fk]); - insertId(inst[pk], cb); + if (err || !inst) return cb(err, null); + insert(inst, cb); }); } }; diff --git a/test/relations.test.js b/test/relations.test.js index 245b9bec..b6030dac 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1255,12 +1255,11 @@ describe('relations', 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) { + p.addressList.create({ street: 'Street 1' }, function(err, address) { should.not.exist(err); - addresses.should.have.length(1); - address1 = addresses[0]; + address1 = address; should.exist(address1.id); - addresses[0].street.should.equal('Street 1'); + address1.street.should.equal('Street 1'); done(); }); }); @@ -1268,13 +1267,9 @@ describe('relations', function () { it('should create embedded items on scope', function(done) { Person.findOne(function(err, p) { - p.addressList.create({ street: 'Street 2' }, function(err, addresses) { + p.addressList.create({ street: 'Street 2' }, function(err, address) { should.not.exist(err); - addresses.should.have.length(2); - address1 = addresses[0]; - address2 = addresses[1]; - should.exist(address1.id); - address1.street.should.equal('Street 1'); + address2 = address; should.exist(address2.id); address2.street.should.equal('Street 2'); done(); @@ -1310,11 +1305,11 @@ describe('relations', function () { it('should validate embedded items', function(done) { Person.findOne(function(err, p) { - p.addressList.create({}, function(err, addresses) { + p.addressList.create({}, function(err, address) { should.exist(err); + should.not.exist(address); err.name.should.equal('ValidationError'); err.details.codes.street.should.eql(['presence']); - addresses.should.have.length(2); done(); }); }); @@ -1428,12 +1423,10 @@ describe('relations', function () { 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'); + p.addressList.create({ id: 'work', street: 'Work Street 2' }, function(err, address) { + should.not.exist(err); + address.id.should.equal('work'); + address.street.should.equal('Work Street 2'); done(); }); }); @@ -1972,10 +1965,11 @@ describe('relations', function () { it('should add a record to scope - object', function (done) { Category.findOne(function(err, cat) { - cat.products.add(product1, function(err, ids) { + cat.products.add(product1, function(err, prod) { should.not.exist(err); cat.productIds.should.eql([product2.id, product1.id]); - ids.should.eql(cat.productIds); + prod.id.should.eql(product1.id); + prod.should.have.property('name'); done(); }); }); @@ -1983,11 +1977,12 @@ describe('relations', function () { it('should add a record to scope - object', function (done) { Category.findOne(function(err, cat) { - cat.products.add(product3.id, function(err, ids) { + cat.products.add(product3.id, function(err, prod) { should.not.exist(err); var expected = [product2.id, product1.id, product3.id]; cat.productIds.should.eql(expected); - ids.should.eql(cat.productIds); + prod.id.should.eql(product3.id); + prod.should.have.property('name'); done(); }); }); From af0ca5b1086e2141c2b523e91b46e6061c9125e1 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Wed, 30 Jul 2014 16:46:05 +0200 Subject: [PATCH 19/20] Handle remoting of custom scope methods --- lib/relation-definition.js | 43 +++++++++++++++++++++++++++++++++----- test/relations.test.js | 23 +++++++++++++------- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 12669318..4185b22f 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -55,21 +55,24 @@ function normalizeType(type) { }; function extendScopeMethods(definition, scopeMethods, ext) { + var customMethods = []; var relationClass = RelationClasses[definition.type]; if (definition.type === RelationTypes.hasMany && definition.modelThrough) { relationClass = RelationClasses.hasManyThrough; } if (typeof ext === 'function') { - ext.call(definition, scopeMethods, relationClass); + customMethods = ext.call(definition, scopeMethods, relationClass); } else if (typeof ext === 'object') { for (var key in ext) { scopeMethods[key] = function () { var relation = new relationClass(definition, this); return ext[key].apply(relation, arguments); }; + if (ext[key].shared === true) scopeMethods[key].shared = true; + customMethods.push(key); } } - return scopeMethods; + return [].concat(customMethods || []); }; /** @@ -519,6 +522,16 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { scopeMethods.build = scopeMethod(definition, 'build'); } + var customMethods = extendScopeMethods(definition, scopeMethods, params.scopeMethods); + + for (var i = 0; i < customMethods.length; i++) { + var methodName = customMethods[i]; + var method = scopeMethods[methodName]; + if (typeof method === 'function' && method.shared === true) { + modelFrom.prototype['__' + methodName + '__' + relationName] = method; + } + }; + // Mix the property and scoped methods into the prototype class defineScope(modelFrom.prototype, params.through || modelTo, relationName, function () { var filter = {}; @@ -537,7 +550,7 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) { } return filter; - }, extendScopeMethods(definition, scopeMethods, params.scopeMethods)); + }, scopeMethods); }; @@ -1558,10 +1571,20 @@ RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) scopeMethods.related = scopeMethod(definition, 'related'); // bound to definition + var customMethods = extendScopeMethods(definition, scopeMethods, params.scopeMethods); + + for (var i = 0; i < customMethods.length; i++) { + var methodName = customMethods[i]; + var method = scopeMethods[methodName]; + if (typeof method === 'function' && method.shared === true) { + modelFrom.prototype['__' + methodName + '__' + accessorName] = method; + } + }; + // Mix the property and scoped methods into the prototype class var scopeDefinition = defineScope(modelFrom.prototype, modelTo, accessorName, function () { return {}; - }, extendScopeMethods(definition, scopeMethods, params.scopeMethods)); + }, scopeMethods); scopeDefinition.related = scopeMethods.related; }; @@ -1962,10 +1985,20 @@ RelationDefinition.referencesMany = function referencesMany(modelFrom, modelTo, scopeMethods.related = scopeMethod(definition, 'related'); + var customMethods = extendScopeMethods(definition, scopeMethods, params.scopeMethods); + + for (var i = 0; i < customMethods.length; i++) { + var methodName = customMethods[i]; + var method = scopeMethods[methodName]; + if (typeof method === 'function' && method.shared === true) { + modelFrom.prototype['__' + methodName + '__' + relationName] = method; + } + }; + // Mix the property and scoped methods into the prototype class var scopeDefinition = defineScope(modelFrom.prototype, modelTo, relationName, function () { return {}; - }, extendScopeMethods(definition, scopeMethods, params.scopeMethods)); + }, scopeMethods); scopeDefinition.related = scopeMethods.related; // bound to definition }; diff --git a/test/relations.test.js b/test/relations.test.js index b6030dac..77a198f9 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1851,16 +1851,23 @@ describe('relations', function () { }); it('can be declared', function (done) { + var reverse = function(cb) { + var modelInstance = this.modelInstance; + var fk = this.definition.keyFrom; + var ids = modelInstance[fk] || []; + modelInstance.updateAttribute(fk, ids.reverse(), function(err, inst) { + cb(err, inst[fk] || []); + }); + }; + + reverse.shared = true; // remoting + Category.referencesMany(Product, { scopeMethods: { - reverse: function(cb) { - var modelInstance = this.modelInstance; - var fk = this.definition.keyFrom; - var ids = modelInstance[fk] || []; - modelInstance.updateAttribute(fk, ids.reverse(), function(err, inst) { - cb(err, inst[fk] || []); - }); - } + reverse: reverse } }); + + Category.prototype['__reverse__products'].should.be.a.function; + db.automigrate(done); }); From 090c738bb5b1a1a6b77fe02b32c7f3724d2c47db Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Wed, 30 Jul 2014 17:30:21 +0200 Subject: [PATCH 20/20] Correctly handle remoting of scope methods --- lib/relation-definition.js | 13 ++++++++++--- test/relations.test.js | 3 +++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 4185b22f..44532273 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -64,11 +64,18 @@ function extendScopeMethods(definition, scopeMethods, ext) { customMethods = ext.call(definition, scopeMethods, relationClass); } else if (typeof ext === 'object') { for (var key in ext) { - scopeMethods[key] = function () { + var relationMethod = ext[key]; + var method = scopeMethods[key] = function () { var relation = new relationClass(definition, this); - return ext[key].apply(relation, arguments); + return relationMethod.apply(relation, arguments); }; - if (ext[key].shared === true) scopeMethods[key].shared = true; + if (relationMethod.shared) { + method.shared = true; + method.accepts = relationMethod.accepts; + method.returns = relationMethod.returns; + method.http = relationMethod.http; + method.description = relationMethod.description; + } customMethods.push(key); } } diff --git a/test/relations.test.js b/test/relations.test.js index 77a198f9..bb054a7a 100644 --- a/test/relations.test.js +++ b/test/relations.test.js @@ -1861,12 +1861,15 @@ describe('relations', function () { }; reverse.shared = true; // remoting + reverse.http = { verb: 'put', path: '/products/reverse' }; Category.referencesMany(Product, { scopeMethods: { reverse: reverse } }); Category.prototype['__reverse__products'].should.be.a.function; + should.exist(Category.prototype['__reverse__products'].shared); + Category.prototype['__reverse__products'].http.should.eql(reverse.http); db.automigrate(done); });