Home Identifier Source Repository

src/databases/BaseDBConnection.js

/**
 * @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};