Db/Db.js

const { EventEmitter } = require('events');
const mysql = require('mysql2');
const Ssh = require('../Ssh/Ssh.js');
const { isPlainObject } = require('is-plain-object');
const decorateError = require('../decorateError/decorateError.js');
const SqlBuilder = require('../SqlBuilder/SqlBuilder.js');
const DbEvent = require('../DbEvent/DbEvent.js');

const noop = () => {};

/**
 * Simple database class for mysql
 */
class Db extends EventEmitter {
	/**
	 * Specify connection details for MySQL and optionally SSH
	 * @param {Object} [config]  MySQL connection details such as host, login, password, encoding, database
	 * @see https://github.com/mysqljs/mysql#connection-options
	 * @param {Object} [sshConfig]  SSH connection details including host, port, user, privateKey
	 * @see https://github.com/mscdex/ssh2#client-methods
	 */
	constructor(config = {}, sshConfig = null) {
		super();
		const env = process.env;
		/**
		 * The config used for this instance
		 * @type {Object}
		 */
		this.config = {
			...config,
			host: config.host || env.DB_HOST || env.RDS_HOSTNAME || '127.0.0.1',
			user: config.user || env.DB_USER || env.RDS_USERNAME || 'root',
			password: config.password || env.DB_PASSWORD || env.RDS_PASSWORD || '',
			database:
				config.database || env.DB_DATABASE || env.RDS_DATABASE || undefined,
			port: config.port || env.DB_PORT || env.RDS_PORT || 3306,
			charset: config.charset || env.DB_CHARSET || env.RDS_CHARSET || 'utf8mb4',
		};
		if (sshConfig) {
			/**
			 * The Ssh instance for tunnelling
			 * @type {Ssh}
			 */
			this.ssh = new Ssh(sshConfig);
		}
		Db.instances.push(this);
	}

	/**
	 * Emit an event and associated data
	 * @param {String} type  The event name
	 * @param {Object} data  Data to send with event
	 * @return {DbEvent}
	 */
	emit(type, data = {}) {
		const evt = new DbEvent({
			type,
			subtype: null,
			target: this,
			error: null,
			data,
		});
		super.emit(type, evt);
		return evt;
	}

	/**
	 * Emit a dbError event and associated data
	 * @param {String} subtype  The name of the event that would have been called in a success case
	 * @param {Error} error  The error that was raised
	 * @param {Object} data  Data to send with event
	 * @return {DbEvent}
	 */
	emitError(subtype, error, data = {}) {
		const type = 'dbError';
		const evt = new DbEvent({
			type,
			subtype,
			target: this,
			error,
			data,
		});
		super.emit(type, evt);
		return evt;
	}

	/**
	 * Create a new QuickDb instance or return the last used one.
	 * Specify connection details for MySQL and optionally SSH
	 * @param {Object} [config]  MySQL connection details such as host, login, password, encoding, database
	 * @see https://github.com/mysqljs/mysql#connection-options
	 * @param {Object} [sshConfig]  SSH connection details including host, port, user, privateKey
	 * @see https://github.com/mscdex/ssh2#client-methods
	 * @return {Db}
	 */
	static factory(config = {}, sshConfig = null) {
		if (Db.instances.length === 0) {
			return new Db(config, sshConfig);
		}
		return Db.instances[Db.instances.length - 1];
	}

	/**
	 * Make a new connection to MySQL
	 * @param {Object} [overrides]  Additional connection params
	 * @return {Promise<Object>}  The mysql connection object
	 * @see https://github.com/mysqljs/mysql#connection-options
	 */
	async connect(overrides = {}) {
		if (this.ssh) {
			await this.ssh.tunnelTo(this);
			this.emit('sshConnect', this.ssh);
		}
		/**
		 * The mysql2 library connection object
		 * @type {Object}
		 */
		this.connection = mysql.createConnection({ ...this.config, ...overrides });
		return new Promise((resolve, reject) => {
			this.connection.connect(error => {
				if (error) {
					decorateError(error);
					this.emitError('connect', error);
					reject(error);
				} else {
					this.emit('connect', { connection: this.connection });
					resolve(this.connection);
				}
			});
		});
	}

