Implemented embedsOne

This commit is contained in:
Fabien Franzen 2014-08-19 22:10:35 +02:00
parent 2b262c04f6
commit 7c901af6d9
3 changed files with 321 additions and 11 deletions

View File

@ -20,6 +20,7 @@ var RelationTypes = {
hasOne: 'hasOne', hasOne: 'hasOne',
hasAndBelongsToMany: 'hasAndBelongsToMany', hasAndBelongsToMany: 'hasAndBelongsToMany',
referencesMany: 'referencesMany', referencesMany: 'referencesMany',
embedsOne: 'embedsOne',
embedsMany: 'embedsMany' embedsMany: 'embedsMany'
}; };
@ -30,6 +31,7 @@ exports.HasOne = HasOne;
exports.HasAndBelongsToMany = HasAndBelongsToMany; exports.HasAndBelongsToMany = HasAndBelongsToMany;
exports.BelongsTo = BelongsTo; exports.BelongsTo = BelongsTo;
exports.ReferencesMany = ReferencesMany; exports.ReferencesMany = ReferencesMany;
exports.EmbedsOne = EmbedsOne;
exports.EmbedsMany = EmbedsMany; exports.EmbedsMany = EmbedsMany;
var RelationClasses = { var RelationClasses = {
@ -39,6 +41,7 @@ var RelationClasses = {
hasOne: HasOne, hasOne: HasOne,
hasAndBelongsToMany: HasAndBelongsToMany, hasAndBelongsToMany: HasAndBelongsToMany,
referencesMany: ReferencesMany, referencesMany: ReferencesMany,
embedsOne: EmbedsOne,
embedsMany: EmbedsMany embedsMany: EmbedsMany
}; };
@ -384,6 +387,24 @@ function HasOne(definition, modelInstance) {
util.inherits(HasOne, Relation); util.inherits(HasOne, Relation);
/**
* EmbedsOne subclass
* @param {RelationDefinition|Object} definition
* @param {Object} modelInstance
* @returns {EmbedsMany}
* @constructor
* @class EmbedsOne
*/
function EmbedsOne(definition, modelInstance) {
if (!(this instanceof EmbedsOne)) {
return new EmbedsMany(definition, modelInstance);
}
assert(definition.type === RelationTypes.embedsOne);
Relation.apply(this, arguments);
}
util.inherits(EmbedsOne, Relation);
/** /**
* EmbedsMany subclass * EmbedsMany subclass
* @param {RelationDefinition|Object} definition * @param {RelationDefinition|Object} definition
@ -1539,6 +1560,170 @@ HasOne.prototype.related = function (refresh, params) {
} }
}; };
RelationDefinition.embedsOne = function (modelFrom, modelTo, params) {
params = params || {};
modelTo = lookupModelTo(modelFrom, modelTo, params);
var thisClassName = modelFrom.modelName;
var relationName = params.as || (i8n.camelize(modelTo.modelName, true) + 'Item');
var propertyName = params.property || i8n.camelize(modelTo.modelName, true);
var idName = modelTo.dataSource.idName(modelTo.modelName) || 'id';
if (relationName === propertyName) {
propertyName = '_' + propertyName;
debug('EmbedsOne property cannot be equal to relation name: ' +
'forcing property %s for relation %s', propertyName, relationName);
}
var definition = modelFrom.relations[relationName] = new RelationDefinition({
name: relationName,
type: RelationTypes.embedsOne,
modelFrom: modelFrom,
keyFrom: propertyName,
keyTo: idName,
modelTo: modelTo,
multiple: false,
properties: params.properties,
scope: params.scope,
options: params.options,
embed: true
});
var opts = { type: modelTo };
if (params.default === true) {
opts.default = function() { return new modelTo(); };
} else if (typeof params.default === 'object') {
opts.default = (function(def) {
return function() { return new modelTo(def); };
}(params.default));
}
modelFrom.dataSource.defineProperty(modelFrom.modelName, propertyName, opts);
// validate the embedded instance
if (definition.options.validate) {
modelFrom.validate(relationName, function(err) {
var inst = this[propertyName];
if (inst instanceof modelTo) {
if (!inst.isValid()) {
var first = Object.keys(inst.errors)[0];
var msg = 'is invalid: `' + first + '` ' + inst.errors[first];
this.errors.add(relationName, msg, 'invalid');
err(false);
}
}
});
}
// Define a property for the scope so that we have 'this' for the scoped methods
Object.defineProperty(modelFrom.prototype, relationName, {
enumerable: true,
configurable: true,
get: function() {
var relation = new EmbedsOne(definition, this);
var relationMethod = relation.related.bind(relation)
relationMethod.create = relation.create.bind(relation);
relationMethod.build = relation.build.bind(relation);
relationMethod.destroy = relation.destroy.bind(relation);
relationMethod._targetClass = definition.modelTo.modelName;
return relationMethod;
}
});
// FIXME: [rfeng] Wrap the property into a function for remoting
// so that it can be accessed as /api/<model>/<id>/<embedsOneRelationName>
// For example, /api/orders/1/customer
var fn = function() {
var f = this[relationName];
f.apply(this, arguments);
};
modelFrom.prototype['__get__' + relationName] = fn;
return definition;
};
EmbedsOne.prototype.related = function (refresh, params) {
var modelTo = this.definition.modelTo;
var modelInstance = this.modelInstance;
var propertyName = this.definition.keyFrom;
if (arguments.length === 1) {
params = refresh;
refresh = false;
} else if (arguments.length > 2) {
throw new Error('Method can\'t be called with more than two arguments');
}
if (params instanceof ModelBaseClass) { // acts as setter
if (params instanceof modelTo) {
this.definition.applyProperties(modelInstance, params);
modelInstance.setAttribute(propertyName, params);
}
return;
}
var embeddedInstance = modelInstance[propertyName];
if (typeof params === 'function') { // acts as async getter
var cb = params;
process.nextTick(function() {
cb(null, embeddedInstance);
});
} else if (params === undefined) { // acts as sync getter
return embeddedInstance;
}
};
EmbedsOne.prototype.create = function (targetModelData, cb) {
var modelTo = this.definition.modelTo;
var propertyName = this.definition.keyFrom;
var modelInstance = this.modelInstance;
if (typeof targetModelData === 'function' && !cb) {
cb = targetModelData;
targetModelData = {};
}
targetModelData = targetModelData || {};
var inst = this.build(targetModelData);
var err = inst.isValid() ? null : new ValidationError(inst);
if (err) {
return process.nextTick(function() {
cb(err);
});
}
modelInstance.updateAttribute(propertyName,
inst, function(err) {
cb(err, err ? null : inst);
});
};
EmbedsOne.prototype.build = function (targetModelData) {
var modelTo = this.definition.modelTo;
var modelInstance = this.modelInstance;
targetModelData = targetModelData || {};
this.definition.applyProperties(modelInstance, targetModelData);
return new modelTo(targetModelData);
};
EmbedsOne.prototype.destroy = function (cb) {
var modelInstance = this.modelInstance;
var propertyName = this.definition.keyFrom;
modelInstance.unsetAttribute(propertyName, true);
if (typeof cb === 'function') {
modelInstance.save(function(err) {
cb(err);
});
}
};
RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) { RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params) {
params = params || {}; params = params || {};
modelTo = lookupModelTo(modelFrom, modelTo, params, true); modelTo = lookupModelTo(modelFrom, modelTo, params, true);
@ -1602,7 +1787,7 @@ RelationDefinition.embedsMany = function embedsMany(modelFrom, modelTo, params)
var id = item[idName] || '(blank)'; var id = item[idName] || '(blank)';
var first = Object.keys(item.errors)[0]; var first = Object.keys(item.errors)[0];
var msg = 'contains invalid item: `' + id + '`'; var msg = 'contains invalid item: `' + id + '`';
msg += ' (' + first + ' ' + item.errors[first] + ')'; msg += ' (`' + first + '` ' + item.errors[first] + ')';
self.errors.add(propertyName, msg, 'invalid'); self.errors.add(propertyName, msg, 'invalid');
} }
} else { } else {
@ -1716,15 +1901,11 @@ EmbedsMany.prototype.related = function(receiver, scopeParams, condOrRefresh, cb
var modelInstance = this.modelInstance; var modelInstance = this.modelInstance;
var actualCond = {}; var actualCond = {};
var actualRefresh = false;
if (arguments.length === 3) { if (arguments.length === 3) {
cb = condOrRefresh; cb = condOrRefresh;
} else if (arguments.length === 4) { } else if (arguments.length === 4) {
if (typeof condOrRefresh === 'boolean') { if (typeof condOrRefresh === 'object') {
actualRefresh = condOrRefresh;
} else {
actualCond = condOrRefresh; actualCond = condOrRefresh;
actualRefresh = true;
} }
} else { } else {
throw new Error('Method can be only called with one or two arguments'); throw new Error('Method can be only called with one or two arguments');
@ -1923,7 +2104,7 @@ EmbedsMany.prototype.build = function(targetModelData) {
} }
} }
this.definition.applyProperties(this.modelInstance, targetModelData); this.definition.applyProperties(modelInstance, targetModelData);
var inst = new modelTo(targetModelData); var inst = new modelTo(targetModelData);

View File

@ -158,14 +158,18 @@ RelationMixin.hasAndBelongsToMany = function hasAndBelongsToMany(modelTo, params
return RelationDefinition.hasAndBelongsToMany(this, modelTo, params); return RelationDefinition.hasAndBelongsToMany(this, modelTo, params);
}; };
RelationMixin.hasOne = function hasMany(modelTo, params) { RelationMixin.hasOne = function hasOne(modelTo, params) {
return RelationDefinition.hasOne(this, modelTo, params); return RelationDefinition.hasOne(this, modelTo, params);
}; };
RelationMixin.referencesMany = function hasMany(modelTo, params) { RelationMixin.referencesMany = function referencesMany(modelTo, params) {
return RelationDefinition.referencesMany(this, modelTo, params); return RelationDefinition.referencesMany(this, modelTo, params);
}; };
RelationMixin.embedsMany = function hasMany(modelTo, params) { RelationMixin.embedsOne = function embedsOne(modelTo, params) {
return RelationDefinition.embedsOne(this, modelTo, params);
};
RelationMixin.embedsMany = function embedsMany(modelTo, params) {
return RelationDefinition.embedsMany(this, modelTo, params); return RelationDefinition.embedsMany(this, modelTo, params);
}; };

View File

@ -1320,6 +1320,131 @@ describe('relations', function () {
}); });
}); });
describe('embedsOne', function () {
var person;
var Other;
before(function () {
db = getSchema();
Person = db.define('Person', {name: String});
Passport = db.define('Passport',
{name:{type:'string', required: true}},
{idInjection: false}
);
Other = db.define('Other', {name: String});
});
it('can be declared using embedsOne method', function () {
Person.embedsOne(Passport, {
default: {name: 'Anonymous'}, // a bit contrived
options: {validate: true}
});
});
it('should have setup a property and accessor', function() {
var p = new Person();
p.passport.should.be.an.object; // because of default
p.passportItem.should.be.a.function;
p.passportItem.create.should.be.a.function;
p.passportItem.build.should.be.a.function;
p.passportItem.destroy.should.be.a.function;
});
it('should return an instance with default values', function() {
var p = new Person();
p.passport.toObject().should.eql({name: 'Anonymous'});
p.passportItem().should.equal(p.passport);
p.passportItem(function(err, passport) {
should.not.exist(err);
passport.should.equal(p.passport);
});
});
it('should embed a model instance', function() {
var p = new Person();
p.passportItem(new Passport({name: 'Fred'}));
p.passport.toObject().should.eql({name: 'Fred'});
p.passport.should.be.an.instanceOf(Passport);
});
it('should not embed an invalid model type', function() {
var p = new Person();
p.passportItem(new Other());
p.passport.toObject().should.eql({name: 'Anonymous'});
p.passport.should.be.an.instanceOf(Passport);
});
it('should create an embedded item on scope', function(done) {
Person.create({name: 'Fred'}, function(err, p) {
should.not.exist(err);
p.passportItem.create({name: 'Fredric'}, function(err, passport) {
should.not.exist(err);
p.passport.toObject().should.eql({name: 'Fredric'});
p.passport.should.be.an.instanceOf(Passport);
done();
});
});
});
it('should get an embedded item on scope', function(done) {
Person.findOne(function(err, p) {
should.not.exist(err);
var passport = p.passportItem();
passport.toObject().should.eql({name: 'Fredric'});
passport.should.be.an.instanceOf(Passport);
passport.should.equal(p.passport);
done();
});
});
it('should validate an embedded item on scope - on creation', function(done) {
var p = new Person({name: 'Fred'});
p.passportItem.create({}, function(err, passport) {
should.exist(err);
err.name.should.equal('ValidationError');
var msg = 'The `Passport` instance is not valid.';
msg += ' Details: `name` can\'t be blank.';
err.message.should.equal(msg);
done();
});
});
it('should validate an embedded item on scope - on update', function(done) {
Person.findOne(function(err, p) {
var passport = p.passportItem();
passport.name = null;
p.save(function(err) {
should.exist(err);
err.name.should.equal('ValidationError');
var msg = 'The `Person` instance is not valid.';
msg += ' Details: `passportItem` is invalid: `name` can\'t be blank.';
err.message.should.equal(msg);
done();
});
});
});
it('should destroy an embedded item on scope', function(done) {
Person.findOne(function(err, p) {
p.passportItem.destroy(function(err) {
should.not.exist(err);
should.equal(p.passport, null);
done();
});
});
});
it('should get an embedded item on scope - verify', function(done) {
Person.findOne(function(err, p) {
should.not.exist(err);
should.equal(p.passport, null);
done();
});
});
});
describe('embedsMany', function () { describe('embedsMany', function () {
var address1, address2; var address1, address2;
@ -1591,7 +1716,7 @@ describe('relations', function () {
Person.create({ name: 'Wilma', addresses: addresses }, function(err, p) { Person.create({ name: 'Wilma', addresses: addresses }, function(err, p) {
err.name.should.equal('ValidationError'); err.name.should.equal('ValidationError');
var expected = 'The `Person` instance is not valid. '; var expected = 'The `Person` instance is not valid. ';
expected += 'Details: `addresses` contains invalid item: `work` (street can\'t be blank).'; expected += 'Details: `addresses` contains invalid item: `work` (`street` can\'t be blank).';
err.message.should.equal(expected); err.message.should.equal(expected);
done(); done();
}); });