UNPKG

angie-orm

Version:

A Feature-Complete Database Relationship Manager Designed for NodeJS

465 lines (413 loc) 15.6 kB
/** * @module BaseDBConnection.js * @author Joe Groseclose <@benderTheCrime> * @date 8/23/2015 */ // System Modules import util from 'util'; import {cyan} from 'chalk'; import $LogProvider from 'angie-log'; // Angie ORM Modules import { $$InvalidModelReferenceError, $$InvalidModelFieldReferenceError, $$InvalidRelationCrossReferenceError } from '../util/$ExceptionsProvider'; // Keys we do not necessarily want to parse as query arguments const p = process, IGNORE_KEYS = [ 'database', 'tail', 'head', 'rows', 'values', 'model' ]; /** * @desc BaseDBConnection is a private class which is not exposed to the Angie * provider. It contains all of the methods quintessential to making DB queries * regardless of DB vehicle. Some of the methods in this class are specific to * SQL type DBs and will need to be replaced when subclassed. This should be the * base class used for each DB type * @since 0.2.3 * @access private */ class BaseDBConnection { /** * @param {object} database The database object to which the connection is * being made * @param {boolean} destructive Should destructive migrations be run? */ constructor(database, destructive = false, dryRun = false) { this.database = database; this.destructive = destructive; this.dryRun = dryRun; } models() { return this._models; } all(args = {}, fetchQuery = '', filterQuery = '') { if (!args.model || !args.model.name) { throw new $$InvalidModelReferenceError(); } let values = '*'; if (typeof args.values === 'object' && args.values.length) { values = args.values; if (values.indexOf('id') === -1) { values.unshift('id'); } } return `SELECT ${values} FROM ${args.model.name}` + `${filterQuery ? ` WHERE ${filterQuery}` : ''}` + `${fetchQuery ? ` ${fetchQuery}` : ''};`; } fetch(args = {}, filterQuery = '') { let ord = 'ASC'; if ( (args.head && args.head === false) || (args.tail && args.tail === true) ) { ord = 'DESC'; } const int = args.rows || 1, fetchQuery = `ORDER BY id ${ord} LIMIT ${int}`; return this.all(args, fetchQuery, filterQuery); } filter(args = {}) { return this.fetch(args, this.$$filterQuery(args)); } /** * @desc _fitlerQuery builds the "WHERE" statements of queries. It is * responsible for parsing all query conditions * @since 0.2.2 * @param {object} args Object representation of the arguments * @param {string} args.key Name and value/conditions of field to be parsed. * Values can be prefixed with the following comparators: * "~": "Like", >, >=, <, <= * @param {object|Array<>} args.values Fields for an "in" query * @returns {string} A query string to be passed to the performed query * @access private */ $$filterQuery(args) { let filterQuery = [], fn = function(k, v) { // If we're dealing with a number... if (typeof v === 'number') { return `${k}=${v}`; } else if (v.indexOf('~') > -1) { // If there is a like operator, add a like phrase return `${k} like '%${v.replace(/~/g, '')}%'`; } else if ( /(<=?|>=?)[^<>=]+/.test(v) && v.indexOf('>>') === -1 && v.indexOf('<<') === -1 ) { // If there is a condition, parse it, use as operator // ^^ TODO there has to be a better way to do that condition return v.replace(/(<=?|>=?)([^<>=]+)/, function(m, p, v) { return `${k}${p}${!isNaN(v) ? v : `'${v}'` }`; }); } // Otherwise, return equality return `${k}='${v.replace(/[<>=]*/g, '')}'`; }; for (let key in args) { if (IGNORE_KEYS.indexOf(key) > -1) { continue; } if (args[ key ] && typeof args[ key ] !== 'object') { filterQuery.push(fn(key, args[ key ])); } else { filterQuery.push(`${key} in ${this.$$queryInString(args[ key ])}`); } } return filterQuery.length ? `${filterQuery.join(' AND ')}` : ''; } create(args = {}) { let keys = Object.keys(args), queryKeys = [], values = []; if (!args.model || !args.model.name) { throw new $$InvalidModelReferenceError(); } keys.forEach(function(key) { if (IGNORE_KEYS.indexOf(key) === -1) { queryKeys.push(key); values.push(`'${args[key]}'`); } }); return `INSERT INTO ${args.model.name} (${queryKeys.join(', ')}) ` + `VALUES (${values.join(', ')});`; } delete(args = {}) { return `DELETE FROM ${args.model.name} WHERE ${this.$$filterQuery(args)};`; } update(args = {}) { if (!args.model || !args.model.name) { throw new $$InvalidModelReferenceError(); } let filterQuery = this.$$filterQuery(args), idSet = this.$$queryInString(args.rows, 'id'); if (!filterQuery) { $LogProvider.warn('No filter query in UPDATE statement.'); } else { return `UPDATE ${args.model.name} SET ${filterQuery} WHERE id in ${idSet};`; } } /** * @desc $$queryInString builds any "in" query statements in the query * arguments * @since 0.2.2 * @param {object} args Object representation of the arguments * @param {string} args.key Name and value/conditions of field to be parsed * @param {object|Array<>} args.values Fields for an "in" query * @param {string} key The name of the key to parse for "in" arguments * @returns {string} A query string to be passed to the performed query * @access private */ $$queryInString(args = {}, key) { let fieldSet = []; if (key) { args.forEach(function(row) { fieldSet.push(`'${row[ key ]}'`); }); } else if (args instanceof Array) { fieldSet = args.map((v) => `'${v}'`); } return `(${fieldSet.length ? fieldSet.join(',') : ''})`; } sync() { let me = this; // Every instance of sync needs a registry of the models, which implies return global.app.$$load().then(function() { me._models = global.app.Models; $LogProvider.info( `Synccing database: ${cyan(me.database.name || me.database.alias)}` ); }); } migrate() { let me = this; // Every instance of sync needs a registry of the models, which implies return global.app.$$load().then(function() { me._models = global.app.Models; $LogProvider.info( `Migrating database: ${cyan(me.database.name || me.database.alias)}` ); }); } $$queryset(model = {}, query, rows = [], errors) { const queryset = new AngieDBObject(this, model, query); let results = [], manyToManyFieldNames = [], rels = [], relFieldNames = {}, relArgs = {}, proms = []; for (let key in model) { let field = model[ key ]; if (field.type && field.type === 'ManyToManyField') { manyToManyFieldNames.push(key); } } if (rows instanceof Array) { rows.forEach(function(v) { // Create a copy to be added to the raw results set let $v = util._extend({}, v); // Add update method to row to allow the single row to be // updated v.update = queryset.$$update.bind(queryset, v); for (let key of manyToManyFieldNames) { const field = model[ key ], many = v[ key ] = {}; for (let method of [ 'add', 'remove' ]) { many[ method ] = queryset.$$addOrRemove.bind( queryset, method, field, v.id ); } for (let method of [ 'all', 'fetch', 'filter' ]) { many[ method ] = queryset.$$readMethods.bind( queryset, method, field, v.id ); } } // Find all of the foreign key fields for (let key in v) { const field = model[ key ]; if (field && ( field.nesting === true || field.deepNesting === true ) ) { rels.push(field.rel); relFieldNames[ field.rel ] = key; relArgs[ field.rel ] = rows.map((v) => v.id); } } results.push($v); }); } // Add update method to row set so that the whole queryset can be // updated rows.update = queryset.$$update.bind(queryset, rows); // Remove reference methods delete queryset.$$update; delete queryset.$$addOrRemove; delete queryset.$$readMethods; // Instantiate a promise for each of the foreign key fields in the query rels.forEach(function(v) { // Reference the relative object proms.push(global.app.Models[ v ].filter({ database: model.$$database.name, id: relArgs[ v ] }).then(function(queryset) { // Add errors to queryset errors if (errors === null) { errors = []; } // Add any errors to the queryset errors.push(queryset.errors); rows.forEach(function(row, i) { queryset.forEach(function(queryrow) { if ( !isNaN(+row[ relFieldNames[ v ] ]) && queryrow.hasOwnProperty('id') && row[ relFieldNames[ v ] ] === queryrow.id ) { // Assign the nested row results[ i ][ relFieldNames[ v ] ] = queryset.results[ i ]; row[ relFieldNames[ v ] ] = queryrow; } else { results[ i ][ relFieldNames[ v ] ] = row[ relFieldNames[ v ] ] = null; } }); }); })); }); return Promise.all(proms).then(function() { // Resolves to a value in the connections currently return util._extend( rows, { // The raw query results results: results, // Any errors errors: errors, first: AngieDBObject.prototype.first, last: AngieDBObject.prototype.last }, queryset ); }); } $$name(modelName) { modelName = modelName.replace(/([A-Z])/g, '_$1').toLowerCase(); if (modelName.charAt(0) === '_') { modelName = modelName.slice(1, modelName.length); } return modelName; } } class AngieDBObject { constructor(database, model, query = '') { if (!database || !model) { return; } this.database = database; this.model = model; this.query = query; } first() { return this[0]; } last() { return this.pop(); } $$update(rows, args = {}) { // This should only be called internally, so it's not a huge hack: // rows either references the whole queryset or just a single row args.rows = rows instanceof Array ? rows : [ rows ]; args.model = this.model; if (typeof args !== 'object') { return; } let updateObj = {}; for (let key in args) { const val = args[ key ] || null; if (IGNORE_KEYS.indexOf(key) > -1) { continue; } else if ( this.model[ key ] && this.model[ key ].validate && this.model[ key ].validate(val) ) { updateObj[ key ] = val; } else { throw new $$InvalidModelFieldReferenceError(this.model.name, key); } } util._extend(args, updateObj); return this.database.update(args); } $$addOrRemove(method, field, id, obj = {}, extra = {}) { switch (method) { case 'add': method = '$createUnlessExists'; break; default: // 'remove' method = 'delete'; } // Get database, other extra options obj = util._extend(obj, extra); // Check to see that there is an existing related object return global.app.Models[ field.rel ].exists(obj).then(function(v) { // Check to see if a reference already exists <-> if (v) { let $obj = util._extend({ [ `${field.name}_id`]: id, [ `${field.rel}_id` ]: obj.id }, extra); return field.crossReferenceTable[ method ]($obj); } throw new Error(); }).catch(function() { throw new $$InvalidRelationCrossReferenceError(method, field, id, obj); }); } $$readMethods(method, field, id, args = {}) { args = util._extend(args, { [ `${field.name}_id` ]: id, values: [ `${field.rel}_id` ] }); method = [ 'filter', 'fetch' ].indexOf(method) > -1 ? method : 'filter'; return field.crossReferenceTable[ method ](args); } } class $$DatabaseConnectivityError extends Error { constructor(database) { let message; switch (database.type) { case 'mysql': message = 'Could not find MySql database ' + `${cyan(database.name || database.alias)}@` + `${database.host || '127.0.0.1'}:${database.port || 3306}`; break; default: message = `Could not find ${cyan(database.name)} in filesystem.`; } $LogProvider.error(message); super(); p.exit(1); } } export default BaseDBConnection; export {$$DatabaseConnectivityError};