	/**
	 * Make a new connection to MySQL if not already connected
	 */
	async connectOnce() {
		if (!this.connection) {
			await this.connect();
		}
	}

	/**
	 * Close this connection to the database
	 * @return {Promise}  Resolves when connection has been closed
	 */
	end() {
		if (this.ssh) {
			this.ssh.end();
			this.emit('sshDisconnect');
		}
		return new Promise((resolve, reject) => {
			if (this.connection) {
				const idx = Db.instances.indexOf(this);
				if (idx > -1) {
					Db.instances.splice(idx, 1);
				}
				this.connection.end(error => {
					if (error) {
						decorateError(error);
						this.emitError('disconnect', error);
						reject(error);
					} else {
						this.emit('disconnect');
						resolve();
					}
				});
			} else {
				resolve();
			}
		});
	}

	/**
	 * Destroy the connection to the database
	 * @return {Db}
	 */
	destroy() {
		if (this.ssh) {
			this.ssh.end();
			this.emit('sshDisconnect');
		}
		if (this.connection && this.connection.destroy) {
			const idx = Db.instances.indexOf(this);
			if (idx > -1) {
				Db.instances.splice(idx, 1);
			}
			this.connection.destroy();
			this.emit('disconnect');
		}
		return this;
	}

	/**
	 * Close all connections to the database
	 * @return {Promise}  Resolves when all connections have been closed
	 */
	static endAll() {
		return Promise.all(Db.instances.map(db => db.end()));
	}

	/**
	 * Destroy all connections to the database
	 * @return {Db}
	 */
	static destroyAll() {
		Db.instances.forEach(db => db.destroy());
		return Db;
	}

	/**
	 * Run a statement of any type
	 * @param {String|Object} sql  The sql to run
	 * @param {*} ...bindVars  Values to bind to the sql placeholders
	 * @returns {Promise<Object>}
	 * @property {String} query  The final SQL that was executed
	 * @property {Array} results  The result rows
	 * @property {Object[]} fields  Info about the selected fields
	 */
	async query(sql, ...bindVars) {
		await this.connectOnce();
		const options = this.bindArgs(sql, bindVars);
		return new Promise((resolve, reject) => {
			const query = this.connection.query(options, (error, results, fields) => {
				if (error) {
					decorateError(error, options);
					this.emitError('query', error);
					reject(error);
				} else {
					const result = { query, results, fields };
					const evt = this.emit('query', result);
					resolve(evt.data);
				}
			});
		});
	}

	/**
	 * Run multiple statements separated by semicolon
	 * @param {String|Object} sql  The sql to run
	 * @param {*} ...bindVars Values to bind to the sql placeholders
	 * @returns {Promise<Object>}
	 * @property {String} query  The final SQL that was executed
	 * @property {Array} results  One element for every statement in the query
	 * @property {Object[]} fields  Info about the selected fields
	 */
	async multiQuery(sql, ...bindVars) {
		await this.connectOnce();
		const options = this.bindArgs(sql, bindVars);
		options.multipleStatements = true;
		return this.query(options);
	}

	/**
	 * Return result rows for the given SELECT statement
	 * @param {String|Object} sql  The SQL to run
	 * @param {*} ...bindVars The values to bind to the each question mark or named binding
	 * @return {Promise<Object>}
	 * @property {String} query  The final SQL that was executed
	 * @property {Array} results  The result rows
	 * @property {Object[]} fields  Info about the selected fields
	 */
	async select(sql, ...bindVars) {
		await this.connectOnce();
		const options = this.bindArgs(sql, bindVars);
		return new Promise((resolve, reject) => {
			const query = this.connection.query(options, (error, results, fields) => {
				if (error) {
					decorateError(error, options);
					this.emitError('select', error);
					reject(error);
				} else {
					const result = { query, results, fields };
					const evt = this.emit('select', result);
					resolve(evt.data);
				}
			});
		});
	}

