Merge pull request #197 from fabien/feature/references

embedsMany (subdocs) and referencesMany (array of ids)
This commit is contained in:
Raymond Feng 2014-07-30 09:18:03 -07:00
commit 56a7df4c63
8 changed files with 1900 additions and 19 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

@ -14,6 +14,7 @@ var Relation = require('./relations.js');
var Inclusion = require('./include.js'); var Inclusion = require('./include.js');
var List = require('./list.js'); var List = require('./list.js');
var geo = require('./geo'); var geo = require('./geo');
var mergeQuery = require('./scope.js').mergeQuery;
var Memory = require('./connectors/memory').Memory; var Memory = require('./connectors/memory').Memory;
var utils = require('./utils'); var utils = require('./utils');
var fieldsToArray = utils.fieldsToArray; var fieldsToArray = utils.fieldsToArray;
@ -324,6 +325,47 @@ DataAccessObject.findById = function find(id, cb) {
}.bind(this)); }.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) { function convertNullToNotFoundError(ctx, cb) {
if (ctx.result !== null) return cb(); if (ctx.result !== null) return cb();
@ -1046,6 +1088,17 @@ DataAccessObject.prototype.remove =
}.bind(this)); }.bind(this));
}, null, cb); }, 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. * Update a single attribute.
@ -1062,7 +1115,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. * Performs validation before updating.
* *
* @trigger `validation`, `save` and `update` hooks * @trigger `validation`, `save` and `update` hooks
@ -1086,9 +1159,7 @@ DataAccessObject.prototype.updateAttributes = function updateAttributes(data, cb
} }
// update instance's properties // update instance's properties
for (var key in data) { inst.setAttributes(data);
inst[key] = data[key];
}
inst.isValid(function (valid) { inst.isValid(function (valid) {
if (!valid) { if (!valid) {

View File

@ -94,7 +94,7 @@ Inclusion.include = function (objects, include, cb) {
subInclude = null; subInclude = null;
} }
var relation = relations[relationName]; var relation = relations[relationName];
if (!relation) { if (!relation) {
cb(new Error('Relation "' + relationName + '" is not defined for ' cb(new Error('Relation "' + relationName + '" is not defined for '
+ self.modelName + ' model')); + self.modelName + ' model'));
@ -106,7 +106,7 @@ Inclusion.include = function (objects, include, cb) {
cb(); cb();
return; return;
} }
// Calling the relation method for each object // Calling the relation method for each object
async.each(objs, function (obj, callback) { async.each(objs, function (obj, callback) {
if(relation.type === 'belongsTo') { if(relation.type === 'belongsTo') {
@ -133,11 +133,11 @@ Inclusion.include = function (objects, include, cb) {
obj.__cachedRelations[relationName] = result; obj.__cachedRelations[relationName] = result;
if(obj === inst) { if(obj === inst) {
obj.__data[relationName] = result; obj.__data[relationName] = result;
obj.strict = false; obj.setStrict(false);
} else { } else {
obj[relationName] = result; obj[relationName] = result;
} }
if (subInclude && result) { if (subInclude && result) {
var subItems = relation.multiple ? result : [result]; var subItems = relation.multiple ? result : [result];
// Recursively include the related models // Recursively include the related models

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,9 @@ var RelationTypes = {
belongsTo: 'belongsTo', belongsTo: 'belongsTo',
hasMany: 'hasMany', hasMany: 'hasMany',
hasOne: 'hasOne', hasOne: 'hasOne',
hasAndBelongsToMany: 'hasAndBelongsToMany' hasAndBelongsToMany: 'hasAndBelongsToMany',
referencesMany: 'referencesMany',
embedsMany: 'embedsMany'
}; };
exports.RelationTypes = RelationTypes; exports.RelationTypes = RelationTypes;
@ -24,13 +28,17 @@ exports.HasManyThrough = HasManyThrough;
exports.HasOne = HasOne; exports.HasOne = HasOne;
exports.HasAndBelongsToMany = HasAndBelongsToMany; exports.HasAndBelongsToMany = HasAndBelongsToMany;
exports.BelongsTo = BelongsTo; exports.BelongsTo = BelongsTo;
exports.ReferencesMany = ReferencesMany;
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,
referencesMany: ReferencesMany,
embedsMany: EmbedsMany
}; };
function normalizeType(type) { function normalizeType(type) {
@ -44,7 +52,35 @@ function normalizeType(type) {
} }
} }
return null; return null;
} };
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') {
customMethods = ext.call(definition, scopeMethods, relationClass);
} else if (typeof ext === 'object') {
for (var key in ext) {
var relationMethod = ext[key];
var method = scopeMethods[key] = function () {
var relation = new relationClass(definition, this);
return relationMethod.apply(relation, arguments);
};
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);
}
}
return [].concat(customMethods || []);
};
/** /**
* Relation definition class. Use to define relationships between models. * Relation definition class. Use to define relationships between models.
@ -57,6 +93,7 @@ function RelationDefinition(definition) {
} }
definition = definition || {}; definition = definition || {};
this.name = definition.name; this.name = definition.name;
this.accessor = definition.accessor || this.name;
assert(this.name, 'Relation name is missing'); assert(this.name, 'Relation name is missing');
this.type = normalizeType(definition.type); this.type = normalizeType(definition.type);
assert(this.type, 'Invalid relation type: ' + definition.type); assert(this.type, 'Invalid relation type: ' + definition.type);
@ -75,6 +112,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 () {
@ -100,6 +138,7 @@ RelationDefinition.prototype.toJSON = function () {
* @param {Object} filter (where, order, limit, fields, ...) * @param {Object} filter (where, order, limit, fields, ...)
*/ */
RelationDefinition.prototype.applyScope = function(modelInstance, filter) { RelationDefinition.prototype.applyScope = function(modelInstance, filter) {
filter = filter || {};
filter.where = filter.where || {}; filter.where = filter.where || {};
if ((this.type !== 'belongsTo' || this.type === 'hasOne') if ((this.type !== 'belongsTo' || this.type === 'hasOne')
&& typeof this.discriminator === 'string') { // polymorphic && typeof this.discriminator === 'string') { // polymorphic
@ -290,6 +329,41 @@ 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);
/**
* ReferencesMany subclass
* @param {RelationDefinition|Object} definition
* @param {Object} modelInstance
* @returns {ReferencesMany}
* @constructor
* @class ReferencesMany
*/
function ReferencesMany(definition, modelInstance) {
if (!(this instanceof ReferencesMany)) {
return new ReferencesMany(definition, modelInstance);
}
assert(definition.type === RelationTypes.referencesMany);
Relation.apply(this, arguments);
}
util.inherits(ReferencesMany, Relation);
/*! /*!
* Find the relation by foreign key * Find the relation by foreign key
@ -455,6 +529,16 @@ RelationDefinition.hasMany = function hasMany(modelFrom, modelTo, params) {
scopeMethods.build = scopeMethod(definition, 'build'); 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 // Mix the property and scoped methods into the prototype class
defineScope(modelFrom.prototype, params.through || modelTo, relationName, function () { defineScope(modelFrom.prototype, params.through || modelTo, relationName, function () {
var filter = {}; var filter = {};
@ -505,6 +589,7 @@ function scopeMethod(definition, methodName) {
*/ */
HasMany.prototype.findById = function (fkId, cb) { HasMany.prototype.findById = function (fkId, cb) {
var modelTo = this.definition.modelTo; var modelTo = this.definition.modelTo;
var modelFrom = this.definition.modelFrom;
var fk = this.definition.keyTo; var fk = this.definition.keyTo;
var pk = this.definition.keyFrom; var pk = this.definition.keyFrom;
var modelInstance = this.modelInstance; var modelInstance = this.modelInstance;
@ -534,7 +619,7 @@ HasMany.prototype.findById = function (fkId, cb) {
if (inst[fk] && inst[fk].toString() === modelInstance[pk].toString()) { if (inst[fk] && inst[fk].toString() === modelInstance[pk].toString()) {
cb(null, inst); cb(null, inst);
} else { } else {
err = new Error('Key mismatch: ' + this.definition.modelFrom.modelName + '.' + pk err = new Error('Key mismatch: ' + modelFrom.modelName + '.' + pk
+ ': ' + modelInstance[pk] + ': ' + modelInstance[pk]
+ ', ' + modelTo.modelName + '.' + fk + ': ' + inst[fk]); + ', ' + modelTo.modelName + '.' + fk + ': ' + inst[fk]);
err.statusCode = 400; err.statusCode = 400;
@ -1013,6 +1098,11 @@ BelongsTo.prototype.related = function (refresh, params) {
modelTo = params.constructor; modelTo = params.constructor;
modelInstance[fk] = params[pk]; modelInstance[fk] = params[pk];
if (discriminator) modelInstance[discriminator] = params.constructor.modelName; if (discriminator) modelInstance[discriminator] = params.constructor.modelName;
var data = {};
this.definition.applyProperties(params, data);
modelInstance.setAttributes(data);
self.resetCache(params); self.resetCache(params);
} else if (typeof params === 'function') { // acts as async getter } else if (typeof params === 'function') { // acts as async getter
@ -1375,3 +1465,789 @@ HasOne.prototype.related = function (refresh, params) {
self.resetCache(); self.resetCache();
} }
}; };
RelationDefinition.embedsMany = function embedsMany(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 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 = modelFrom.relations[accessorName] = new RelationDefinition({
accessor: accessorName,
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);
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) {
modelFrom.validate(relationName, function(err) {
var self = this;
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] + ')';
self.errors.add(relationName, msg, 'invalid');
}
} else {
hasErrors = true;
self.errors.add(relationName, 'Contains invalid item', 'invalid');
}
});
if (hasErrors) err(false);
});
}
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'),
get: scopeMethod(definition, 'get'),
set: scopeMethod(definition, 'set'),
unset: scopeMethod(definition, 'unset'),
at: scopeMethod(definition, 'at')
};
var findByIdFunc = scopeMethods.findById;
modelFrom.prototype['__findById__' + accessorName] = findByIdFunc;
var destroyByIdFunc = scopeMethods.destroy;
modelFrom.prototype['__destroyById__' + accessorName] = destroyByIdFunc;
var updateByIdFunc = scopeMethods.updateById;
modelFrom.prototype['__updateById__' + accessorName] = updateByIdFunc;
var addFunc = scopeMethods.add;
modelFrom.prototype['__link__' + accessorName] = addFunc;
var removeFunc = scopeMethods.remove;
modelFrom.prototype['__unlink__' + accessorName] = removeFunc;
scopeMethods.create = scopeMethod(definition, 'create');
scopeMethods.build = scopeMethod(definition, 'build');
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 {};
}, scopeMethods);
scopeDefinition.related = scopeMethods.related;
};
EmbedsMany.prototype.related = function(receiver, scopeParams, condOrRefresh, cb) {
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 embeddedList = self[relationName] || [];
this.definition.applyScope(modelInstance, actualCond);
var params = mergeQuery(actualCond, scopeParams);
if (params.where) {
embeddedList = embeddedList ? embeddedList.filter(applyFilter(params)) : embeddedList;
}
var returnRelated = function(list) {
if (params.include) {
modelTo.include(list, params.include, cb);
} else {
process.nextTick(function() { cb(null, list); });
}
};
returnRelated(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] || [];
var find = function(id) {
for (var i = 0; i < embeddedList.length; i++) {
var item = embeddedList[i];
if (item[pk].toString() === id) return item;
}
return null;
};
var item = find(fkId.toString()); // in case of explicit id
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 = (fkId instanceof modelTo) ? fkId : 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);
});
}
} else if (typeof cb === 'function') {
process.nextTick(cb); // not found
}
return inst; // sync
};
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;
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;
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) {
return process.nextTick(function() {
cb(err);
});
}
modelInstance.updateAttribute(relationName,
embeddedList, function(err, modelInst) {
cb(err, err ? null : inst);
});
};
EmbedsMany.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] || [];
targetModelData = targetModelData || {};
if (typeof targetModelData[pk] !== 'number' && autoId) {
var ids = embeddedList.map(function(m) {
return (typeof m[pk] === 'number' ? m[pk] : 0);
});
if (ids.length > 0) {
targetModelData[pk] = Math.max.apply(null, ids) + 1;
} else {
targetModelData[pk] = 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;
};
/**
* Add the target model instance to the 'embedsMany' relation
* @param {Object|ID} acInst The actual instance or id value
*/
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;
var modelInstance = this.modelInstance;
var options = definition.options;
var belongsTo = options.belongsTo && modelTo.relations[options.belongsTo];
if (!belongsTo) {
throw new Error('Invalid reference: ' + options.belongsTo || '(none)');
}
var fk2 = belongsTo.keyTo;
var pk2 = belongsTo.modelTo.definition.idName() || 'id';
var query = {};
query[fk2] = (acInst instanceof belongsTo.modelTo) ? acInst[pk2] : acInst;
var filter = { where: query };
belongsTo.applyScope(modelInstance, filter);
belongsTo.modelTo.findOne(filter, function(err, ref) {
if (ref instanceof belongsTo.modelTo) {
var inst = self.build(data || {});
inst[options.belongsTo](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 belongsTo = options.belongsTo && modelTo.relations[options.belongsTo];
if (!belongsTo) {
throw new Error('Invalid reference: ' + options.belongsTo || '(none)');
}
var fk2 = belongsTo.keyTo;
var pk2 = belongsTo.modelTo.definition.idName() || 'id';
var query = {};
query[fk2] = (acInst instanceof belongsTo.modelTo) ? acInst[pk2] : acInst;
var filter = { where: query };
belongsTo.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);
});
});
};
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.definition.properties[idName].type;
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;
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');
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 {};
}, scopeMethods);
scopeDefinition.related = scopeMethods.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 insert = function(inst, done) {
var id = inst[pk];
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) {
done(err, err ? null : inst);
});
};
if (acInst instanceof modelTo) {
insert(acInst, 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, null);
insert(inst, 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); });
}
};

