water-orm
Version:
A monolith version of Standalone waterline ORM
618 lines (467 loc) • 16.7 kB
JavaScript
/**
* Module dependencies
*/
var _ = require('lodash');
var utils = require('./utils');
var hop = utils.object.hasOwnProperty;
/**
* Expose JoinTables
*/
module.exports = JoinTables;
/**
* Insert Join/Junction Tables where needed whenever two collections
* point to each other. Also replaces the references to point to the new join table.
*
* @param {Object} collections
* @return {Object}
* @api private
*/
function JoinTables(collections) {
var self = this;
var joinTables;
collections = collections || {};
this.tables = {};
this.collections = _.cloneDeep(collections);
// Build Up Join Tables
for(var collection in collections) {
// Parse the collection's attributes and create join tables
// where needed for collections
joinTables = this.buildJoins(collection);
this.uniqueTables(joinTables);
// Mark hasManyThrough tables as junction tables with select all set to true
this.markCustomJoinTables(collection);
}
// Update Collection Attributes to point to the join table
this.linkAttributes();
// Filter all the tables which have at least on collection on migrate: self, so they won't be built
this.filterMigrateSafeTables();
// Remove properties added just for unqueness
Object.keys(this.tables).forEach(function(table) {
delete self.tables[table].joinedAttributes;
});
return _.extend(this.collections, this.tables);
}
/**
* Build A Set of Join Tables
*
* @param {String} collection
* @api private
* @return {Array}
*/
JoinTables.prototype.buildJoins = function(collection) {
var self = this;
var tables = [];
var attributes = this.collections[collection].attributes;
var collectionAttributes = this.mapCollections(attributes);
// If there are no collection attributes return an empty array
if(Object.keys(collectionAttributes).length === 0) return [];
// For each collection attribute, inspect it to build up a join table if needed.
collectionAttributes.forEach(function(attribute) {
var table = self.parseAttribute(collection, attribute);
if(table) tables.push(self.buildTable(table));
});
return tables;
};
/**
* Find Has Many attributes for a given set of attributes.
*
* @param {Object} attributes
* @return {Object}
* @api private
*/
JoinTables.prototype.mapCollections = function(attributes) {
var collectionAttributes = [];
for(var attribute in attributes) {
if(!hop(attributes[attribute], 'collection')) continue;
collectionAttributes.push({ key: attribute, val: attributes[attribute] });
}
return collectionAttributes;
};
/**
* Parse Collection Attributes
*
* Check the collection the attribute references to see if this is a one-to-many or many-to-many
* relationship. If it's a one-to-many we don't need to build up a join table.
*
* @param {String} collectionName
* @param {Object} attribute
* @return {Object}
* @api private
*/
JoinTables.prototype.parseAttribute = function(collectionName, attribute) {
var error = '';
var attr = attribute.val;
// Check if this is a hasManyThrough attribute,
// if so a join table doesn't need to be created
if(hop(attr, 'through')) return;
// Normalize `collection` property name to lowercased version
attr.collection = attr.collection.toLowerCase();
// Grab the associated collection and ensure it exists
var child = this.collections[attr.collection];
if(!child) {
error = 'Collection ' + collectionName + ' has an attribute named ' + attribute.key + ' that is ' +
'pointing to a collection named ' + attr.collection + ' which doesn\'t exist. You must ' +
' first create the ' + attr.collection + ' collection.';
throw new Error(error);
}
// If the child attribute does not exist, throw an Error.
if(hop(attr, 'via') && (!child.attributes[attr.via])) {
error = 'on mapping custom foreign keys: ' + collectionName + '->' + attr.collection + '. Attribute: `' +
attr.via + '` does not exist on: collection: `' + attr.collection + '`';
throw new Error(error);
}
// If the attribute has a `via` key, check if it's a foreign key. If so this is a one-to-many
// relationship and no join table is needed.
if(hop(attr, 'via') && hop(child.attributes[attr.via], 'foreignKey')) return;
// If no via is specified, a name needs to be created for the other column
// in the join table. Use the attribute key and the associated collection name
// which will be unique.
if(!hop(attr, 'via')) attr.via = attribute.key + '_' + attr.collection;
// Build up an object that can be used to build a join table
var tableAttributes = {
column_one: {
collection: collectionName.toLowerCase(),
attribute: attribute.key,
via: attr.via
},
column_two: {
collection: attr.collection,
attribute: attr.via,
via: attribute.key
}
};
return tableAttributes;
};
/**
* Build Collection for a single join
*
* @param {Object} columns
* @return {Object}
* @api private
*/
JoinTables.prototype.buildTable = function(columns) {
var table = {};
var c1 = columns.column_one;
var c2 = columns.column_two;
table.identity = this.buildCollectionName(columns).toLowerCase();
table.tableName = table.identity;
table.tables = [c1.collection, c2.collection];
table.joinedAttributes = [];
table.junctionTable = true;
// Append the meta data if defined.
// To do this, find the dominant collection or just use C1 and then merge the
// two meta objects together with the dominant side taking precedence.
var dominantCollection;
var nonDominantCollection;
if(this.searchForAttribute(c2.collection, 'dominant')) {
dominantCollection = c2.collection;
nonDominantCollection = c1.collection;
} else {
dominantCollection = c1.collection;
nonDominantCollection = c2.collection;
}
var metaData = _.merge({}, (this.collections[nonDominantCollection].meta || {}), (this.collections[dominantCollection].meta || {}));
table.meta = metaData;
// Look for a dominant collection property so the join table can be created on the correct connection.
table.connection = this.findDominantConnection(columns);
if(!table.connection) {
var err = "A 'dominant' property was not supplied for the two collections in a many-to-many relationship. " +
"One side of the relationship between '" + c1.collection + "' and '" + c2.collection + "' needs a " +
"'dominant: true' flag set so a join table can be created on the correct connection.";
throw new Error(err);
}
// Set a primary key (should probably be refactored)
table.attributes = {
id: {
primaryKey: true,
autoIncrement: true,
type: 'integer'
}
};
// Add each foreign key as an attribute
table.attributes[c1.collection + '_' + c1.attribute] = this.buildForeignKey(c1, c2);
table.attributes[c2.collection + '_' + c2.attribute] = this.buildForeignKey(c2, c1);
table.joinedAttributes.push(c1.collection + '_' + c1.attribute);
table.joinedAttributes.push(c2.collection + '_' + c2.attribute);
return table;
};
/**
* Build a collection name by combining two collection and attribute names.
*
* @param {Object} columns
* @return {String}
* @api private
*/
JoinTables.prototype.buildCollectionName = function(columns) {
var c1 = columns.column_one;
var c2 = columns.column_two;
if(c1.collection < c2.collection) {
return c1.collection + '_' + c1.attribute + '__' + c2.collection + '_' + c2.attribute;
}
return c2.collection + '_' + c2.attribute + '__' + c1.collection + '_' + c1.attribute;
};
/**
* Find the dominant collection.
*
* @param {Object} columns
* @return {String}
* @api private
*/
JoinTables.prototype.findDominantConnection = function(columns) {
var c1 = this.collections[columns.column_one.collection];
var c2 = this.collections[columns.column_two.collection];
var dominantCollection;
// Don't require a dominant collection on self-referencing associations
if(columns.column_one.collection === columns.column_two.collection) {
return c1.connection;
}
dominantCollection = this.searchForAttribute(columns.column_one.collection, 'dominant');
if(dominantCollection) return c1.connection;
dominantCollection = this.searchForAttribute(columns.column_two.collection, 'dominant');
if(dominantCollection) return c2.connection;
// Don't require a dominant collection for models on the same connection.
if (c1.connection[0] === c2.connection[0]) {
return c1.connection;
}
return false;
};
/**
* Search Attributes for an attribute property.
*
* @param {String} collectionName
* @param {String} attributeName
* @param {String} value (optional)
* @return {String}
* @api private
*/
JoinTables.prototype.searchForAttribute = function(collectionName, attributeName, value) {
var collection = this.collections[collectionName];
var matching;
var properties;
Object.keys(collection.attributes).forEach(function(key) {
properties = collection.attributes[key];
if(!value && hop(properties, attributeName)) matching = key;
if(hop(properties, attributeName) && properties[attributeName] === value) matching = key;
});
return matching;
};
/**
* Build a Foreign Key value for an attribute in the join collection
*
* @param {Object} column_one
* @param {Object} column_two
* @return {Object}
* @api private
*/
JoinTables.prototype.buildForeignKey = function(column_one, column_two) {
var primaryKey = this.findPrimaryKey(column_one.collection);
var columnName = (column_one.collection + '_' + column_one.attribute);
var viaName = column_two.collection + '_' + column_one.via;
return {
columnName: columnName,
type: primaryKey.attributes.type,
foreignKey: true,
references: column_one.collection,
on: primaryKey.name,
onKey: primaryKey.name,
via: viaName,
groupKey: column_one.collection
};
};
/**
* Filter Out Duplicate Join Tables
*
* @param {Array} tables
* @api private
*/
JoinTables.prototype.uniqueTables = function(tables) {
var self = this;
tables.forEach(function(table) {
var add = true;
// Check if any tables are already joining these attributes together
Object.keys(self.tables).forEach(function(tableName) {
var currentTable = self.tables[tableName];
if(currentTable.joinedAttributes.indexOf(table.joinedAttributes[0]) === -1) return;
if(currentTable.joinedAttributes.indexOf(table.joinedAttributes[1]) === -1) return;
add = false;
});
if(hop(self.tables, table.identity)) return;
if(add) self.tables[table.identity] = table;
});
};
/**
* Find a collection's primary key attribute
*
* @param {String} collection
* @return {Object}
* @api private
*/
JoinTables.prototype.findPrimaryKey = function(collection) {
var primaryKey = null;
var attribute;
var error;
if(!this.collections[collection]) {
throw new Error('Trying to access a collection ' + collection + ' that is not defined.');
}
if(!this.collections[collection].attributes) {
throw new Error('Collection, ' + collection + ', has no attributes defined.');
}
for(var key in this.collections[collection].attributes) {
attribute = this.collections[collection].attributes[key];
if(!hop(attribute, 'primaryKey')) continue;
primaryKey = {
name: attribute.columnName || key,
attributes: attribute
};
}
if(!primaryKey) {
error = 'Trying to create an association on a model that doesn\'t have a Primary Key.';
throw new Error(error);
}
return primaryKey;
};
/**
* Update Collection Attributes to point to the join table instead of the other collection
*
* @api private
*/
JoinTables.prototype.linkAttributes = function() {
for(var collection in this.collections) {
var attributes = this.collections[collection].attributes;
this.updateAttribute(collection, attributes);
}
};
/**
* Update An Attribute
*
* @param {String} collection
* @param {Object} attributes
* @api private
*/
JoinTables.prototype.updateAttribute = function(collection, attributes) {
for(var attribute in attributes) {
if(!hop(attributes[attribute], 'collection')) continue;
var attr = attributes[attribute];
var parent = collection;
var child = attr.collection;
var via = attr.via;
var joined = this.findJoinTable(parent, child, via);
if(!joined.join) continue;
// If the table doesn't know about the other side ignore updating anything
if(!hop(joined.table.attributes, collection + '_' + attribute)) continue;
this.collections[collection].attributes[attribute] = {
collection: joined.table.identity,
references: joined.table.identity,
on: joined.table.attributes[collection + '_' + attribute].columnName,
onKey: joined.table.attributes[collection + '_' + attribute].columnName
};
}
};
/**
* Mark Custom Join Tables as a Junction Table
*
* If a collection has an attribute with a `through` property, lookup
* the collection it points to and mark it as a `junctionTable`.
*
* @param {String} collection
* @api private
*/
JoinTables.prototype.markCustomJoinTables = function(collection) {
var attributes = this.collections[collection].attributes;
for(var attribute in attributes) {
if(!hop(attributes[attribute], 'through')) continue;
var linkedCollection = attributes[attribute].through;
this.collections[linkedCollection].throughTable = this.collections[linkedCollection].throughTable || {};
var throughPath = collection + '.' + attribute;
var linkedAttrs = this.collections[linkedCollection].attributes;
this.collections[linkedCollection].throughTable[throughPath] = _.find(_.keys(linkedAttrs), function(attr){
return linkedAttrs[attr].references === attributes[attribute].collection.toLowerCase();
});
// Build up proper reference on the attribute
attributes[attribute].collection = linkedCollection;
attributes[attribute].references = linkedCollection;
// Find Reference Key
var via = attributes[attribute].via;
var reference = this.findReference(collection, linkedCollection, via);
var referencedAttr = this.collections[linkedCollection].attributes[reference];
var onValue;
if (referencedAttr && referencedAttr.columnName) {
onValue = referencedAttr.columnName;
} else {
onValue = reference;
}
attributes[attribute].on = onValue;
attributes[attribute].onKey = reference;
delete attributes[attribute].through;
}
};
/**
* Find Reference attribute name in a set of attributes
*
* @param {String} parent
* @param {String} collection
* @return {String}
* @api private
*/
JoinTables.prototype.findReference = function(parent, collection, via) {
var attributes = this.collections[collection].attributes;
var reference;
for(var attribute in attributes) {
if(!hop(attributes[attribute], 'foreignKey')) continue;
if(!hop(attributes[attribute], 'references')) continue;
if(attributes[attribute].references !== parent) continue;
if(via && via !== attribute) continue;
reference = attribute;
break;
}
return reference;
};
/**
* Search for a matching join table
*
* @param {String} parent
* @param {String} child
* @param {String} via
* @return {Object}
* @api private
*/
JoinTables.prototype.findJoinTable = function(parent, child, via) {
var join = false;
var tableCollection;
for(var table in this.tables) {
var tables = this.tables[table].tables;
if(tables.indexOf(parent) < 0) continue;
if(tables.indexOf(child) < 0) continue;
var column = child + '_' + via;
if(!hop(this.tables[table].attributes, column)) continue;
join = true;
tableCollection = this.tables[table];
break;
}
return { join: join, table: tableCollection };
};
/**
* Filter all tables which have at least one collection set to migrate: true, before they get physically created in the database
* AFTER all references are set and all collections have been linked
*
* @param {String} tables
* @api private
*/
JoinTables.prototype.filterMigrateSafeTables = function() {
var self = this;
for(var table in this.tables) {
var tables = this.tables[table].tables;
// iterate through all collections, if one of them is migrate: safe we delete the table
// so it does not get built
var migrateSafe = false;
tables.forEach(function(collection) {
if(self.collections[collection].migrate === 'safe') {
migrateSafe = true;
}
});
if(migrateSafe === true) {
this.tables[table].migrate = 'safe';
}
}
return this.tables;
};