	/**
	 * Return result array as col1 => col2 pairs for the given SELECT statement
	 * @param {String|Object} sql  The SQL to run
	 * @param {*} ...bindVars The values to bind to the each question mark or named binding
	 * @return {Promise<Object>}
	 * @property {String} query  The final SQL that was executed
	 * @property {Object} results  The result object with key-value pairs
	 * @property {Object[]} fields  Info about the selected fields
	 */
	async selectHash(sql, ...bindVars) {
		const { query, results, fields } = await this.select(sql, ...bindVars);
		const key = fields[0].name;
		const val = fields[1].name;
		const hash = {};
		results.forEach(result => {
			hash[result[key]] = result[val];
		});
		return { query, results: hash, fields };
	}

	/**
	 * Return result array as col1 for the given SELECT statement
	 * @param {String|Object} sql  The SQL to run
	 * @param {*} ...bindVars The values to bind to the each question mark or named binding
	 * @return {Promise<Object>}
	 * @property {String} query  The final SQL that was executed
	 * @property {Array} results  The result list
	 * @property {Object[]} fields  Info about the selected fields
	 */
	async selectList(sql, ...bindVars) {
		const { query, results, fields } = await this.select(sql, ...bindVars);
		const name = fields[0].name;
		const list = results.map(result => result[name]);
		return {
			query,
			results: list,
			fields,
		};
	}

	/**
	 * Return records all grouped by one of the column's values
	 * @param {String} groupField  The name of the field to group by
	 * @param {String|Object} sql  The SQL to run
	 * @param {*} ...bindVars The values to bind to the each question mark or named binding
	 * @return {Promise<Object>}
	 * @property {String} query  The final SQL that was executed
	 * @property {Object} results  Result rows grouped by groupField
	 * @property {Object[]} fields  Info about the selected fields
	 */
	async selectGrouped(groupField, sql, ...bindVars) {
		const { query, results, fields } = await this.select(sql, ...bindVars);
		const groups = {};
		results.forEach(result => {
			if (!groups[result[groupField]]) {
				groups[result[groupField]] = [];
			}
			groups[result[groupField]].push(result);
		});
		return {
			query,
			results: groups,
			fields,
		};
	}

	/**
	 * Return records all indexed by one of the column's values
	 * @param {String} indexField  The name of the field to index by
	 * @param {String|Object} sql  The SQL to run
	 * @param {*} ...bindVars The values to bind to the each question mark or named binding
	 * @return {Promise<Object>}
	 * @property {String} query  The final SQL that was executed
	 * @property {Object} results  The results indexed by indexField
	 * @property {Object[]} fields  Info about the selected fields
	 */
	async selectIndexed(indexField, sql, ...bindVars) {
		const { query, results, fields } = await this.select(sql, ...bindVars);
		const hash = {};
		results.forEach(result => {
			hash[result[indexField]] = result;
		});
		return {
			query,
			results: hash,
			fields,
		};
	}

	/**
	 * Return first result row for the given SELECT statement
	 * @param {String|Object} sql  The SQL to run
	 * @param {*} ...bindVars The values to bind to the each question mark or named binding
	 * @return {Promise<Object>}
	 * @property {String} query  The final SQL that was executed
	 * @property {Object|undefined} results  The result row or undefined
	 * @property {Object[]} fields  Info about the selected fields
	 */
	async selectFirst(sql, ...bindVars) {
		const { query, results, fields } = await this.select(sql, ...bindVars);
		return {
			query,
			results: results[0],
			fields,
		};
	}

	/**
	 * Return first column value for the first result row for the given SELECT statement
	 * @param {String|Object} sql  The SQL to run
	 * @param {*} ...bindVars The values to bind to the each question mark or named binding
	 * @return {Promise<Object>}
	 * @property {String} query  The final SQL that was executed
	 * @property {*} results  The value returned in the first field of the first row
	 * @property {Object[]} fields  Info about the selected fields
	 */
	async selectValue(sql, ...bindVars) {
		const { query, results, fields } = await this.select(sql, ...bindVars);
		let value = undefined;
		if (results.length > 0) {
			const name = fields[0].name;
			value = results[0][name];
		}
		return {
			query,
			results: value,
			fields,
		};
	}