View File

@ -161,3 +161,11 @@ 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.referencesMany = function hasMany(modelTo, params) {
RelationDefinition.referencesMany(this, modelTo, params);
};
RelationMixin.embedsMany = function hasMany(modelTo, params) {
RelationDefinition.embedsMany(this, modelTo, params);
};

View File

@ -18,7 +18,7 @@ function ScopeDefinition(definition) {
ScopeDefinition.prototype.related = function(receiver, scopeParams, condOrRefresh, cb) { ScopeDefinition.prototype.related = function(receiver, scopeParams, condOrRefresh, cb) {
var name = this.name; var name = this.name;
var self = receiver; var self = receiver;
var actualCond = {}; var actualCond = {};
var actualRefresh = false; var actualRefresh = false;
var saveOnCache = true; var saveOnCache = true;
@ -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

@ -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 () { describe('find', function () {

View File

@ -4,6 +4,8 @@ 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;
var Link;
describe('relations', function () { describe('relations', function () {
@ -580,7 +582,7 @@ describe('relations', function () {
author.avatar(function (err, p) { author.avatar(function (err, p) {
should.not.exist(err); should.not.exist(err);
p.name.should.equal('Avatar'); p.name.should.equal('Avatar');
p.imageableId.should.equal(author.id); p.imageableId.should.eql(author.id);
p.imageableType.should.equal('Author'); p.imageableType.should.equal('Author');
done(); done();
}); });
@ -592,7 +594,7 @@ describe('relations', function () {
reader.mugshot(function (err, p) { reader.mugshot(function (err, p) {
should.not.exist(err); should.not.exist(err);
p.name.should.equal('Mugshot'); p.name.should.equal('Mugshot');
p.imageableId.should.equal(reader.id); p.imageableId.should.eql(reader.id);
p.imageableType.should.equal('Reader'); p.imageableType.should.equal('Reader');
done(); done();
}); });
@ -654,7 +656,7 @@ describe('relations', function () {
author.pictures.create({ name: 'Author Pic' }, function (err, p) { author.pictures.create({ name: 'Author Pic' }, function (err, p) {
should.not.exist(err); should.not.exist(err);
should.exist(p); should.exist(p);
p.imageableId.should.equal(author.id); p.imageableId.should.eql(author.id);
p.imageableType.should.equal('Author'); p.imageableType.should.equal('Author');
done(); done();
}); });
@ -666,7 +668,7 @@ describe('relations', function () {
reader.pictures.create({ name: 'Reader Pic' }, function (err, p) { reader.pictures.create({ name: 'Reader Pic' }, function (err, p) {
should.not.exist(err); should.not.exist(err);
should.exist(p); should.exist(p);
p.imageableId.should.equal(reader.id); p.imageableId.should.eql(reader.id);
p.imageableType.should.equal('Reader'); p.imageableType.should.equal('Reader');
done(); done();
}); });
@ -737,7 +739,7 @@ describe('relations', function () {
Author.create({ name: 'Author 2' }, function(err, author) { Author.create({ name: 'Author 2' }, function(err, author) {
var p = new Picture({ name: 'Sample' }); var p = new Picture({ name: 'Sample' });
p.imageable(author); // assign p.imageable(author); // assign
p.imageableId.should.equal(author.id); p.imageableId.should.eql(author.id);
p.imageableType.should.equal('Author'); p.imageableType.should.equal('Author');
p.save(done); p.save(done);
}); });
@ -1089,7 +1091,7 @@ describe('relations', function () {
it('should find record on scope', function (done) { it('should find record on scope', function (done) {
Passport.findOne(function (err, p) { Passport.findOne(function (err, p) {
p.personId.should.equal(personCreated.id); p.personId.should.eql(personCreated.id);
p.person(function(err, person) { p.person(function(err, person) {
person.name.should.equal('Fred'); person.name.should.equal('Fred');
person.should.not.have.property('age'); person.should.not.have.property('age');
@ -1217,5 +1219,887 @@ describe('relations', function () {
should.equal(Article.prototype.tags._targetClass, 'Tag'); should.equal(Article.prototype.tags._targetClass, 'Tag');
}); });
}); });
describe('embedsMany', function () {
var address1, address2;
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, address) {
should.not.exist(err);
address1 = address;
should.exist(address1.id);
address1.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, address) {
should.not.exist(err);
address2 = address;
should.exist(address2.id);
address2.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.eql(address1.id);
addresses[0].street.should.equal('Street 1');
addresses[1].id.should.eql(address2.id);
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.eql(address2.id);
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, address) {
should.exist(err);
should.not.exist(address);
err.name.should.equal('ValidationError');
err.details.codes.street.should.eql(['presence']);
done();
});
});
});
it('should find embedded items by id', function(done) {
Person.findOne(function(err, p) {
p.addressList.findById(address2.id, function(err, address) {
address.should.be.instanceof(Address);
address.id.should.eql(address2.id);
address.street.should.equal('Street 2');
done();
});
});
});
it('should check if item exists', function(done) {
Person.findOne(function(err, p) {
p.addressList.exists(address2.id, 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(address2.id, { street: 'New Street' }, function(err, address) {
address.should.be.instanceof(Address);
address.id.should.eql(address2.id);
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(address2.id, { 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(address2.id, function(err, address) {
address.should.be.instanceof(Address);
address.id.should.eql(address2.id);
address.street.should.equal('New Street');
done();
});
});
});
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);
p.addressList.destroy(address1.id, 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, address) {
should.not.exist(err);
address.id.should.equal('work');
address.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();
});
});
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();
});
});
});
describe('embedsMany - relations, scope and properties', function () {
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', {name: String});
db.automigrate(function () {
Person.destroyAll(done);
});
});
it('can be declared', function (done) {
Category.embedsMany(Link, {
as: 'items', // rename
scope: { include: 'product' }, // always include
options: { belongsTo: '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(function() {
Product.create({ name: 'Product 0' }, done); // offset ids for tests
});
});
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;
Product.create({ name: 'Product 3' }, function(err, p) {
product3 = p;
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);
var link = cat.items.build();
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);
var product = cat.items.at(1);
product.id.should.eql(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).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);
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.eql(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();
});
});
});
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.eql(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 () {
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, // needs unique auto-id
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) {
// 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();
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].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');
done();
});
});
});
describe('referencesMany', function () {
var product1, product2, product3;
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) {
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
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);
});
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 allow 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, prod) {
should.not.exist(err);
cat.productIds.should.eql([product2.id, product1.id]);
prod.id.should.eql(product1.id);
prod.should.have.property('name');
done();
});
});
});
it('should add a record to scope - object', function (done) {
Category.findOne(function(err, cat) {
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);
prod.id.should.eql(product3.id);
prod.should.have.property('name');
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 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(product3.id);
cat.products[1].id.should.eql(product2.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();
});
});
});
});
}); });