Merge pull request #140 from strongloop/feature/refactor-relation

Feature/refactor relation
This commit is contained in:
Raymond Feng 2014-06-20 20:56:44 -07:00
commit 3f7e85101d
7 changed files with 1059 additions and 487 deletions

View File

@ -13,7 +13,7 @@ function List(items, itemType, parent) {
try {
items = JSON.parse(items);
} catch (e) {
throw new Error('could not create List from JSON string: ', items);
throw new Error(util.format('could not create List from JSON string: %j', items));
}
}
@ -22,7 +22,7 @@ function List(items, itemType, parent) {
items = items || [];
if (!Array.isArray(items)) {
throw new Error('Items must be an array: ' + items);
throw new Error(util.format('Items must be an array: %j', items));
}
if(!itemType) {
@ -92,12 +92,3 @@ List.prototype.toString = function () {
return JSON.stringify(this.toJSON());
};
/*
var strArray = new List(['1', 2], String);
strArray.push(3);
console.log(strArray);
console.log(strArray.length);
console.log(strArray.toJSON());
console.log(strArray.toString());
*/

838
lib/relation-definition.js Normal file
View File

@ -0,0 +1,838 @@
/*!
* Dependencies
*/
var assert = require('assert');
var util = require('util');
var i8n = require('inflection');
var defineScope = require('./scope.js').defineScope;
var ModelBaseClass = require('./model.js');
exports.Relation = Relation;
exports.RelationDefinition = RelationDefinition;
var RelationTypes = {
belongsTo: 'belongsTo',
hasMany: 'hasMany',
hasOne: 'hasOne',
hasAndBelongsToMany: 'hasAndBelongsToMany'
};
exports.RelationTypes = RelationTypes;
exports.HasMany = HasMany;
exports.HasManyThrough = HasManyThrough;
exports.HasOne = HasOne;
exports.HasAndBelongsToMany = HasAndBelongsToMany;
exports.BelongsTo = BelongsTo;
var RelationClasses = {
belongsTo: BelongsTo,
hasMany: HasMany,
hasManyThrough: HasManyThrough,
hasOne: HasOne,
hasAndBelongsToMany: HasAndBelongsToMany
};
function normalizeType(type) {
if (!type) {
return type;
}
var t1 = type.toLowerCase();
for (var t2 in RelationTypes) {
if (t2.toLowerCase() === t1) {
return t2;
}
}
return null;
}
/**
* Relation definition class. Use to define relationships between models.
* @param {Object} definition
* @class RelationDefinition
*/
function RelationDefinition(definition) {
if (!(this instanceof RelationDefinition)) {
return new RelationDefinition(definition);
}
definition = definition || {};
this.name = definition.name;
assert(this.name, 'Relation name is missing');
this.type = normalizeType(definition.type);
assert(this.type, 'Invalid relation type: ' + definition.type);
this.modelFrom = definition.modelFrom;
assert(this.modelFrom);
this.keyFrom = definition.keyFrom;
this.modelTo = definition.modelTo;
assert(this.modelTo);
this.keyTo = definition.keyTo;
this.modelThrough = definition.modelThrough;
this.keyThrough = definition.keyThrough;
this.multiple = (this.type !== 'belongsTo' && this.type !== 'hasOne');
}
RelationDefinition.prototype.toJSON = function () {
var json = {
name: this.name,
type: this.type,
modelFrom: this.modelFrom.modelName,
keyFrom: this.keyFrom,
modelTo: this.modelTo.modelName,
keyTo: this.keyTo,
multiple: this.multiple
};
if (this.modelThrough) {
json.modelThrough = this.modelThrough.modelName;
json.keyThrough = this.keyThrough;
}
return json;
};
/**
* A relation attaching to a given model instance
* @param {RelationDefinition|Object} definition
* @param {Object} modelInstance
* @returns {Relation}
* @constructor
* @class Relation
*/
function Relation(definition, modelInstance) {
if (!(this instanceof Relation)) {
return new Relation(definition, modelInstance);
}
if (!(definition instanceof RelationDefinition)) {
definition = new RelationDefinition(definition);
}
this.definition = definition;
this.modelInstance = modelInstance;
}
/**
* HasMany subclass
* @param {RelationDefinition|Object} definition
* @param {Object} modelInstance
* @returns {HasMany}
* @constructor
* @class HasMany
*/
function HasMany(definition, modelInstance) {
if (!(this instanceof HasMany)) {
return new HasMany(definition, modelInstance);
}
assert(definition.type === RelationTypes.hasMany);
Relation.apply(this, arguments);
}
util.inherits(HasMany, Relation);
/**
* HasManyThrough subclass
* @param {RelationDefinition|Object} definition
* @param {Object} modelInstance
* @returns {HasManyThrough}
* @constructor
* @class HasManyThrough
*/
function HasManyThrough(definition, modelInstance) {
if (!(this instanceof HasManyThrough)) {
return new HasManyThrough(definition, modelInstance);
}
assert(definition.type === RelationTypes.hasMany);
assert(definition.modelThrough);
HasMany.apply(this, arguments);
}
util.inherits(HasManyThrough, HasMany);
/**
* BelongsTo subclass
* @param {RelationDefinition|Object} definition
* @param {Object} modelInstance
* @returns {BelongsTo}
* @constructor
* @class BelongsTo
*/
function BelongsTo(definition, modelInstance) {
if (!(this instanceof BelongsTo)) {
return new BelongsTo(definition, modelInstance);
}
assert(definition.type === RelationTypes.belongsTo);
Relation.apply(this, arguments);
}
util.inherits(BelongsTo, Relation);
/**
* HasAndBelongsToMany subclass
* @param {RelationDefinition|Object} definition
* @param {Object} modelInstance
* @returns {HasAndBelongsToMany}
* @constructor
* @class HasAndBelongsToMany
*/
function HasAndBelongsToMany(definition, modelInstance) {
if (!(this instanceof HasAndBelongsToMany)) {
return new HasAndBelongsToMany(definition, modelInstance);
}
assert(definition.type === RelationTypes.hasAndBelongsToMany);
Relation.apply(this, arguments);
}
util.inherits(HasAndBelongsToMany, Relation);
/**
* HasOne subclass
* @param {RelationDefinition|Object} definition
* @param {Object} modelInstance
* @returns {HasOne}
* @constructor
* @class HasOne
*/
function HasOne(definition, modelInstance) {
if (!(this instanceof HasOne)) {
return new HasOne(definition, modelInstance);
}
assert(definition.type === RelationTypes.hasOne);
Relation.apply(this, arguments);
}
util.inherits(HasOne, Relation);
/*!
* Find the relation by foreign key
* @param {*} foreignKey The foreign key
* @returns {Object} The relation object
*/
function findBelongsTo(modelFrom, modelTo, keyTo) {
var relations = modelFrom.relations;
var keys = Object.keys(relations);
for (var k = 0; k < keys.length; k++) {
var rel = relations[keys[k]];
if (rel.type === RelationTypes.belongsTo &&
rel.modelTo === modelTo &&
rel.keyTo === keyTo) {
return rel.keyFrom;
}
}
return null;
}
/*!
* Look up a model by name from the list of given models
* @param {Object} models Models keyed by name
* @param {String} modelName The model name
* @returns {*} The matching model class
*/
function lookupModel(models, modelName) {
if(models[modelName]) {
return models[modelName];
}
var lookupClassName = modelName.toLowerCase();
for (var name in models) {
if (name.toLowerCase() === lookupClassName) {
return models[name];
}
}
}
/**
* Define a "one to many" relationship by specifying the model name
*
* Examples:
* ```
* User.hasMany(Post, {as: 'posts', foreignKey: 'authorId'});
* ```
*
* ```
* Book.hasMany(Chapter);
* ```
* Or, equivalently:
* ```
* Book.hasMany('chapters', {model: Chapter});
* ```
* @param {Model} modelFrom Source model class
* @param {Object|String} modelTo Model object (or String name of model) to which you are creating the relationship.
* @options {Object} params Configuration parameters; see below.
* @property {String} as Name of the property in the referring model that corresponds to the foreign key field in the related model.
* @property {String} foreignKey Property name of foreign key field.
* @property {Object} model Model object
*/
RelationDefinition.hasMany = 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);
}
}
var relationName = params.as || i8n.camelize(modelTo.pluralModelName, true);
var fk = params.foreignKey || i8n.camelize(thisClassName + '_id', true);
var idName = modelFrom.dataSource.idName(modelFrom.modelName) || 'id';
var definition = new RelationDefinition({
name: relationName,
type: RelationTypes.hasMany,
modelFrom: modelFrom,
keyFrom: idName,
keyTo: fk,
modelTo: modelTo,
multiple: true
});
if (params.through) {
definition.modelThrough = params.through;
var keyThrough = definition.throughKey || i8n.camelize(modelTo.modelName + '_id', true);
definition.keyThrough = keyThrough;
}
modelFrom.relations[relationName] = definition;
if (!params.through) {
// obviously, modelTo should have attribute called `fk`
modelTo.dataSource.defineForeignKey(modelTo.modelName, fk, this.modelName);
}
var scopeMethods = {
findById: scopeMethod(definition, 'findById'),
destroy: scopeMethod(definition, 'destroyById')
}
if(definition.modelThrough) {
scopeMethods.create = scopeMethod(definition, 'create');
scopeMethods.add = scopeMethod(definition, 'add');
scopeMethods.remove = scopeMethod(definition, 'remove');
}
// Mix the property and scoped methods into the prototype class
defineScope(modelFrom.prototype, params.through || modelTo, relationName, function () {
var filter = {};
filter.where = {};
filter.where[fk] = this[idName];
if (params.through) {
filter.collect = i8n.camelize(modelTo.modelName, true);
filter.include = filter.collect;
}
return filter;
}, scopeMethods);
};
function scopeMethod(definition, methodName) {
var method = function () {
var relationClass = RelationClasses[definition.type];
if (definition.type === RelationTypes.hasMany && definition.modelThrough) {
relationClass = RelationClasses.hasManyThrough;
}
var relation = new relationClass(definition, this);
return relation[methodName].apply(relation, arguments);
};
return method;
}
HasMany.prototype.findById = function (id, cb) {
var modelTo = this.definition.modelTo;
var fk = this.definition.keyTo;
var pk = this.definition.keyFrom;
var modelInstance = this.modelInstance;
modelTo.findById(id, function (err, inst) {
if (err) {
return cb(err);
}
if (!inst) {
return cb(new Error('Not found'));
}
// Check if the foreign key matches the primary key
if (inst[fk] && inst[fk].toString() === modelInstance[pk].toString()) {
cb(null, inst);
} else {
cb(new Error('Permission denied: foreign key does not match the primary key'));
}
});
};
HasMany.prototype.destroyById = function (id, cb) {
var modelTo = this.definition.modelTo;
var fk = this.definition.keyTo;
var pk = this.definition.keyFrom;
var modelInstance = this.modelInstance;
modelTo.findById(id, function (err, inst) {
if (err) {
return cb(err);
}
if (!inst) {
return cb(new Error('Not found'));
}
// Check if the foreign key matches the primary key
if (inst[fk] && inst[fk].toString() === modelInstance[pk].toString()) {
inst.destroy(cb);
} else {
cb(new Error('Permission denied: foreign key does not match the primary key'));
}
});
};
// Create an instance of the target model and connect it to the instance of
// the source model by creating an instance of the through model
HasManyThrough.prototype.create = function create(data, done) {
var definition = this.definition;
var modelTo = definition.modelTo;
var modelThrough = definition.modelThrough;
if (typeof data === 'function' && !done) {
done = data;
data = {};
}
var modelInstance = this.modelInstance;
// First create the target model
modelTo.create(data, function (err, ac) {
if (err) {
return done && done(err, ac);
}
// The primary key for the target model
var pk2 = modelTo.dataSource.idName(modelTo.modelName) || 'id';
var fk1 = findBelongsTo(modelThrough, definition.modelFrom,
definition.keyFrom);
var fk2 = findBelongsTo(modelThrough, definition.modelTo, pk2);
var d = {};
d[fk1] = modelInstance[definition.keyFrom];
d[fk2] = ac[pk2];
// Then create the through model
modelThrough.create(d, function (e) {
if (e) {
// Undo creation of the target model
ac.destroy(function () {
done && done(e);
});
} else {
done && done(err, ac);
}
});
});
};
/**
* Add the target model instance to the 'hasMany' relation
* @param {Object|ID} acInst The actual instance or id value
*/
HasManyThrough.prototype.add = function (acInst, done) {
var definition = this.definition;
var modelThrough = definition.modelThrough;
var modelTo = definition.modelTo;
var pk1 = definition.keyFrom;
var data = {};
var query = {};
var fk1 = findBelongsTo(modelThrough, definition.modelFrom,
definition.keyFrom);
// The primary key for the target model
var pk2 = modelTo.dataSource.idName(modelTo.modelName) || 'id';
var fk2 = findBelongsTo(modelThrough, definition.modelTo, pk2);
query[fk1] = this.modelInstance[pk1];
query[fk2] = acInst[pk2] || acInst;
data[fk1] = this.modelInstance[pk1];
data[fk2] = acInst[pk2] || acInst;
// Create an instance of the through model
modelThrough.findOrCreate({where: query}, data, done);
};
/**
* Remove the target model instance from the 'hasMany' relation
* @param {Object|ID) acInst The actual instance or id value
*/
HasManyThrough.prototype.remove = function (acInst, done) {
var modelThrough = this.definition.modelThrough;
var fk2 = this.definition.keyThrough;
var pk = this.definition.keyFrom;
var q = {};
q[fk2] = acInst[pk] || acInst;
modelThrough.deleteAll(q, done );
};
/**
* Declare "belongsTo" relation that sets up a one-to-one connection with
* another model, such that each instance of the declaring model "belongs to"
* one instance of the other model.
*
* For example, if an application includes users and posts, and each post can
* be written by exactly one user. The following code specifies that `Post` has
* a reference called `author` to the `User` model via the `userId` property of
* `Post` as the foreign key.
* ```
* Post.belongsTo(User, {as: 'author', foreignKey: 'userId'});
* ```
*
* This optional parameter default value is false, so the related object will
* be loaded from cache if available.
*
* @param {Class|String} modelTo Model object (or String name of model) to
* which you are creating the relationship.
* @options {Object} params Configuration parameters; see below.
* @property {String} as Name of the property in the referring model that
* corresponds to the foreign key field in the related model.
* @property {String} foreignKey Name of foreign key property.
*
*/
RelationDefinition.belongsTo = function (modelFrom, modelTo, params) {
params = params || {};
if ('string' === typeof modelTo) {
params.as = modelTo;
if (params.model) {
modelTo = params.model;
} else {
var modelToName = modelTo.toLowerCase();
modelTo = lookupModel(modelFrom.dataSource.modelBuilder.models, modelToName);
}
}
var idName = modelFrom.dataSource.idName(modelTo.modelName) || 'id';
var relationName = params.as || i8n.camelize(modelTo.modelName, true);
var fk = params.foreignKey || relationName + 'Id';
var relationDef = modelFrom.relations[relationName] = new RelationDefinition({
name: relationName,
type: RelationTypes.belongsTo,
modelFrom: modelFrom,
keyFrom: fk,
keyTo: idName,
modelTo: modelTo
});
modelFrom.dataSource.defineForeignKey(modelFrom.modelName, fk, modelTo.modelName);
// 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 BelongsTo(relationDef, this);
var relationMethod = relation.related.bind(relation);
relationMethod.create = relation.create.bind(relation);
relationMethod.build = relation.build.bind(relation);
relationMethod._targetClass = relationDef.modelTo.modelName;
return relationMethod;
}
});
// Wrap the property into a function for remoting
// so that it can be accessed as /api/<model>/<id>/<belongsToRelationName>
// For example, /api/orders/1/customer
var fn = function() {
var f = this[relationName];
f.apply(this, arguments);
};
fn.shared = true;
fn.http = {verb: 'get', path: '/' + relationName};
fn.accepts = {arg: 'refresh', type: 'boolean', http: {source: 'query'}};
fn.description = 'Fetches belongsTo relation ' + relationName;
fn.returns = {arg: relationName, type: 'object', root: true};
modelFrom.prototype['__get__' + relationName] = fn;
};
BelongsTo.prototype.create = function(targetModelData, cb) {
var modelTo = this.definition.modelTo;
var fk = this.definition.keyTo;
var pk = this.definition.keyFrom;
var modelInstance = this.modelInstance;
modelTo.create(targetModelData, function(err, targetModel) {
if(!err) {
modelInstance[fk] = targetModel[pk];
cb && cb(err, targetModel);
} else {
cb && cb(err);
}
});
};
BelongsTo.prototype.build = function(targetModelData) {
var modelTo = this.definition.modelTo;
return new modelTo(targetModelData);
};
/**
* Define the method for the belongsTo relation itself
* It will support one of the following styles:
* - order.customer(refresh, callback): Load the target model instance asynchronously
* - order.customer(customer): Synchronous setter of the target model instance
* - order.customer(): Synchronous getter of the target model instance
*
* @param refresh
* @param params
* @returns {*}
*/
BelongsTo.prototype.related = function (refresh, params) {
var modelTo = this.definition.modelTo;
var pk = this.definition.keyTo;
var fk = this.definition.keyFrom;
var modelInstance = this.modelInstance;
var relationName = this.definition.name;
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');
}
var cachedValue;
if (!refresh && modelInstance.__cachedRelations
&& (modelInstance.__cachedRelations[relationName] !== undefined)) {
cachedValue = modelInstance.__cachedRelations[relationName];
}
if (params instanceof ModelBaseClass) { // acts as setter
modelInstance[fk] = params[pk];
modelInstance.__cachedRelations[relationName] = params;
} else if (typeof params === 'function') { // acts as async getter
var cb = params;
if (cachedValue === undefined) {
modelTo.findById(modelInstance[fk], function (err, inst) {
if (err) {
return cb(err);
}
if (!inst) {
return cb(null, null);
}
// Check if the foreign key matches the primary key
if (inst[pk] === modelInstance[fk]) {
cb(null, inst);
} else {
cb(new Error('Permission denied'));
}
});
return modelInstance[fk];
} else {
cb(null, cachedValue);
return cachedValue;
}
} else if (params === undefined) { // acts as sync getter
return modelInstance[fk];
} else { // setter
modelInstance[fk] = params;
delete modelInstance.__cachedRelations[relationName];
}
};
/**
* A hasAndBelongsToMany relation creates a direct many-to-many connection with
* another model, with no intervening model. For example, if your application
* includes users and groups, with each group having many users and each user
* appearing in many groups, you could declare the models this way:
* ```
* User.hasAndBelongsToMany('groups', {model: Group, foreignKey: 'groupId'});
* ```
*
* @param {String|Object} modelTo Model object (or String name of model) to
* which you are creating the relationship.
* @options {Object} params Configuration parameters; see below.
* @property {String} as Name of the property in the referring model that
* corresponds to the foreign key field in the related model.
* @property {String} foreignKey Property name of foreign key field.
* @property {Object} model Model object
*/
RelationDefinition.hasAndBelongsToMany = function hasAndBelongsToMany(modelFrom, modelTo, params) {
params = params || {};
var models = modelFrom.dataSource.modelBuilder.models;
if ('string' === typeof modelTo) {
params.as = modelTo;
if (params.model) {
modelTo = params.model;
} else {
modelTo = lookupModel(models, i8n.singularize(modelTo)) ||
modelTo;
}
if (typeof modelTo === 'string') {
throw new Error('Could not find "' + modelTo + '" relation for ' + modelFrom.modelName);
}
}
if (!params.through) {
var name1 = modelFrom.modelName + modelTo.modelName;
var name2 = modelTo.modelName + modelFrom.modelName;
params.through = lookupModel(models, name1) || lookupModel(models, name2) ||
modelFrom.dataSource.define(name1);
}
params.through.belongsTo(modelFrom);
params.through.belongsTo(modelTo);
this.hasMany(modelFrom, modelTo, {as: params.as, through: params.through});
};
/**
* A HasOne relation creates a one-to-one connection from modelFrom to modelTo.
* This relation indicates that each instance of a model contains or possesses
* one instance of another model. For example, each supplier in your application
* has only one account.
*
* @param {Function} modelFrom The declaring model class
* @param {String|Function} modelTo Model object (or String name of model) to
* which you are creating the relationship.
* @options {Object} params Configuration parameters; see below.
* @property {String} as Name of the property in the referring model that
* corresponds to the foreign key field in the related model.
* @property {String} foreignKey Property name of foreign key field.
* @property {Object} model Model object
*/
RelationDefinition.hasOne = function (modelFrom, modelTo, params) {
params = params || {};
if ('string' === typeof modelTo) {
params.as = modelTo;
if (params.model) {
modelTo = params.model;
} else {
var modelToName = modelTo.toLowerCase();
modelTo = lookupModel(modelFrom.dataSource.modelBuilder.models, modelToName);
}
}
var pk = modelFrom.dataSource.idName(modelTo.modelName) || 'id';
var relationName = params.as || i8n.camelize(modelTo.modelName, true);
var fk = params.foreignKey || i8n.camelize(modelFrom.modelName + '_id', true);
var relationDef = modelFrom.relations[relationName] = new RelationDefinition({
name: relationName,
type: RelationTypes.hasOne,
modelFrom: modelFrom,
keyFrom: pk,
keyTo: fk,
modelTo: modelTo
});
modelFrom.dataSource.defineForeignKey(modelTo.modelName, fk, modelFrom.modelName);
// 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 HasOne(relationDef, this);
var relationMethod = relation.related.bind(relation)
relationMethod.create = relation.create.bind(relation);
relationMethod.build = relation.build.bind(relation);
return relationMethod;
}
});
};
/**
* Create a target model instance
* @param {Object} targetModelData The target model data
* @callback {Function} [cb] Callback function
* @param {String|Object} err Error string or object
* @param {Object} The newly created target model instance
*/
HasOne.prototype.create = function(targetModelData, cb) {
var modelTo = this.definition.modelTo;
var fk = this.definition.keyTo;
var pk = this.definition.keyFrom;
var modelInstance = this.modelInstance;
targetModelData = targetModelData || {};
targetModelData[fk] = modelInstance[pk];
modelTo.create(targetModelData, function(err, targetModel) {
if(!err) {
cb && cb(err, targetModel);
} else {
cb && cb(err);
}
});
};
/**
* Build a target model instance
* @param {Object} targetModelData The target model data
* @returns {Object} The newly built target model instance
*/
HasOne.prototype.build = function(targetModelData) {
var modelTo = this.definition.modelTo;
var pk = this.definition.keyFrom;
var fk = this.definition.keyTo;
targetModelData = targetModelData || {};
targetModelData[fk] = this.modelInstance[pk];
return new modelTo(targetModelData);
};
/**
* Define the method for the hasOne relation itself
* It will support one of the following styles:
* - order.customer(refresh, callback): Load the target model instance asynchronously
* - order.customer(customer): Synchronous setter of the target model instance
* - order.customer(): Synchronous getter of the target model instance
*
* @param {Boolean} refresh Reload from the data source
* @param {Object|Function} params Query parameters
* @returns {Object}
*/
HasOne.prototype.related = function (refresh, params) {
var modelTo = this.definition.modelTo;
var fk = this.definition.keyTo;
var pk = this.definition.keyFrom;
var modelInstance = this.modelInstance;
var relationName = this.definition.name;
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');
}
var cachedValue;
if (!refresh && modelInstance.__cachedRelations
&& (modelInstance.__cachedRelations[relationName] !== undefined)) {
cachedValue = modelInstance.__cachedRelations[relationName];
}
if (params instanceof ModelBaseClass) { // acts as setter
params[fk] = modelInstance[pk];
modelInstance.__cachedRelations[relationName] = params;
} else if (typeof params === 'function') { // acts as async getter
var cb = params;
if (cachedValue === undefined) {
var query = {where: {}};
query.where[fk] = modelInstance[pk];
modelTo.findOne(query, function (err, inst) {
if (err) {
return cb(err);
}
if (!inst) {
return cb(null, null);
}
// Check if the foreign key matches the primary key
if (inst[fk] === modelInstance[pk]) {
cb(null, inst);
} else {
cb(new Error('Permission denied'));
}
});
return modelInstance[pk];
} else {
cb(null, cachedValue);
return cachedValue;
}
} else if (params === undefined) { // acts as sync getter
return modelInstance[pk];
} else { // setter
params[fk] = modelInstance[pk];
delete modelInstance.__cachedRelations[relationName];
}
};