	/**
	 * Run the given SELECT statement wrapped in a SELECT EXISTS query
	 * @param {String|Object} sql  The SQL to run
	 * @param {*} ...bindVars The values to bind to the each question mark or named binding
	 * @return {Promise<Object>}
	 * @property {String} query  The final SQL that was executed
	 * @property {Boolean} results  True if any records match query
	 * @property {Object[]} fields  Info about the selected fields
	 */
	async selectExists(sql, ...bindVars) {
		const options = typeof sql === 'object' ? sql : { sql };
		options.sql = `SELECT EXISTS (${options.sql}) AS does_it_exist`;
		const { query, results, fields } = await this.select(options, ...bindVars);
		const doesItExist = results[0] ? Boolean(results[0].does_it_exist) : false;
		return {
			query,
			results: doesItExist,
			fields,
		};
	}

	/**
	 * Run the given INSERT statement
	 * @param {String|Object} sql  The SQL to run
	 * @param {*} ...bindVars The values to bind to the each question mark or named binding
	 * @return {Promise<Object>}
	 * @property {String} query  The final SQL that was executed
	 * @property {Number} insertId  The id of the last inserted record
	 */
	async insert(sql, ...bindVars) {
		await this.connectOnce();
		const options = this.bindArgs(sql, bindVars);
		return new Promise((resolve, reject) => {
			const query = this.connection.query(options, (error, results) => {
				if (error) {
					decorateError(error, options);
					this.emitError('insert', error);
					reject(error);
				} else {
					const result = {
						query,
						insertId: results.insertId,
						affectedRows: results.affectedRows,
						changedRows: results.changedRows,
					};
					const evt = this.emit('insert', result);
					resolve(evt.data);
				}
			});
		});
	}

	/**
	 * Run the given UPDATE statement
	 * @param {String|Object} sql  The SQL to run
	 * @param {*} ...bindVars The values to bind to the each question mark or named binding
	 * @return {Promise<Object>}
	 * @property {String} query  The final SQL that was executed
	 * @property {Number} affectedRows  The number of rows matching the WHERE criteria
	 * @property {Number} changedRows  The number of rows affected by the statement
	 */
	async update(sql, ...bindVars) {
		await this.connectOnce();
		const options = this.bindArgs(sql, bindVars);
		return new Promise((resolve, reject) => {
			const query = this.connection.query(options, (error, results) => {
				if (error) {
					decorateError(error, options);
					this.emitError('update', error);
					reject(error);
				} else {
					const result = {
						query,
						affectedRows: results.affectedRows,
						changedRows: results.changedRows,
					};
					const evt = this.emit('update', result);
					resolve(evt.data);
				}
			});
		});
	}

	/**
	 * Run the given DELETE statement
	 * @param {String|Object} sql  The SQL to run
	 * @param {*} ...bindVars The values to bind to the each question mark or named binding
	 * @return {Promise<Object>}
	 * @property {String} query  The final SQL that was executed
	 * @property {Number} changedRows  The number of rows affected by the statement
	 */
	async delete(sql, ...bindVars) {
		await this.connectOnce();
		const options = this.bindArgs(sql, bindVars);
		return new Promise((resolve, reject) => {
			const query = this.connection.query(options, (error, results) => {
				if (error) {
					decorateError(error, options);
					this.emitError('delete', error);
					reject(error);
				} else {
					const result = {
						query,
						affectedRows: results.affectedRows,
						changedRows: results.changedRows,
					};
					const evt = this.emit('delete', result);
					resolve(evt.data);
				}
			});
		});
	}

	/**
	 * Build a SELECT statement and return result rows
	 * @param {String} table  The name of the table
	 * @param {Array} fields  An array of field names to select
	 * @param {Object} criteria  Params to construct the WHERE clause - see SqlBuilder#buildWhere
	 * @param {String} extra  Additional raw SQL such as GROUP BY, ORDER BY, or LIMIT
	 * @return {Promise<Object>}
	 * @property {String} query  The final SQL that was executed
	 * @property {Array} results  The result rows
	 * @property {Object[]} fields  Info about the selected fields
	 * @see SqlBuilder#buildWhere
	 */
	selectFrom(table, fields = [], criteria = {}, extra = '') {
		const sql = SqlBuilder.selectFrom(table, fields, criteria, extra);
		return this.select(sql);
	}

