sharp-db
Version:
Classes for running SQL and building select queries for MySQL in Node
315 lines (290 loc) • 7.95 kB
JavaScript
const capitalize = require('lodash/capitalize');
const camelCase = require('lodash/camelCase');
const upperFirst = require('lodash/upperFirst');
/**
* Parse SQL and populate onto a Select query object
*/
class Parser {
/**
* Create a new instance
* @param {Select} query A Select object on which to build parsed conditions
*/
constructor(query) {
this.query = query;
}
/**
* Strip single-line and multi-line comment blocks
* @param {String} sql The SQL string
* @return {String}
* @private
*/
_stripComments(sql) {
// multiline comments
sql = sql.replace(/\/\*[\s\S]*?\*\//g, '');
// single line comments -- dashes
sql = sql.replace(/--([\r\n]|\s+[^\r\n]+[\r\n])/g, '');
// single line comments #hashes
sql = sql.replace(/#[^\r\n]+[\r\n]/g, '');
return sql;
}
/**
* Before splitting into SQL clauses, extract some regex-able subqueries
* @param {String} sql The unparsed sql string
* @return {Object} An array with new sql and subqueries
* @private
*/
_extractSubqueries(sql) {
const subqueries = {};
let i = 0;
const extractor = $0 => {
const placeholder = `~~SUBQUERY_${i++}~~`;
subqueries[placeholder] = $0;
return placeholder;
};
// subselect in FROM clause
sql = sql.replace(/\(\s*SELECT\s+.+\)\s+AS\s+[^\s,]+/, extractor);
// IF() in FROM clause
sql = sql.replace(/\bIF\s*\(.+\)\s+AS\s+[^\s,]+/, extractor);
// IN (SELECT *) in JOINs, WHERE or HAVING
sql = sql.replace(/\bIN\s*\(\s*SELECT\s.+?\)/, extractor);
return { sql, subqueries };
}
/**
* Inject column subqueries back into this object
* @param {Object} subqueries The lookup of extracted subqueries
* @private
*/
_injectSubqueries(subqueries) {
const replacer = $0 => {
return subqueries[$0] || $0;
};
const mapper = clause => {
return clause.replace(/~~SUBQUERY_\d+~~/g, replacer);
};
this.query._columns = this.query._columns.map(replacer);
this.query._joins = this.query._joins.map(mapper);
this.query._wheres = this.query._wheres.map(mapper);
this.query._havings = this.query._havings.map(mapper);
}
/**
* Split SQL into clauses (used by ::parse())
* @param {String} sql The SQL to split
* @return {String[]}
* @private
*/
_split(sql) {
const splitter =
/(?:^|\s)(SELECT|FROM|(?:INNER|LEFT\s+OUTER|RIGHT\s+OUTER|LEFT|RIGHT|CROSS|FULL|FULL\s+OUTER)\s+JOIN|WHERE|GROUP BY|HAVING|ORDER BY|LIMIT|OFFSET)\b/i;
return sql.split(splitter);
}
/**
* Get a QuickSelect object representing the given SQL SELECT statement
* @param {String} rawSql The raw SQL for the SELECT statement
* @return {Boolean}
*/
parse(rawSql) {
const stripped = this._stripComments(rawSql);
const { sql, subqueries } = this._extractSubqueries(stripped);
const expressions = this._split(sql);
let i = 1;
while (i < expressions.length) {
const rawKeyword = expressions[i++].trim();
const keyword = upperFirst(camelCase(rawKeyword));
const clause = expressions[i++].trim();
const handler = `_handle${keyword}`;
this[handler](clause);
}
this._injectSubqueries(subqueries);
return true;
}
/**
* Handle SQL_CALC_FOUND_ROWS and column names
* @param {String} clause The clause after the SELECT
* @private
*/
_handleSelect(clause) {
let columns = clause.split(/s*,s*/);
// now handle parenthesis expressions that contain commas
let buffer = '';
columns.forEach((column, i) => {
if (i === 0) {
const optionRegex = /^(SQL_CALC_FOUND_ROWS)\s+/i;
const match = column.match(optionRegex);
if (match) {
this.query.option(match[1]);
column = column.replace(optionRegex, '');
}
}
if (buffer.length) {
// we are in the middle of an expression containing parenthesis
if (column.indexOf(')') > 0) {
// we have an end parenthesis
buffer += column;
this.query.column(buffer.trim());
buffer = '';
} else {
buffer += column + ',';
}
} else if (column.match(/\([^)]+$/)) {
buffer = column + ',';
} else {
this.query.column(column.trim());
}
});
}
/**
* Handle table names
* @param {String} clause The clause after the FROM
* @private
*/
_handleFrom(clause) {
const tables = clause.split(/\s*,\s*/);
tables.forEach(table => this.query.table(table));
}
/**
* Handle INNER JOIN statements
* @param {String} clause The clause after the INNER JOIN
* @private
*/
_handleInnerJoin(clause) {
this.query.innerJoin(clause);
}
/**
* Handle LEFT JOIN statements
* @param {String} clause The clause after the LEFT JOIN
* @private
*/
_handleLeftJoin(clause) {
this.query.leftJoin(clause);
}
/**
* Handle LEFT OUTER JOIN statements
* @param {String} clause The clause after the LEFT OUTER JOIN
* @private
*/
_handleLeftOuterJoin(clause) {
this.query.leftOuterJoin(clause);
}
/**
* Handle RIGHT JOIN statements
* @param {String} clause The clause after the RIGHT JOIN
* @private
*/
_handleRightJoin(clause) {
this.query.rightJoin(clause);
}
/**
* Handle RIGHT OUTER JOIN statements
* @param {String} clause The clause after the RIGHT OUTER JOIN
* @private
*/
_handleRightOuterJoin(clause) {
this.query.rightOuterJoin(clause);
}
/**
* Handle CROSS JOIN statements
* @param {String} clause The clause after the CROSS JOIN
* @private
*/
_handleCrossJoin(clause) {
this.query.crossJoin(clause);
}
/**
* Handle FULL JOIN statements
* @param {String} clause The clause after the FULL JOIN
* @private
*/
_handleFullJoin(clause) {
this.query.fullJoin(clause);
}
/**
* Handle FULL OUTER JOIN statements
* @param {String} clause The clause after the FULL OUTER JOIN
* @private
*/
_handleFullOuterJoin(clause) {
this.query.fullOuterJoin(clause);
}
/**
* Handle WHERE conditions
* @param {String} clause All the conditions after WHERE
* @private
*/
_handleWhere(clause) {
if (/^(1|'1'|true)$/i.test(clause)) {
this.query._wheres.push(clause);
} else {
this._handleConditions('where', clause);
}
}
/**
* Handle HAVING statements
* @param {String} clause All the conditions after HAVING
* @private
*/
_handleHaving(clause) {
this._handleConditions('having', clause);
}
/**
* Build a conditions list
* @param {String} type Either WHERE or HAVING
* @param {String} clause The expressions following the type keyword
* @private
*/
_handleConditions(type, clause) {
const andGroups = clause.split(/\bAND\b/i);
andGroups.forEach(andGroup => {
const orPieces = andGroup.split(/\bOR\b/i).map(str => str.trim());
if (orPieces.length === 1) {
// no OR operators
const fn = type; // either where or having
this.query[fn](orPieces[0]);
} else {
// some OR operators
const orFn = 'or' + capitalize(type); // either orWhere or orHaving
this.query[orFn](orPieces);
}
});
}
/**
* Handle GROUP BY statements
* @param {String} clause The clauses after the GROUP BY
* @private
*/
_handleGroupBy(clause) {
const columns = clause.split(/\s*,\s*/);
columns.forEach(column => this.query.groupBy(column));
}
/**
* Handle ORDER BY statements
* @param {String} clause The clause after the ORDER BY
* @private
*/
_handleOrderBy(clause) {
const columns = clause.split(/\s*,\s*/);
columns.forEach(column => this.query.orderBy(column));
}
/**
* Handle LIMIT statements including "LIMIT #" and "LIMIT #, #"
* @param {String} clause The clause after the LIMIT
* @private
*/
_handleLimit(clause) {
const offsetLimit = clause.match(/^(\d+|\?|:\w+)\s*,\s*(\d+|\?|:\w+)$/);
if (offsetLimit) {
this.query.offset(offsetLimit[1]);
this.query.limit(offsetLimit[2]);
} else {
this.query.limit(clause);
}
}
/**
* Handle OFFSET statements
* @param {String} clause The number after the OFFSET
* @private
*/
_handleOffset(clause) {
this.query.offset(clause);
}
}
module.exports = Parser;