View File

@ -1,49 +1,17 @@
/*!
* Dependencies
*/
var i8n = require('inflection');
var defineScope = require('./scope.js').defineScope;
var ModelBaseClass = require('./model.js');
var relation = require('./relation-definition');
var RelationDefinition = relation.RelationDefinition;
module.exports = Relation;
module.exports = RelationMixin;
/**
* Relations class. Use to define relationships between models.
* RelationMixin class. Use to define relationships between models.
*
* @class Relation
* @class RelationMixin
*/
function Relation() {
}
/**
* Find the relation by foreign key
* @param {*} foreignKey The foreign key
* @returns {Object} The relation object
*/
Relation.relationNameFor = function relationNameFor(foreignKey) {
for (var rel in this.relations) {
if (this.relations[rel].type === 'belongsTo' && this.relations[rel].keyFrom === foreignKey) {
return rel;
}
}
};
/*!
* Look up a model by name from the list of given models
* @param {Object} models Models keyed by name
* @param {String} modelName The model name
* @returns {*} The matching model class
*/
function lookupModel(models, modelName) {
if(models[modelName]) {
return models[modelName];
}
var lookupClassName = modelName.toLowerCase();
for (var name in models) {
if (name.toLowerCase() === lookupClassName) {
return models[name];
}
}
function RelationMixin() {
}
/**
@ -90,173 +58,14 @@ function lookupModel(models, modelName) {
* });
* });
*```
* @param {Object|String} anotherClass Model object (or String name of model) to which you are creating the relationship.
* @param {Object|String} modelTo Model object (or String name of model) to which you are creating the relationship.
* @options {Object} parameters Configuration parameters; see below.
* @property {String} as Name of the property in the referring model that corresponds to the foreign key field in the related model.
* @property {String} foreignKey Property name of foreign key field.
* @property {Object} model Model object
*/
Relation.hasMany = function hasMany(anotherClass, params) {
var thisClassName = this.modelName;
params = params || {};
if (typeof anotherClass === 'string') {
params.as = anotherClass;
if (params.model) {
anotherClass = params.model;
} else {
var anotherClassName = i8n.singularize(anotherClass).toLowerCase();
anotherClass = lookupModel(this.dataSource.modelBuilder.models, anotherClassName);
}
}
var methodName = params.as || i8n.camelize(anotherClass.pluralModelName, true);
var fk = params.foreignKey || i8n.camelize(thisClassName + '_id', true);
var idName = this.dataSource.idName(this.modelName) || 'id';
this.relations[methodName] = {
type: 'hasMany',
keyFrom: idName,
keyTo: fk,
modelTo: anotherClass,
multiple: true
};
if (params.through) {
this.relations[methodName].modelThrough = params.through;
}
// each instance of this class should have method named
// pluralize(anotherClass.modelName)
// which is actually just anotherClass.find({where: {thisModelNameId: this[idName]}}, cb);
var scopeMethods = {
findById: findById,
destroy: destroyById
};
if (params.through) {
var fk2 = i8n.camelize(anotherClass.modelName + '_id', true);
// Create an instance of the target model and connect it to the instance of
// the source model by creating an instance of the through model
scopeMethods.create = function create(data, done) {
if (typeof data !== 'object') {
done = data;
data = {};
}
if ('function' !== typeof done) {
done = function () {
};
}
var self = this;
// First create the target model
anotherClass.create(data, function (err, ac) {
if (err) return done(err, ac);
var d = {};
d[params.through.relationNameFor(fk)] = self;
d[params.through.relationNameFor(fk2)] = ac;
// Then create the through model
params.through.create(d, function (e) {
if (e) {
// Undo creation of the target model
ac.destroy(function () {
done(e);
});
} else {
done(err, ac);
}
});
});
};
/*!
* Add the target model instance to the 'hasMany' relation
* @param {Object|ID} acInst The actual instance or id value
*/
scopeMethods.add = function (acInst, done) {
var data = {};
var query = {};
query[fk] = this[idName];
data[params.through.relationNameFor(fk)] = this;
query[fk2] = acInst[idName] || acInst;
data[params.through.relationNameFor(fk2)] = acInst;
// Create an instance of the through model
params.through.findOrCreate({where: query}, data, done);
};
/*!
* Remove the target model instance from the 'hasMany' relation
* @param {Object|ID) acInst The actual instance or id value
*/
scopeMethods.remove = function (acInst, done) {
var q = {};
q[fk2] = acInst[idName] || acInst;
params.through.findOne({where: q}, function (err, d) {
if (err) {
return done(err);
}
if (!d) {
return done();
}
d.destroy(done);
});
};
// No destroy method will be injected
delete scopeMethods.destroy;
}
// Mix the property and scoped methods into the prototype class
defineScope(this.prototype, params.through || anotherClass, methodName, function () {
var filter = {};
filter.where = {};
filter.where[fk] = this[idName];
if (params.through) {
filter.collect = i8n.camelize(anotherClass.modelName, true);
filter.include = filter.collect;
}
return filter;
}, scopeMethods);
if (!params.through) {
// obviously, anotherClass should have attribute called `fk`
anotherClass.dataSource.defineForeignKey(anotherClass.modelName, fk, this.modelName);
}
// Find the target model instance by id
function findById(id, cb) {
anotherClass.findById(id, function (err, inst) {
if (err) {
return cb(err);
}
if (!inst) {
return cb(new Error('Not found'));
}
// Check if the foreign key matches the primary key
if (inst[fk] && inst[fk].toString() === this[idName].toString()) {
cb(null, inst);
} else {
cb(new Error('Permission denied'));
}
}.bind(this));
}
// Destroy the target model instance by id
function destroyById(id, cb) {
var self = this;
anotherClass.findById(id, function (err, inst) {
if (err) {
return cb(err);
}
if (!inst) {
return cb(new Error('Not found'));
}
// Check if the foreign key matches the primary key
if (inst[fk] && inst[fk].toString() === self[idName].toString()) {
inst.destroy(cb);
} else {
cb(new Error('Permission denied'));
}
});
}
RelationMixin.hasMany = function hasMany(modelTo, params) {
RelationDefinition.hasMany(this, modelTo, params);
};
/**
@ -304,151 +113,14 @@ Relation.hasMany = function hasMany(anotherClass, params) {
* ```
* This optional parameter default value is false, so the related object will be loaded from cache if available.
*
* @param {Class|String} anotherClass Model object (or String name of model) to which you are creating the relationship.
* @param {Class|String} modelTo Model object (or String name of model) to which you are creating the relationship.
* @options {Object} params Configuration parameters; see below.
* @property {String} as Name of the property in the referring model that corresponds to the foreign key field in the related model.
* @property {String} foreignKey Name of foreign key property.
*
*/
Relation.belongsTo = function (anotherClass, params) {
params = params || {};
if ('string' === typeof anotherClass) {
params.as = anotherClass;
if (params.model) {
anotherClass = params.model;
} else {
var anotherClassName = anotherClass.toLowerCase();
anotherClass = lookupModel(this.dataSource.modelBuilder.models, anotherClassName);
}
}
var idName = this.dataSource.idName(anotherClass.modelName) || 'id';
var methodName = params.as || i8n.camelize(anotherClass.modelName, true);
var fk = params.foreignKey || methodName + 'Id';
this.relations[methodName] = {
type: 'belongsTo',
keyFrom: fk,
keyTo: idName,
modelTo: anotherClass,
multiple: false
};
this.dataSource.defineForeignKey(this.modelName, fk, anotherClass.modelName);
this.prototype.__finders__ = this.prototype.__finders__ || {};
// Set up a finder to find by id and make sure the foreign key of the declaring
// model matches the primary key of the target model
this.prototype.__finders__[methodName] = function (id, cb) {
if (id === null) {
cb(null, null);
return;
}
anotherClass.findById(id, function (err, inst) {
if (err) {
return cb(err);
}
if (!inst) {
return cb(null, null);
}
// Check if the foreign key matches the primary key
if (inst[idName] === this[fk]) {
cb(null, inst);
} else {
cb(new Error('Permission denied'));
}
}.bind(this));
};
// Define the method for the belongsTo relation itself
// It will support one of the following styles:
// - order.customer(refresh, callback): Load the target model instance asynchronously
// - order.customer(customer): Synchronous setter of the target model instance
// - order.customer(): Synchronous getter of the target model instance
var relationMethod = function (refresh, p) {
if (arguments.length === 1) {
p = refresh;
refresh = false;
} else if (arguments.length > 2) {
throw new Error('Method can\'t be called with more than two arguments');
}
var self = this;
var cachedValue;
if (!refresh && this.__cachedRelations && (this.__cachedRelations[methodName] !== undefined)) {
cachedValue = this.__cachedRelations[methodName];
}
if (p instanceof ModelBaseClass) { // acts as setter
this[fk] = p[idName];
this.__cachedRelations[methodName] = p;
} else if (typeof p === 'function') { // acts as async getter
if (typeof cachedValue === 'undefined') {
this.__finders__[methodName].apply(self, [this[fk], function (err, inst) {
if (!err) {
self.__cachedRelations[methodName] = inst;
}
p(err, inst);
}]);
return this[fk];
} else {
p(null, cachedValue);
return cachedValue;
}
} else if (typeof p === 'undefined') { // acts as sync getter
return this[fk];
} else { // setter
this[fk] = p;
delete this.__cachedRelations[methodName];
}
};
// Define a property for the scope so that we have 'this' for the scoped methods
Object.defineProperty(this.prototype, methodName, {
enumerable: true,
configurable: true,
get: function () {
var fn = function() {
// Call the relation method on the declaring model instance
return relationMethod.apply(this, arguments);
}
// Create an instance of the target model and set the foreign key of the
// declaring model instance to the id of the target instance
fn.create = function(targetModelData, cb) {
var self = this;
anotherClass.create(targetModelData, function(err, targetModel) {
if(!err) {
self[fk] = targetModel[idName];
cb && cb(err, targetModel);
} else {
cb && cb(err);
}
});
}.bind(this);
// Build an instance of the target model
fn.build = function(targetModelData) {
return new anotherClass(targetModelData);
}.bind(this);
fn._targetClass = anotherClass.modelName;
return fn;
}});
// Wrap the property into a function for remoting
// so that it can be accessed as /api/<model>/<id>/<belongsToRelationName>
// For example, /api/orders/1/customer
var fn = function() {
var f = this[methodName];
f.apply(this, arguments);
};
fn.shared = true;
fn.http = {verb: 'get', path: '/' + methodName};
fn.accepts = {arg: 'refresh', type: 'boolean', http: {source: 'query'}};
fn.description = 'Fetches belongsTo relation ' + methodName;
fn.returns = {arg: methodName, type: 'object', root: true};
this.prototype['__get__' + methodName] = fn;
RelationMixin.belongsTo = function (modelTo, params) {
RelationDefinition.belongsTo(this, modelTo, params);
};
/**
@ -475,39 +147,17 @@ Relation.belongsTo = function (anotherClass, params) {
* user.groups.remove(group, callback);
* ```
*
* @param {String|Object} anotherClass Model object (or String name of model) to which you are creating the relationship.
* @param {String|Object} modelTo Model object (or String name of model) to which you are creating the relationship.
* the relation
* @options {Object} params Configuration parameters; see below.
* @property {String} as Name of the property in the referring model that corresponds to the foreign key field in the related model.
* @property {String} foreignKey Property name of foreign key field.
* @property {Object} model Model object
*/
Relation.hasAndBelongsToMany = function hasAndBelongsToMany(anotherClass, params) {
params = params || {};
var models = this.dataSource.modelBuilder.models;
if ('string' === typeof anotherClass) {
params.as = anotherClass;
if (params.model) {
anotherClass = params.model;
} else {
anotherClass = lookupModel(models, i8n.singularize(anotherClass).toLowerCase()) ||
anotherClass;
}
if (typeof anotherClass === 'string') {
throw new Error('Could not find "' + anotherClass + '" relation for ' + this.modelName);
}
}
if (!params.through) {
var name1 = this.modelName + anotherClass.modelName;
var name2 = anotherClass.modelName + this.modelName;
params.through = lookupModel(models, name1) || lookupModel(models, name2) ||
this.dataSource.define(name1);
}
params.through.belongsTo(this);
params.through.belongsTo(anotherClass);
this.hasMany(anotherClass, {as: params.as, through: params.through});
RelationMixin.hasAndBelongsToMany = function hasAndBelongsToMany(modelTo, params) {
RelationDefinition.hasAndBelongsToMany(this, modelTo, params);
};
RelationMixin.hasOne = function hasMany(modelTo, params) {
RelationDefinition.hasOne(this, modelTo, params);
};

View File

@ -6,6 +6,52 @@ var defineCachedRelations = utils.defineCachedRelations;
*/
exports.defineScope = defineScope;
function ScopeDefinition(definition) {
this.sourceModel = definition.sourceModel;
this.targetModel = definition.targetModel || definition.sourceModel;
this.name = definition.name;
this.params = definition.params;
this.methods = definition.methods;
}
ScopeDefinition.prototype.related = function(receiver, scopeParams, condOrRefresh, cb) {
var name = this.name;
var self = receiver;
var actualCond = {};
var actualRefresh = false;
var saveOnCache = true;
if (arguments.length === 3) {
cb = condOrRefresh;
} else if (arguments.length === 4) {
if (typeof condOrRefresh === 'boolean') {
actualRefresh = condOrRefresh;
} else {
actualCond = condOrRefresh;
actualRefresh = true;
saveOnCache = false;
}
} else {
throw new Error('Method can be only called with one or two arguments');
}
if (!self.__cachedRelations || self.__cachedRelations[name] === undefined
|| actualRefresh) {
// It either doesn't hit the cache or refresh is required
var params = mergeQuery(actualCond, scopeParams);
return this.targetModel.find(params, function (err, data) {
if (!err && saveOnCache) {
defineCachedRelations(self);
self.__cachedRelations[name] = data;
}
cb(err, data);
});
} else {
// Return from cache
cb(null, self.__cachedRelations[name]);
}
}
/**
* Define a scope to the class
* @param {Model} cls The class where the scope method is added
@ -22,7 +68,7 @@ function defineScope(cls, targetClass, name, params, methods) {
cls._scopeMeta = {};
}
// only makes sence to add scope in meta if base and target classes
// only makes sense to add scope in meta if base and target classes
// are same
if (cls === targetClass) {
cls._scopeMeta[name] = params;
@ -32,6 +78,14 @@ function defineScope(cls, targetClass, name, params, methods) {
}
}
var definition = new ScopeDefinition({
sourceModel: cls,
targetModel: targetClass,
name: name,
params: params,
methods: methods
});
// Define a property for the scope
Object.defineProperty(cls, name, {
enumerable: false,
@ -49,42 +103,18 @@ function defineScope(cls, targetClass, name, params, methods) {
*/
get: function () {
var self = this;
var f = function caller(condOrRefresh, cb) {
var actualCond = {};
var actualRefresh = false;
var saveOnCache = true;
if (arguments.length === 1) {
cb = condOrRefresh;
} else if (arguments.length === 2) {
if (typeof condOrRefresh === 'boolean') {
actualRefresh = condOrRefresh;
var f = function(condOrRefresh, cb) {
if(arguments.length === 1) {
definition.related(self, f._scope, condOrRefresh);
} else {
actualCond = condOrRefresh;
actualRefresh = true;
saveOnCache = false;
}
} else {
throw new Error('Method can be only called with one or two arguments');
}
if (!self.__cachedRelations || self.__cachedRelations[name] === undefined
|| actualRefresh) {
// It either doesn't hit the cache or reresh is required
var params = mergeParams(actualCond, caller._scope);
return targetClass.find(params, function (err, data) {
if (!err && saveOnCache) {
defineCachedRelations(self);
self.__cachedRelations[name] = data;
}
cb(err, data);
});
} else {
// Return from cache
cb(null, self.__cachedRelations[name]);
definition.related(self, f._scope, condOrRefresh, cb);
}
};
f._scope = typeof params === 'function' ? params.call(this) : params;
f._targetClass = targetClass.modelName;
f._scope = typeof definition.params === 'function' ?
definition.params.call(self) : definition.params;
f._targetClass = definition.targetModel.modelName;
if (f._scope.collect) {
f._targetClass = i8n.capitalize(f._scope.collect);
}
@ -92,20 +122,23 @@ function defineScope(cls, targetClass, name, params, methods) {
f.build = build;
f.create = create;
f.destroyAll = destroyAll;
for (var i in methods) {
f[i] = methods[i].bind(this);
for (var i in definition.methods) {
f[i] = definition.methods[i].bind(self);
}
// define sub-scopes
// Define scope-chaining, such as
// Station.scope('active', {where: {isActive: true}});
// Station.scope('subway', {where: {isUndeground: true}});
// Station.active.subway(cb);
Object.keys(targetClass._scopeMeta).forEach(function (name) {
Object.defineProperty(f, name, {
enumerable: false,
get: function () {
mergeParams(f._scope, targetClass._scopeMeta[name]);
mergeQuery(f._scope, targetClass._scopeMeta[name]);
return f;
}
});
}.bind(this));
}.bind(self));
return f;
}
});
@ -150,9 +183,40 @@ function defineScope(cls, targetClass, name, params, methods) {
cls['__delete__' + name] = fn_delete;
/*
* Extracting fixed property values for the scope from the where clause into
* the data object
*
* @param {Object} The data object
* @param {Object} The where clause
*/
function setScopeValuesFromWhere(data, where) {
for (var i in where) {
if (i === 'and') {
// Find fixed property values from each subclauses
for (var w = 0, n = where[i].length; w < n; w++) {
setScopeValuesFromWhere(data, where[i][w]);
}
continue;
}
var prop = targetClass.definition.properties[i];
if (prop) {
var val = where[i];
if (typeof val !== 'object' || val instanceof prop.type) {
// Only pick the {propertyName: propertyValue}
data[i] = where[i];
}
}
}
}
// and it should have create/build methods with binded thisModelNameId param
function build(data) {
return new targetClass(mergeParams(this._scope, {where: data || {}}).where);
data = data || {};
// Find all fixed property values for the scope
var where = (this._scope && this._scope.where) || {};
setScopeValuesFromWhere(data, where);
return new targetClass(data);
}
function create(data, cb) {
@ -170,29 +234,31 @@ function defineScope(cls, targetClass, name, params, methods) {
- If fetching the Elements on which destroyAll is called results in an error
*/
function destroyAll(cb) {
targetClass.find(this._scope, function (err, data) {
if (err) {
cb(err);
} else {
(function loopOfDestruction(data) {
if (data.length > 0) {
data.shift().destroy(function (err) {
if (err && cb) cb(err);
loopOfDestruction(data);
});
} else {
if (cb) cb();
targetClass.destroyAll(this._scope, cb);
}
}(data));
}
/*!
* Merge query parameters
* @param {Object} base The base object to contain the merged results
* @param {Object} update The object containing updates to be merged
* @returns {*|Object} The base object
* @private
*/
function mergeQuery(base, update) {
if (!update) {
return;
}
base = base || {};
if (update.where && Object.keys(update.where).length > 0) {
if (base.where && Object.keys(base.where).length > 0) {
base.where = {and: [base.where, update.where]};
} else {
base.where = update.where;
}
});
}
function mergeParams(base, update) {
base = base || {};
if (update.where) {
base.where = merge(base.where, update.where);
}
// Overwrite inclusion
if (update.include) {
base.include = update.include;
}
@ -205,36 +271,21 @@ function defineScope(cls, targetClass, name, params, methods) {
base.order = update.order;
}
if(update.limit !== undefined) {
// overwrite pagination
if (update.limit !== undefined) {
base.limit = update.limit;
}
if(update.skip !== undefined) {
if (update.skip !== undefined) {
base.skip = update.skip;
}
if(update.offset !== undefined) {
if (update.offset !== undefined) {
base.offset = update.offset;
}
if(update.fields !== undefined) {
// Overwrite fields
if (update.fields !== undefined) {
base.fields = update.fields;
}
return base;
}
}
/**
* Merge `base` and `update` params
* @param {Object} base - base object (updating this object)
* @param {Object} update - object with new data to update base
* @returns {Object} `base`
*/
function merge(base, update) {
base = base || {};
if (update) {
Object.keys(update).forEach(function (key) {
base[key] = update[key];
});
}
return base;
}

View File

@ -872,27 +872,33 @@ describe('Load models with relations', function () {
var Account = ds.define('Account', {userId: Number, type: String}, {relations: {user: {type: 'belongsTo', model: 'User'}}});
assert(Post.relations['user']);
assert.deepEqual(Post.relations['user'], {
assert.deepEqual(Post.relations['user'].toJSON(), {
name: 'user',
type: 'belongsTo',
modelFrom: 'Post',
keyFrom: 'userId',
modelTo: 'User',
keyTo: 'id',
modelTo: User,
multiple: false
});
assert(User.relations['posts']);
assert.deepEqual(User.relations['posts'], {
assert.deepEqual(User.relations['posts'].toJSON(), {
name: 'posts',
type: 'hasMany',
modelFrom: 'User',
keyFrom: 'id',
modelTo: 'Post',
keyTo: 'userId',
modelTo: Post,
multiple: true
});
assert(User.relations['accounts']);
assert.deepEqual(User.relations['accounts'], {
assert.deepEqual(User.relations['accounts'].toJSON(), {
name: 'accounts',
type: 'hasMany',
modelFrom: 'User',
keyFrom: 'id',
modelTo: 'Account',
keyTo: 'userId',
modelTo: Account,
multiple: true
});

View File

@ -1,7 +1,7 @@
// This test written in mocha+should.js
var should = require('./init.js');
var db, Book, Chapter, Author, Reader;
var db, Book, Chapter, Author, Reader, Publisher;
describe('relations', function () {
before(function (done) {
@ -180,6 +180,42 @@ describe('relations', function () {
});
describe('hasOne', function () {
var Supplier, Account;
before(function () {
db = getSchema();
Supplier = db.define('Supplier', {name: String});
Account = db.define('Account', {accountNo: String});
});
it('can be declared using hasOne method', function () {
Supplier.hasOne(Account);
Object.keys((new Account()).toObject()).should.include('supplierId');
(new Supplier()).account.should.be.an.instanceOf(Function);
});
it('can be used to query data', function (done) {
// Supplier.hasOne(Account);
db.automigrate(function () {
Supplier.create({name: 'Supplier 1'}, function (e, supplier) {
should.not.exist(e);
should.exist(supplier);
supplier.account.create({accountNo: 'a01'}, function (err, account) {
supplier.account(function (e, act) {
should.not.exist(e);
should.exist(act);
act.should.be.an.instanceOf(Account);
supplier.account().should.equal(act.id);
done();
});
});
});
});
});
});
describe('hasAndBelongsToMany', function () {
var Article, Tag, ArticleTag;
it('can be declared', function (done) {

View File

@ -3,7 +3,7 @@ var should = require('./init.js');
var db, Railway, Station;
describe('sc0pe', function () {
describe('scope', function () {
before(function () {
db = getSchema();