	/**
	 * Select the record with the given column value
	 * @param {String} table  The name of the table from which to select
	 * @param {String} column  The name of the column from which to select
	 * @param {String} value  The value of the record for that column
	 * @return {Promise<Object>}
	 * @property {String} query  The final SQL that was executed
	 * @property {Object|undefined} results  The result row or undefined
	 * @property {Object[]} fields  Info about the selected fields
	 */
	selectByKey(table, column, value) {
		const sql = SqlBuilder.selectBy(table, column, value);
		return this.selectFirst(sql);
	}

	/**
	 * Select the record with the given id
	 * @param {String} table  The name of the table from which to select
	 * @param {String} id  The value of the id column
	 * @return {Promise<Object>}
	 * @property {String} query  The final SQL that was executed
	 * @property {Object|undefined} results  The result row or undefined
	 * @property {Object[]} fields  Info about the selected fields
	 */
	selectId(table, id) {
		return this.selectByKey(table, 'id', id);
	}

	/**
	 * Select the record with the given UUID
	 * @param {String} table  The name of the table from which to select
	 * @param {String} uuid  The value of the uuid column
	 * @return {Promise<Object>}
	 * @property {String} query  The final SQL that was executed
	 * @property {Object|undefined} results  The result row or undefined
	 * @property {Object[]} fields  Info about the selected fields
	 */
	selectUuid(table, uuid) {
		return this.selectByKey(table, 'uuid', uuid);
	}

	/**
	 * Find a record or add a new one
	 * @param {String} table  The name of the table from which to select
	 * @param {Object} criteria  Criteria by which to find the row
	 * @param {Object} newValues  The values to use to insert if the record doesn't exist
	 * @return {Promise<Object>}
	 * @property {String} query  The final SQL that was executed
	 * @property {Object|undefined} results  The result row or undefined
	 * @property {Object[]} fields  Info about the selected fields
	 * @property {Number} insertId  The id of the last inserted record
	 */
	selectOrCreate(table, criteria, newValues) {
		return this.selectFrom(table, ['*'], criteria).then(
			async ({ query, results, fields }) => {
				if (results.length > 0) {
					return {
						query,
						results: results[0],
						insertId: null,
						affectedRows: 0,
						changedRows: 0,
						fields,
					};
				}
				try {
					const { query, insertId, affectedRows, changedRows } =
						await this.insertInto(table, newValues);
					if (!insertId) {
						throw new Error(`Unknown error getting insertId from ${query}`);
					}
					const { results: newRows, fields } = await this.selectFrom(
						table,
						['*'],
						criteria
					);
					if (!newRows || !newRows[0]) {
						throw new Error(
							`Error fetching newly created record from ${table}`
						);
					}
					return {
						query,
						results: newRows[0],
						insertId,
						affectedRows,
						changedRows,
						fields,
					};
				} catch (e) {
					return Promise.reject(e);
				}
			},
			/* istanbul ignore next */
			err => err
		);
	}

	/**
	 * Find a record's id or add a new one
	 * @param {String} table  The name of the table from which to select
	 * @param {Object} criteria  Criteria by which to find the row
	 * @param {Object} newValues  The values to use to insert if the record doesn't exist
	 * @return {Promise<Object>}
	 * @property {String} query  The final SQL that was executed
	 * @property {Object} results  The result row or new values
	 * @property {Object[]} fields  Info about the selected fields
	 * @property {Number} insertId  The id of the last inserted record
	 */
	selectOrCreateId(table, criteria, newValues) {
		return this.selectFrom(table, ['id'], criteria).then(
			async ({ query, results, fields }) => {
				if (results.length > 0) {
					return {
						query,
						results: results[0].id,
						fields,
					};
				} else {
					return this.insertInto(table, newValues).then(
						({ query, insertId, fields }) => {
							return {
								query,
								results: insertId,
								fields,
							};
						}
					);
				}
			},
			/* istanbul ignore next */
			err => err
		);
	}

