timw4mail/node-query

View on GitHub
lib/QueryParser.js

Summary

Maintainability
B
5 hrs
Test Coverage
const XRegExp = require('xregexp');
const Helpers = require('./Helpers');

// --------------------------------------------------------------------------

/**
 * Internal object for parsing query fragments
 *
 * @private
 * @param {Driver} driver - The driver object for the database in use
 */
class QueryParser {
    /**
     * @constructor
     *
     * @param {Driver} driver - The driver object for the database in use
     * @return {void}
     */
    constructor (driver) {
        this.driver = driver;

        const matchPatterns = {
            function: /([a-z0-9_]+\((.*)\))/i,
            operator: /!=?|=|\+|&&?|~|\|\|?|\^|\/|<>|>=?|<=?|-|%|OR|AND|NOT|XOR/ig,
            literal: /([0-9]+)|'(.*?)'|true|false/ig
        };

        // Full pattern for identifiers
        // Making sure that literals and functions aren't matched
        matchPatterns.identifier = XRegExp(
            `(
                (?!
                    ${matchPatterns['function'].source}|
                    ${matchPatterns.literal.source}
                )
                ([a-z_-]+[0-9]*\\.?)
            )+`, 'igx');

        // Full pattern for determining ordering of the pieces
        matchPatterns.joinCombined = XRegExp(
            `${matchPatterns['function'].source}+|    # functions
            ${matchPatterns.literal.source}+|    # literal values
            ${matchPatterns.identifier.source}    # identifiers
            |(${matchPatterns.operator.source})+`, 'igx');

        this.matchPatterns = matchPatterns;
        this.identifierBlacklist = ['true', 'false', 'null'];
    }

    /**
     * Filter matched patterns
     *
     * @param {Array} array - Set of possible matches
     * @return {Array|null} - Filtered set of possible matches
     */
    filterMatches (array) {
        const output = [];

        // Return non-array matches
        if (Helpers.isNull(array)) {
            return null;
        }

        array.forEach(item => {
            output.push(item);
        });
        return output;
    }

    /**
     * Check if the string contains an operator, and if so, return the operator(s).
     * If there are no matches, return null
     *
     * @param {String} string - the string to check
     * @return {Array|null} - List of operators
     */
    hasOperator (string) {
        return this.filterMatches(string.match(this.matchPatterns.operator));
    }

    /**
     * Tokenize the sql into parts for additional processing
     *
     * @param {String} sql - Join sql to parse
     * @return {Object} - Join condition components
     */
    parseJoin (sql) {
        const matches = {};
        const output = {
            functions: [],
            identifiers: [],
            operators: [],
            literals: []
        };

        // Get clause components
        matches.functions = sql.match(new RegExp(this.matchPatterns['function'].source, 'ig'));
        matches.identifiers = sql.match(this.matchPatterns.identifier);
        matches.operators = sql.match(this.matchPatterns.operator);
        matches.literals = sql.match(this.matchPatterns.literal);

        // Get everything at once for ordering
        matches.combined = sql.match(this.matchPatterns.joinCombined);

        // Flatten the matches to increase relevance
        Object.keys(matches).forEach(key => {
            output[key] = this.filterMatches(matches[key]);
        });

        return output;
    }

    /**
     * Return the output of the parsing of the join condition
     *
     * @param {String} condition - The join condition to evaluate
     * @return {String} - The parsed/escaped join condition
     */
    compileJoin (condition) {
        const parts = this.parseJoin(condition);

        // Quote the identifiers
        parts.combined.forEach((part, i) => {
            if (parts.identifiers.indexOf(part) !== -1 && !Helpers.isNumber(part)) {
                parts.combined[i] = this.driver.quoteIdentifiers(part);
            }
        });

        return parts.combined.join(' ');
    }

    /**
     * Parse a where clause to separate functions from values
     *
     * @param {Driver} driver - The current db driver
     * @param {State} state - Query Builder state object
     * @return {String} - The parsed/escaped where condition
     */
    parseWhere (driver, state) {
        const whereMap = state.whereMap;
        let    whereValues = state.rawWhereValues;

        const outputMap = [];
        const outputValues = [];

        Object.keys(whereMap).forEach(key => {
            // Combine fields, operators, functions and values into a full clause
            // to have a common starting flow
            let fullClause = '';

            // Add an explicit = sign where one is inferred
            if (!this.hasOperator(key)) {
                fullClause = `${key} = ${whereMap[key]}`;
            } else if (whereMap[key] === key) {
                fullClause = key;
            } else {
                fullClause = `${key} ${whereMap[key]}`;
            }

            // Separate the clause into separate pieces
            const parts = this.parseJoin(fullClause);

            // Filter explicit literals from lists of matches
            if (whereValues.indexOf(whereMap[key]) !== -1) {
                const value = whereMap[key];
                const identIndex = parts.identifiers.indexOf(value);
                const litIndex = (Helpers.isArray(parts.literals)) ? parts.literals.indexOf(value) : -1;
                const combIndex = parts.combined.indexOf(value);
                const funcIndex = (Helpers.isArray(parts.functions)) ? parts.functions.indexOf(value) : -1;
                let inOutputArray = outputValues.includes(value);

                // Remove the identifier in question,
                // and add to the output values array
                if (identIndex !== -1) {
                    parts.identifiers.splice(identIndex, 1);

                    if (!inOutputArray) {
                        outputValues.push(value);
                        inOutputArray = true;
                    }
                }

                // Remove the value from the literals list
                // so it is not added twice
                if (litIndex !== -1) {
                    parts.literals.splice(litIndex, 1);

                    if (!inOutputArray) {
                        outputValues.push(value);
                        inOutputArray = true;
                    }
                }

                // Remove the value from the combined list
                // and replace it with a placeholder
                if (combIndex !== -1 && funcIndex === -1) {
                    // Make sure to skip functions when replacing values
                    parts.combined[combIndex] = '?';
                }
            }

            // Filter false positive identifiers
            parts.identifiers = parts.identifiers.filter(item => {
                const isInCombinedMatches = parts.combined.indexOf(item) !== -1;
                const isNotInBlackList = this.identifierBlacklist.indexOf(item.toLowerCase()) === -1;

                return isInCombinedMatches && isNotInBlackList;
            }, this);

            // Quote identifiers
            if (Helpers.isArray(parts.identifiers)) {
                parts.identifiers.forEach(ident => {
                    const index = parts.combined.indexOf(ident);
                    if (index !== -1) {
                        parts.combined[index] = driver.quoteIdentifiers(ident);
                    }
                });
            }

            // Replace each literal with a placeholder in the map
            // and add the literal to the values,
            // This should only apply to literal values that are not
            // explicitly mapped to values, but have to be parsed from
            // a where condition,
            if (Helpers.isArray(parts.literals)) {
                parts.literals.forEach(lit => {
                    const litIndex = parts.combined.indexOf(lit);

                    if (litIndex !== -1) {
                        parts.combined[litIndex] = '?';
                        outputValues.push(lit);
                    }
                });
            }

            outputMap.push(parts.combined.join(' '));
        });

        state.rawWhereValues = [];
        state.whereValues = state.whereValues.concat(outputValues);
        state.whereMap = outputMap;

        return state;
    }
}

module.exports = QueryParser;