Merge branch 'feature/embed-hasmany' of github.com:fabien/loopback-datasource-juggler into fabien-feature/embed-hasmany

This commit is contained in:
Raymond Feng 2014-07-28 14:51:16 -07:00
commit 1b44a6d779
5 changed files with 630 additions and 3 deletions

View File

@ -17,6 +17,7 @@ exports.initialize = function initializeDataSource(dataSource, callback) {
}; };
exports.Memory = Memory; exports.Memory = Memory;
exports.applyFilter = applyFilter;
function Memory(m, settings) { function Memory(m, settings) {
if (m instanceof Memory) { if (m instanceof Memory) {

View File

@ -7,6 +7,8 @@ var i8n = require('inflection');
var defineScope = require('./scope.js').defineScope; var defineScope = require('./scope.js').defineScope;
var mergeQuery = require('./scope.js').mergeQuery; var mergeQuery = require('./scope.js').mergeQuery;
var ModelBaseClass = require('./model.js'); var ModelBaseClass = require('./model.js');
var applyFilter = require('./connectors/memory').applyFilter;
var ValidationError = require('./validations.js').ValidationError;
exports.Relation = Relation; exports.Relation = Relation;
exports.RelationDefinition = RelationDefinition; exports.RelationDefinition = RelationDefinition;
@ -15,7 +17,8 @@ var RelationTypes = {
belongsTo: 'belongsTo', belongsTo: 'belongsTo',
hasMany: 'hasMany', hasMany: 'hasMany',
hasOne: 'hasOne', hasOne: 'hasOne',
hasAndBelongsToMany: 'hasAndBelongsToMany' hasAndBelongsToMany: 'hasAndBelongsToMany',
embedsMany: 'embedsMany'
}; };
exports.RelationTypes = RelationTypes; exports.RelationTypes = RelationTypes;
@ -24,13 +27,15 @@ exports.HasManyThrough = HasManyThrough;
exports.HasOne = HasOne; exports.HasOne = HasOne;
exports.HasAndBelongsToMany = HasAndBelongsToMany; exports.HasAndBelongsToMany = HasAndBelongsToMany;
exports.BelongsTo = BelongsTo; exports.BelongsTo = BelongsTo;
exports.EmbedsMany = EmbedsMany;
var RelationClasses = { var RelationClasses = {
belongsTo: BelongsTo, belongsTo: BelongsTo,
hasMany: HasMany, hasMany: HasMany,
hasManyThrough: HasManyThrough, hasManyThrough: HasManyThrough,
hasOne: HasOne, hasOne: HasOne,
hasAndBelongsToMany: HasAndBelongsToMany hasAndBelongsToMany: HasAndBelongsToMany,
embedsMany: EmbedsMany
}; };
function normalizeType(type) { function normalizeType(type) {
@ -75,6 +80,7 @@ function RelationDefinition(definition) {
this.properties = definition.properties || {}; this.properties = definition.properties || {};
this.options = definition.options || {}; this.options = definition.options || {};
this.scope = definition.scope; this.scope = definition.scope;
this.embed = definition.embed === true;
} }
RelationDefinition.prototype.toJSON = function () { RelationDefinition.prototype.toJSON = function () {
@ -290,6 +296,23 @@ function HasOne(definition, modelInstance) {
util.inherits(HasOne, Relation); 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 * Find the relation by foreign key
@ -1375,3 +1398,310 @@ HasOne.prototype.related = function (refresh, params) {
self.resetCache(); 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 []; }
});
// 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) {
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 id = item[idName] || '(blank)';
var first = Object.keys(item.errors)[0];
var msg = 'contains invalid item: `' + id + '`';
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'),
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;
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) {
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);
if (this.definition.options.prepend) {
embeddedList.unshift(inst);
} else {
embeddedList.push(inst);
}
return inst;
};

View File

@ -161,3 +161,7 @@ RelationMixin.hasAndBelongsToMany = function hasAndBelongsToMany(modelTo, params
RelationMixin.hasOne = function hasMany(modelTo, params) { RelationMixin.hasOne = function hasMany(modelTo, params) {
RelationDefinition.hasOne(this, modelTo, params); RelationDefinition.hasOne(this, modelTo, params);
}; };
RelationMixin.embedsMany = function hasMany(modelTo, params) {
RelationDefinition.embedsMany(this, modelTo, params);
};

View File

@ -224,6 +224,8 @@ function defineScope(cls, targetClass, name, params, methods) {
var where = (this._scope && this._scope.where) || {}; var where = (this._scope && this._scope.where) || {};
targetClass.destroyAll(where, cb); targetClass.destroyAll(where, cb);
} }
return definition;
} }
/*! /*!

View File

@ -4,6 +4,7 @@ var should = require('./init.js');
var db, Book, Chapter, Author, Reader; var db, Book, Chapter, Author, Reader;
var Category, Product; var Category, Product;
var Picture, PictureLink; var Picture, PictureLink;
var Person, Address;
describe('relations', function () { describe('relations', function () {
@ -1218,4 +1219,293 @@ describe('relations', function () {
}); });
}); });
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, validate: true } });
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();
});
});
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();
});
});
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();
});
});
});
}); });