	/**
	 * Build an INSERT statement and run it
	 * @param {String} table  The name of the table
	 * @param {Object} insert  column-value pairs to insert
	 * @return {Promise<Object>}
	 * @property {String} query  The final SQL that was executed
	 * @property {Number} insertId  The id of the last inserted record
	 */
	insertInto(table, insert) {
		const sql = SqlBuilder.insertInto(table, insert);
		return this.insert(sql);
	}

	/**
	 * Run an "INSERT INTO ... ON DUPLICATE KEY UPDATE" query where
	 * if a key conflicts, update the given fields
	 * @param {String} table  The name of the table
	 * @param {Object} insert  An array with column => value pairs for insertion
	 * @param {Object} update  An array with column => value pairs for update
	 * @return {Promise<Object>}
	 * @property {Number} insertId  The id of the last inserted or updated record
	 * @property {Number} affectedRows  The number of rows matching the WHERE criteria
	 * @property {Number} changedRows  The number of rows affected by the statement
	 */
	async insertIntoOnDuplicateKeyUpdate(table, insert, update) {
		const sql = SqlBuilder.insertIntoOnDuplicateKeyUpdate(
			table,
			insert,
			update
		);
		return this.insert(sql);
	}

	/**
	 * Build an INSERT statement and run it
	 * @param {String} table  The name of the table
	 * @param {Array} rows  An Array of objects, each with column-value pairs to insert
	 * @return {Promise<Object>}
	 * @property {String} query  The final SQL that was executed
	 * @property {Number} insertId  The id of the last inserted record
	 */
	insertExtended(table, rows) {
		const sql = SqlBuilder.insertExtended(table, rows);
		return this.insert(sql);
	}

	/**
	 * Build an UPDATE statement and run it
	 * @param {String} table  The name of the table
	 * @param {Object} set  An array of column-value pairs to update
	 * @param {Object} where  Params to construct the WHERE clause - see SqlBuilder#buildWheres
	 * @return {Promise<Object>}
	 * @property {String} query  The final SQL that was executed
	 * @property {Number} affectedRows  The number of rows matching the WHERE criteria
	 * @property {Number} changedRows  The number of rows affected by the statement
	 * @see SqlBuilder#buildWheres
	 */
	updateTable(table, set, where = {}) {
		const sql = SqlBuilder.updateTable(table, set, where);
		return this.update(sql, set);
	}

	/**
	 * Construct a DELETE query and run
	 * @param {String} table  The name of the table from which to delete
	 * @param {Object} where  WHERE conditions on which to delete - see SqlBuilder#buildWheres
	 * @param {Number} limit  Limit deletion to this many records
	 * @return {Promise<Object>}
	 * @property {String} query  The final SQL that was executed
	 * @property {Number} affectedRows  The number of rows matching the WHERE criteria
	 * @property {Number} changedRows  The number of rows affected by the statement
	 * @see SqlBuilder#buildWheres
	 */
	async deleteFrom(table, where, limit = null) {
		const sql = SqlBuilder.deleteFrom(table, where, limit);
		return this.delete(sql);
	}

	/**
	 * Construct INSERT statements suitable for a backup
	 * @param {String} table  The name of the table from which to fetch records
	 * @param {Object} where  WHERE conditions on which to fetch records - see SqlBuilder#buildWheres
	 * @see SqlBuilder#buildWheres
	 * @param {Object} options  Additional options
	 * @property {Number} [limit=0]  Limit export to this many records
	 * @property {Number} [chunkSize=250]  If > 0, restrict INSERT STATEMENTS to a maximum of this many records
	 * @property {Boolean} [discardIds=false]  If true, columns selected as "id" will have a NULL value
	 * @property {Boolean} [disableForeignKeyChecks=false]  If true, add statements to disable and re-enable foreign key checks
	 * @property {Boolean} [lockTables=false]  If true, add statements to lock and unlock tables
	 * @return {Promise<Object>}
	 * @property {String} results  The exported SQL
	 * @property {String} query  The SQL that was executed to fetch records
	 * @property {Object[]} fields  Details on the fields fetched
	 * @property {Number} affectedRows  The number of rows matching the WHERE criteria
	 * @property {Number} chunks  The number of chunks of rows
	 */
	async exportAsSql(
		table,
		where = {},
		{
			limit = 0,
			chunkSize = 250,
			discardIds = false,
			truncateTable = false,
			disableForeignKeyChecks = false,
			lockTables = false,
		} = {}
	) {
		// get results
		const additional = limit > 0 ? `LIMIT ${limit}` : '';
		const {
			results: rows,
			fields,
			query,
		} = await this.selectFrom(table, [], where, additional);
		if (rows.length === 0) {
			return {
				results: '',
				fields,
				query,
				affectedRows: 0,
				chunks: 0,
			};
		}
		const sql = SqlBuilder.exportRows(table, rows, {
			fields,
			chunkSize,
			discardIds,
			truncateTable,
			disableForeignKeyChecks,
			lockTables,
		});
		return {
			results: sql,
			fields,
			query,
			affectedRows: rows.length,
			chunks: Math.ceil(rows.length / chunkSize),
		};
	}

	/**
	 * run START TRANSACTION
	 * @alias Db#beginTransaction
	 * @return {Promise<Object>}
	 */
	startTransaction() {
		this.emit('startTransaction');
		return this.query('START TRANSACTION');
	}

	/**
	 * run START TRANSACTION
	 * @alias Db#startTransaction
	 * @return {Promise<Object>}
	 */
	beginTransaction() {
		return this.startTransaction();
	}

	/**
	 * run COMMIT
	 * @return {Promise<Object>}
	 */
	commit() {
		this.emit('commit');
		return this.query('COMMIT');
	}

	/**
	 * run ROLLBACK
	 * @return {Promise<Object>}
	 */
	rollback() {
		this.emit('rollback');
		return this.query('ROLLBACK');
	}

	/**
	 * Bind an arguments to a query
	 * @param {String|Object} sql  The base SQL query
	 * @param {*} args  A value, an object with key/value paris, or an array of values to bind
	 * @return {Object}
	 * @property {String} sql  The final SQL with bound values replaced
	 * @example
	 * db.select('SELECT * FROM users WHERE id = ?', 100);
	 * db.bindArgs('SELECT * FROM users WHERE id = ?', 100); // SELECT * FROM users WHERE id = '100'
	 * db.select('SELECT * FROM users WHERE id = :id', { id: 100 });
	 * db.bindArgs('SELECT * FROM users WHERE id = :id', { id: 100 }); // SELECT * FROM users WHERE id = '100'
	 */
	bindArgs(sql, args) {
		const options = typeof sql == 'object' ? sql : { sql };
		if (options.sql === '' || typeof options.sql !== 'string') {
			throw new Error('SQL must be a non-empty empty string');
		}
		if (!Array.isArray(args)) {
			args = [];
		}
		if (Array.isArray(sql.values)) {
			args = sql.values.concat(args);
		} else if (sql.values) {
			args = [sql.values].concat(args);
		}
		options.values = undefined;
		options.bound = {};
		args.forEach((arg, i) => {
			if (
				arg instanceof Boolean ||
				arg instanceof Number ||
				arg instanceof String
			) {
				arg = arg.valueOf();
			}
			if (isPlainObject(arg)) {
				options.sql = options.sql.replace(/:([\w_]+)/g, ($0, $1) => {
					if (arg.hasOwnProperty($1) && arg[$1] !== undefined) {
						options.bound[$1] = arg[$1];
						return this.escape(arg[$1]);
					}
					return $0;
				});
			} else {
				options.bound[String(i)] = arg;
				options.sql = options.sql.replace('?', this.escape(arg));
			}
		});
		const evt = this.emit('bind', options);
		return evt.data;
	}

	/**
	 * Escape a value for use in a raw query and surround with apostrophes
	 * @param {*} value  The value to escape
	 * @return {String}
	 */
	escape(value) {
		return mysql.escape(value);
	}

	/**
	 * Escape a value for use in a raw query without apostrophes
	 * @param {String|Number|Boolean|null} value  The value to escape
	 * @return {String}
	 */
	escapeQuoteless(value) {
		let escaped = this.escape(value);
		if (escaped.slice(0, 1) === "'" && escaped.slice(-1) === "'") {
			escaped = escaped.slice(1, -1);
		}
		return escaped;
	}

	/**
	 * Get the proper escaping for a LIKE or NOT LIKE clause
	 * @param {String} infix  One of ?% or %?% or %? or ?
	 * @param {String} value  The value to search for
	 * @return {String}
	 */
	escapeLike(infix, value) {
		// use ?% for LIKE 'value%'
		// use %?% for LIKE '%value%'
		// use %? for LIKE '%value'
		// use ? for LIKE 'value'
		const quoteless = this.escapeQuoteless(value);
		switch (infix) {
			case '?':
			default:
				return `'${quoteless}'`;
			case '?%':
				return `'${quoteless}%'`;
			case '%?':
				return `'%${quoteless}'`;
			case '%?%':
				return `'%${quoteless}%'`;
		}
	}

	/**
	 * Escape an identifier such as a table or column
	 * @param identifier
	 * @return {*}
	 */
	quote(identifier) {
		if (/[`()]/.test(identifier)) {
			return identifier;
		}
		let quoted = mysql.escapeId(identifier);
		if (/`\*`$/.test(quoted)) {
			quoted = quoted.slice(0, -3) + '*';
		}
		return quoted;
	}

	/**
	 * Return an object with query methods to run on template literals
	 * (backticked strings) where interpolated strings are automatically escaped
	 * @example
	 * const db = Db.factory();
	 * const { select, selectValue } = db.tpl();
	 * const users = await select`SELECT * FROM users WHERE id IN(${userIds})`;
	 * const count = await selectValue`SELECT COUNT(*) FROM users WHERE is_active = ${isActive}`;
	 * @return {Object}  Object with query methods
	 * @property {Function} select  Same as Db#select()
	 * @property {Function} selectFirst  Same as Db#selectFirst()
	 * @property {Function} selectList  Same as Db#selectList()
	 * @property {Function} selectHash  Same as Db#selectHash()
	 * @property {Function} selectValue  Same as Db#selectValue()
	 * @property {Function} insert  Same as Db#insert()
	 * @property {Function} update  Same as Db#update()
	 * @property {Function} delete  Same as Db#delete()
	 */
	tpl() {
		if (!this._templatized) {
			function toSql(templateData, variables) {
				let s = templateData[0];
				variables.forEach((variable, i) => {
					s += mysql.escape(variable);
					s += templateData[i + 1];
				});
				return s;
			}
			const supported = [
				'select',
				'selectFirst',
				'selectList',
				'selectHash',
				'selectValue',
				'insert',
				'update',
				'delete',
			];
			const functions = {};
			supported.forEach(name => {
				functions[name] = (templateData, ...variables) => {
					return this[name](toSql(templateData, variables));
				};
			});
			this._templatized = functions;
		}
		return this._templatized;
	}

	/**
	 * Run the given handler by passing a new database instance. Three signatures:
	 *   Db.withInstance(handler)
	 *   Db.withInstance(mysqlConfig, handler)
	 *   Db.withInstance(mysqlConfig, sshConfig, handler)
	 * @example
	 *   const addresses = await Db.withInstance(async db => {
	 *   	const sql = 'SELECT * FROM animals WHERE type = "cat"';
	 *      const { results: cats } = await db.select(sql);
	 *      const homes = await findHomes(cats);
	 *      return {
	 *          homes: homes.map(home => home.address);
	 *      };
	 *   });
	 * @param {Object} [config]  The mysql connection information (or omit to read from env)
	 * @param {Object} [sshConfig]  The ssh config information (or omit to read from env)
	 * @param {Function} handler  The function to pass the Db instance to
	 * @returns {Promise<Error|*>}
	 */
	static async withInstance(config, sshConfig, handler) {
		if (typeof config === 'function') {
			handler = config;
			config = {};
			sshConfig = null;
		}
		if (typeof sshConfig === 'function') {
			handler = sshConfig;
			sshConfig = null;
		}
		const db = new Db(config, sshConfig);
		try {
			const res = await handler(db);
			db.end().then(noop, noop);
			return res;
		} catch (error) {
			db.end().then(noop, noop);
			return { error };
		}
	}
}

/**
 * A list of all the Db instances that have been created
 * @type {Array}
 */
Db.instances = [];

module.exports = Db;