Sql.js

// @author Chris Demmings - https://demmings.github.io/
//  *** DEBUG START ***
//  Remove comments for testing in NODE
export { Sql, gsSQL, GasSql, BindData, TableExtract };
import { Table } from './Table.js';
import { TableData } from './TableData.js';
import { SqlParse } from './SimpleParser.js';
import { SelectTables } from './Views.js';

class Logger {
    static log(msg) {
        console.log(msg);
    }
}
//  *** DEBUG END ***/
/**
 * Query any sheet range using standard SQL SELECT syntax.
 * @example
 * gsSQL("select * from expenses where type = ?1", "expenses", A1:B, true, "travel")
 * 
 * @param {String} statement - SQL string 
 * @param {...any} parms - "table name",  SheetRange, [..."table name", SheetRange], OutputTitles (true/false), [...Bind Variable] 
 * @returns {any[][]} - Double array of selected data.  First index ROW, Second index COLUMN.
 * @customfunction
 */
function gsSQL(statement, ...parms) {     //  skipcq: JS-0128
    return GasSql.execute(statement, parms);
}

/**
 * @classdesc 
 * Top level class used by Google Sheets custom function to process SELECT and return table data. 
 */
class GasSql {
    /**
     * Run SELECT command statement and returns data in a table format (double array).
     * The appropriate functions are selected to be run based on the format of the command line parameters.
     * @param {String} statement 
     * @param {any[]} parms 
     * @returns {any[][]}
     */
    static execute(statement, parms) {
        if (parms.length === 0 || (parms.length > 0 && (Array.isArray(parms[0]) || parms[0] === ''))) {
            return GasSql.executeSqlv1(statement, parms);
        }
        else if (parms.length > 0 && typeof parms[0] === 'string') {
            return GasSql.executeSqlv2(statement, parms);
        }
        else {
            throw new Error("Invalid gsSQL() parameter list.");
        }
    }

    /**
     * Processes SQL SELECT using original command line syntax.  This syntax does not update automatically if the
     * data changes, so is not recommended anymore.
     * @param {String} statement 
     * @param {any[]} parms 
     * @returns {any[][]}
     */
    static executeSqlv1(statement, parms) {
        const sqlCmd = new Sql();
        let columnTitle = true;
        const bindings = [];

        //  If first item of parms is an array, the parms are assumed to be:
        // @param {any[][]} tableArr - {{"tableName", "sheetRange", cacheSeconds, hasColumnTitle}; {"name","range",cache,true};...}"
        // @param {Boolean} columnTitle - TRUE will add column title to output (default=TRUE)
        // @param {...any} bindings - Bind variables to match '?' in SQL statement.
        const tableArr = parms.length > 0 ? parms[0] : [];

        const tableList = GasSql.parseTableSettings(tableArr, statement);
        Logger.log(`gsSQL: tableList=${tableList}.  Statement=${statement}. List Len=${tableList.length}`);

        for (const tableDef of tableList) {
            sqlCmd.addTableData(tableDef[0], tableDef[1], tableDef[2], tableDef[3]);
        }
        columnTitle = parms.length > 1 ? parms[1] : true;

        for (let i = 2; i < parms.length; i++) {
            bindings.push(parms[i]);
        }

        sqlCmd.enableColumnTitle(columnTitle);

        for (const bind of bindings) {
            sqlCmd.addBindParameter(bind);
        }

        return sqlCmd.execute(statement);
    }

    /**
     * Process SQL SELECT using new command line syntax.  Using this syntax ensures that the select data is refreshed
     * if any of the selected table data changes - and is therefore the recommended usage.
     * @param {String} statement 
     * @param {any[]} parms 
     * @returns {any[][]}
     */
    static executeSqlv2(statement, parms) {
        const sqlCmd = new Sql();
        let columnTitle = true;
        const bindings = [];

        //  We expect:  "tableName", tableData[], ...["tableName", tableData[]], includeColumnOutput, ...bindings
        let i = 0;
        while (i + 1 < parms.length && typeof parms[i] !== 'boolean') {
            Logger.log(`Add Table: ${parms[i]}. Items=${parms[i + 1].length}`);
            sqlCmd.addTableData(parms[i], parms[i + 1], 0, true);
            i += 2;
        }
        if (i < parms.length && typeof parms[i] === 'boolean') {
            columnTitle = parms[i];
            i++
        }
        Logger.log(`Column Titles: ${columnTitle}`);
        while (i < parms.length) {
            Logger.log(`Add BIND Variable: ${parms[i]}`);
            bindings.push(parms[i]);
            i++
        }

        sqlCmd.enableColumnTitle(columnTitle);

        for (const bind of bindings) {
            sqlCmd.addBindParameter(bind);
        }

        return sqlCmd.execute(statement);
    }

    /**
     * 
     * @param {any[][]} tableArr - Referenced Table list.  This is normally the second parameter in gsSQL() custom function.  
     * It is a double array with first index for TABLE, and the second index are settings in the table. 
     * The setting index for each table is as follows:
     * * 0 - Table Name.
     * * 1 - Sheet Range.
     * * 2 - Cache seconds.
     * * 3 - First row contains title (for field name)
     * @param {String} statement - SQL SELECT statement.  If no data specified in 'tableArr', the SELECT is 
     * parsed and each referenced table is assumed to be a TAB name on the sheet.
     * @param {Boolean} randomOrder - Returned table list is randomized.
     * @returns {any[][]} - Data from 'tableArr' PLUS any extracted tables referenced from SELECT statement.
     * It is a double array with first index for TABLE, and the second index are settings in the table. 
     * The setting index for each table is as follows:
     * * 0 - Table Name.
     * * 1 - Sheet Range.
     * * 2 - Cache seconds.
     * * 3 - First row contains title (for field name)
     */
    static parseTableSettings(tableArr, statement = "", randomOrder = true) {
        let tableList = [];
        let referencedTableSettings = tableArr;

        //  Get table names from the SELECT statement when no table range info is given.
        if (tableArr.length === 0 && statement !== "") {
            referencedTableSettings = TableExtract.getReferencedTableNames(statement);
        }

        if (referencedTableSettings.length === 0) {
            throw new Error('Missing table definition {{"name","range",cache};{...}}');
        }

        Logger.log(`tableArr = ${referencedTableSettings}`);
        for (/** @type {any[]} */ const table of referencedTableSettings) {
            if (table.length === 1)
                table.push(table[0]);   // if NO RANGE, assumes table name is sheet name.
            if (table.length === 2)
                table.push(60);         //  default 0 second cache.
            if (table.length === 3)
                table.push(true);       //  default HAS column title row.
            if (table[1] === "")
                table[1] = table[0];    //  If empty range, assumes TABLE NAME is the SHEET NAME and loads entire sheet.
            if (table.length !== 4)
                throw new Error("Invalid table definition [name,range,cache,hasTitle]");

            tableList.push(table);
        }

        //  If called at the same time, loading similar tables in similar order - all processes
        //  just wait for table - but if loaded in different order, each process could be loading something.
        if (randomOrder)
            tableList = tableList.sort(() => Math.random() - 0.5);

        return tableList;
    }
}

/** 
 * @classdesc
 * Perform SQL SELECT using this class.
 */
class Sql {
    constructor() {
        /** @property {Map<String,Table>} - Map of referenced tables.*/
        this.tables = new Map();
        /** @property {Boolean} - Are column tables to be ouptout? */
        this.columnTitle = false;
        /** @property {BindData} - List of BIND data linked to '?' in statement. */
        this.bindData = new BindData();
        /** @property {String} - derived table name to output in column title replacing source table name. */
        this.columnTableNameReplacement = null;
    }

    /**
    * Parse SQL SELECT statement, performs SQL query and returns data ready for custom function return.
    * <br>Execute() can be called multiple times for different SELECT statements, provided that all required
    * table data was loaded in the constructor.  
    * Methods that would be used PRIOR to execute are:
    * <br>**enableColumnTitle()** - turn on/off column title in output
    * <br>**addBindParameter()** - If bind data is needed in select.  e.g. "select * from table where id = ?"
    * <br>**addTableData()** - At least ONE table needs to be added prior to execute. This tells **execute** where to find the data.
    * <br>**Example SELECT and RETURN Data**
    * ```js
    *   let stmt = "SELECT books.id, books.title, books.author_id " +
    *        "FROM books " +
    *        "WHERE books.author_id IN ('11','12') " +
    *        "ORDER BY books.title";
    *
    *    let data = new Sql()
    *        .addTableData("books", this.bookTable())
    *        .enableColumnTitle(true)
    *        .execute(stmt);
    * 
    *    Logger.log(data);
    * 
    * [["books.id", "books.title", "books.author_id"],
    *    ["4", "Dream Your Life", "11"],
    *    ["8", "My Last Book", "11"],
    *    ["5", "Oranges", "12"],
    *    ["1", "Time to Grow Up!", "11"]]
    * ```
    * @param {any} statement - SELECT statement as STRING or AST of SELECT statement.
    * @returns {any[][]} - Double array where first index is ROW and second index is COLUMN.
    */
    execute(statement) {
        this.ast = (typeof statement === 'string') ? SqlParse.sql2ast(statement) : statement;

        //  "SELECT * from (select a,b,c from table) as derivedtable"
        //  Sub query data is loaded and given the name 'derivedtable' (using ALIAS from AS)
        //  The AST.FROM is updated from the sub-query to the new derived table name. 
        this.selectFromSubQuery();

        //  A JOIN table can a sub-query.  When this is the case, the sub-query SELECT is
        //  evaluated and the return data is given the ALIAS (as) name.  The AST is then
        //  updated to use the new table.
        this.selectJoinSubQuery();

        TableAlias.setTableAlias(this.tables, this.ast);
        Sql.loadSchema(this.tables);

        let selectResults = this.select(this.ast);

        //  Apply SET rules to SELECTs (UNION, UNION ALL, EXCEPT, INTERSECT)
        selectResults = this.selectSet(selectResults, this.ast);

        return selectResults;
    }

    /**
     * Modifies AST when FROM is a sub-query rather than a table name.
     */
    selectFromSubQuery() {
        if (typeof this.ast.FROM !== 'undefined' && typeof this.ast.FROM.SELECT !== 'undefined') {
            const data = new Sql()
                .setTables(this.tables)
                .enableColumnTitle(true)
                .replaceColumnTableNameWith(this.ast.FROM.table)
                .execute(this.ast.FROM);

            if (typeof this.ast.FROM.table !== 'undefined') {
                this.addTableData(this.ast.FROM.table, data);
            }

            if (this.ast.FROM.table === '') {
                throw new Error("Every derived table must have its own alias");
            }

            this.ast.FROM.as = '';
        }
    }

    /**
     * Checks if the JOINed table is a sub-query.  
     * The sub-query is evaluated and assigned the alias name.
     * The AST is adjusted to use the new JOIN TABLE.
     * @returns {void}
     */
    selectJoinSubQuery() {
        if (typeof this.ast.JOIN === 'undefined')
            return;

        for (const joinAst of this.ast.JOIN) {
            if (typeof joinAst.table !== 'string') {
                const data = new Sql()
                    .setTables(this.tables)
                    .enableColumnTitle(true)
                    .replaceColumnTableNameWith(joinAst.as)
                    .execute(joinAst.table);

                if (typeof joinAst.as !== 'undefined') {
                    this.addTableData(joinAst.as, data);
                }

                if (joinAst.as === '') {
                    throw new Error("Every derived table must have its own alias");
                }
                joinAst.table = joinAst.as;
                joinAst.as = '';
            }
        }
    }

    /**
     * Apply set rules to each select result.
     * @param {any[][]} leftTableData 
     * @param {Object} unionAst 
     * @returns {any[][]}
     */
    selectSet(leftTableData, unionAst) {
        if (! SqlSets.isSqlSet(unionAst)) {
            return leftTableData;
        }

        //  If the column titles are in the data, we need to remove and add back in later.
        let columnTitles = [];
        if (this.areColumnTitlesOutput() && leftTableData.length > 0) {
            columnTitles = leftTableData.shift();
        }

        this.enableColumnTitle(false);
        let ast = unionAst;
        while (SqlSets.isSqlSet(ast)) {
            const setType = SqlSets.getSetType(ast);
            ast = ast[setType][0];
            const rightTableData = this.select(ast);
            leftTableData = SqlSets.applySet(setType, leftTableData, rightTableData);
        }

        if (columnTitles.length > 0) {
            leftTableData.unshift(columnTitles);
        }

        return leftTableData;
    }

    /**
     * Load SELECT data and return in double array.
     * @param {Object} selectAst - Abstract Syntax Tree of SELECT
     * @returns {any[][]} - double array useable by Google Sheet in custom function return value.
     * * First row of data will be column name if column title output was requested.
     * * First Array Index - ROW
     * * Second Array Index - COLUMN
     */
    select(selectAst) {
        let ast = selectAst;

        Sql.errorCheckSelectAST(ast);

        //  Manipulate AST to add GROUP BY if DISTINCT keyword.
        ast = Sql.distinctField(ast);

        //  Manipulate AST add pivot fields.
        ast = Pivot.pivotField(ast, this.tables, this.bindData);

        const view = new SelectTables(ast, this.tables, this.bindData);

        //  JOIN tables to create a derived table.
        view.join(ast);                 // skipcq: JS-D008

        view.updateSelectedFields(ast);

        //  Get the record ID's of all records matching WHERE condition.
        const recordIDs = view.whereCondition(ast);

        //  Get selected data records.
        let viewTableData = view.getViewData(recordIDs);

        //  Compress the data.
        viewTableData = view.groupBy(ast, viewTableData);

        //  Sort our selected data.
        view.orderBy(ast, viewTableData);

        //  Remove fields referenced but not included in SELECT field list.
        view.removeTempColumns(viewTableData);

        //  Limit rows returned.
        viewTableData = SelectTables.limit(ast, viewTableData);

        //  Add column titles
        viewTableData = this.addColumnTitles(viewTableData, view);

        //  Deal with empty dataset.
        viewTableData = Sql.cleanUp(viewTableData);

        return viewTableData;
    }

    /**
     * Add data for each referenced table in SELECT, before EXECUTE().
     * @param {String} tableName - Name of table referenced in SELECT.
     * @param {any} tableData - Either double array or a named range.
     * @param {Number} cacheSeconds - How long should loaded data be cached (default=0)
     * @param {Boolean} hasColumnTitle - Is first data row the column title?
     * @returns {Sql}
     */
    addTableData(tableName, tableData, cacheSeconds = 0, hasColumnTitle = true) {
        let tableInfo = null;

        if (Array.isArray(tableData)) {
            tableInfo = new Table(tableName)
                .setHasColumnTitle(hasColumnTitle)
                .loadArrayData(tableData);
        }
        else {
            tableInfo = new Table(tableName)
                .setHasColumnTitle(hasColumnTitle)
                .loadNamedRangeData(tableData, cacheSeconds);
        }

        this.tables.set(tableName.toUpperCase(), tableInfo);

        return this;
    }

    /**
     * Copies the data from an external tableMap to this instance.  
     * It copies a reference to outside array data only.  
     * The schema would need to be re-loaded.
     * @param {Map<String,Table>} tableMap 
     */
    copyTableData(tableMap) {
        // @ts-ignore
        for (const tableName of tableMap.keys()) {
            const tableInfo = tableMap.get(tableName);
            this.addTableData(tableName, tableInfo.tableData);
        }

        return this;
    }

    /**
     * Include column headers in return data.
     * @param {Boolean} value - true will return column names in first row of return data.
     * @returns {Sql}
     */
    enableColumnTitle(value) {
        this.columnTitle = value;
        return this;
    }

    /**
     * Derived table data that requires the ALIAS table name in column title.
     * @param {String} replacementTableName - derived table name to replace original table name.  To disable, set to null.
     * @returns {Sql}
     */
    replaceColumnTableNameWith(replacementTableName) {
        this.columnTableNameReplacement = replacementTableName;
        return this;
    }

    /**
     * Query if this instance of Sql() will generate column titles.
     * @returns {Boolean}
     */
    areColumnTitlesOutput() {
        return this.columnTitle;
    }

    /**
     * Add a bind data value.  Must be added in order.  If bind data is a named range, use addBindNamedRangeParameter().
     * @param {any} value - literal data. 
     * @returns {Sql}
     */
    addBindParameter(value) {
        this.bindData.add(value);
        return this;
    }

    /**
     * List of bind data added so far.
     * @returns {any[]}
     */
    getBindData() {
        return this.bindData.getBindDataList();
    }

    /**
     * The BIND data is a sheet named range that will be read and used for bind data.
     * @param {String} value - Sheets Named Range for SINGLE CELL only.
     * @returns {Sql}
     */
    addBindNamedRangeParameter(value) {
        const namedValue = TableData.getValueCached(value, 30);
        this.bindData.add(namedValue);
        Logger.log(`BIND=${value} = ${namedValue}`);
        return this;
    }

    /**
     * Set all bind data at once using array.
     * @param {BindData} value - Bind data.
     * @returns {Sql}
     */
    setBindValues(value) {
        this.bindData = value;
        return this;
    }

    /**
     * Clears existing BIND data so Sql() instance can be used again with new bind parameters.
     * @returns {Sql}
     */
    clearBindParameters() {
        this.bindData.clear();
        return this;
    }

    /**
     * Updates 'tables' with table column information.
     * @param {Map<String,Table>} tables 
     */
    static loadSchema(tables) {
        // @ts-ignore
        for (const table of tables.keys()) {
            const tableInfo = tables.get(table.toUpperCase());
            tableInfo.loadSchema();
        }
    }

    /**
     * Sets all tables referenced SELECT.
     * @param {Map<String,Table>} mapOfTables - Map of referenced tables indexed by TABLE name.
     */
    setTables(mapOfTables) {
        this.tables = mapOfTables;
        return this;
    }

    /**
     * Returns a map of all tables configured for this SELECT.
     * @returns {Map<String,Table>} - Map of referenced tables indexed by TABLE name.
     */
    getTables() {
        return this.tables;
    }

    /**
     * Basic sanity check of AST for a SELECT statement.
     * @param {object} ast 
     */
    static errorCheckSelectAST(ast) {
        if (typeof ast.SELECT === 'undefined') {
            throw new Error("Only SELECT statements are supported.");
        }

        if (typeof ast.FROM === 'undefined') {
            throw new Error("Missing keyword FROM");
        }
    }

    /**
     * If 'GROUP BY' is not set and 'DISTINCT' column is specified, update AST to add 'GROUP BY'.
     * @param {Object} ast - Abstract Syntax Tree for SELECT.
     * @returns {Object} - Updated AST to include GROUP BY when DISTINCT field used.
     */
    static distinctField(ast) {
        const astFields = ast.SELECT;

        if (astFields.length === 0)
            return ast;

        const firstField = astFields[0].name.toUpperCase();
        if (firstField.startsWith("DISTINCT")) {
            astFields[0].name = firstField.replace("DISTINCT", "").trim();

            if (typeof ast['GROUP BY'] === 'undefined') {
                const groupBy = [];

                for (const astItem of astFields) {
                    groupBy.push({ name: astItem.name, as: '' });
                }

                ast["GROUP BY"] = groupBy;
            }
        }

        return ast;
    }

    /**
     * Add column titles to data if needed.
     * @param {any[][]} viewTableData 
     * @param {SelectTables} view 
     * @returns {any[][]}
     */
    addColumnTitles(viewTableData, view) {
        if (this.columnTitle) {
            viewTableData.unshift(view.getColumnTitles(this.columnTableNameReplacement));
        }

        return viewTableData;
    }

    /**
     * If no data and no titles, create empty double array so sheets function does not have an error.
     * @param {any[][]} viewTableData 
     * @returns {any[][]}
     */
    static cleanUp(viewTableData) {
        if (viewTableData.length === 0) {
            viewTableData.push([""]);
        }

        if (viewTableData.length === 1 && viewTableData[0].length === 0) {
            viewTableData[0] = [""];
        }

        return viewTableData;
    }
}

/**
 * @classdesc Deals with the table ALIAS inside select AST.
 */
class TableAlias {
    /**
     * Updates 'tables' with associated table ALIAS name found in ast.
     * @param {Map<String,Table>} tables 
     * @param {Object} ast 
     */
    static setTableAlias(tables, ast) {
        // @ts-ignore
        for (const table of tables.keys()) {
            const tableAlias = TableAlias.getTableAlias(table, ast);
            const tableInfo = tables.get(table.toUpperCase());
            tableInfo.setTableAlias(tableAlias);
        }
    }

    /**
    * Find table alias name (if any) for input actual table name.
    * @param {String} tableName - Actual table name.
    * @param {Object} ast - Abstract Syntax Tree for SQL.
    * @returns {String} - Table alias.  Empty string if not found.
    */
    static getTableAlias(tableName, ast) {
        let tableAlias = "";
        const ucTableName = tableName.toUpperCase();

        tableAlias = TableAlias.getTableAliasFromJoin(tableAlias, ucTableName, ast);
        tableAlias = TableAlias.getTableAliasUnion(tableAlias, ucTableName, ast);
        tableAlias = TableAlias.getTableAliasWhereIn(tableAlias, ucTableName, ast);
        tableAlias = TableAlias.getTableAliasWhereTerms(tableAlias, ucTableName, ast);

        return tableAlias;
    }

    /**
     * Searches the FROM and JOIN components of a SELECT to find the table alias.
     * @param {String} tableAlias - Default alias name
     * @param {String} tableName - table name to search for.
     * @param {Object} ast - Abstract Syntax Tree to search
     * @returns {String} - Table alias name.
     */
    static getTableAliasFromJoin(tableAlias, tableName, ast) {
        const astTableBlocks = ['FROM', 'JOIN'];
        let aliasNameFound = tableAlias;

        let i = 0;
        while (aliasNameFound === "" && i < astTableBlocks.length) {
            aliasNameFound = TableAlias.locateAstTableAlias(tableName, ast, astTableBlocks[i]);
            i++;
        }

        return aliasNameFound;
    }

    /**
     * Search a property of AST for table alias name.
     * @param {String} tableName - Table name to find in AST.
     * @param {Object} ast - AST of SELECT.
     * @param {String} astBlock - AST property to search.
     * @returns {String} - Alias name or "" if not found.
     */
    static locateAstTableAlias(tableName, ast, astBlock) {
        if (typeof ast[astBlock] === 'undefined')
            return "";

        let block = [ast[astBlock]];
        if (TableAlias.isIterable(ast[astBlock])) {
            block = ast[astBlock];
        }

        for (const astItem of block) {
            if (typeof astItem.table === 'string' && tableName === astItem.table.toUpperCase() && astItem.as !== "") {
                return astItem.as;
            }
        }

        return "";
    }

    /**
     * Check if input is iterable.
     * @param {any} input - Check this object to see if it can be iterated. 
     * @returns {Boolean} - true - can be iterated.  false - cannot be iterated.
     */
    static isIterable(input) {
        if (input === null || input === undefined) {
            return false
        }

        return typeof input[Symbol.iterator] === 'function'
    }

    /**
     * Searches the UNION portion of the SELECT to locate the table alias.
     * @param {String} tableAlias - default table alias.
     * @param {String} tableName - table name to search for.
     * @param {Object} ast - Abstract Syntax Tree to search
     * @returns {String} - table alias
     */
    static getTableAliasUnion(tableAlias, tableName, ast) {
        const astRecursiveTableBlocks = ['UNION', 'UNION ALL', 'INTERSECT', 'EXCEPT'];
        let extractedAlias = tableAlias;

        let i = 0;
        while (extractedAlias === "" && i < astRecursiveTableBlocks.length) {
            if (typeof ast[astRecursiveTableBlocks[i]] !== 'undefined') {
                for (const unionAst of ast[astRecursiveTableBlocks[i]]) {
                    extractedAlias = TableAlias.getTableAlias(tableName, unionAst);

                    if (extractedAlias !== "")
                        break;
                }
            }
            i++;
        }

        return extractedAlias;
    }

    /**
     * Search WHERE IN component of SELECT to find table alias.
     * @param {String} tableAlias - default table alias
     * @param {String} tableName - table name to search for
     * @param {Object} ast - Abstract Syntax Tree to search
     * @returns {String} - table alias
     */
    static getTableAliasWhereIn(tableAlias, tableName, ast) {
        let extractedAlias = tableAlias;
        if (tableAlias === "" && typeof ast.WHERE !== 'undefined' && ast.WHERE.operator === "IN") {
            extractedAlias = TableAlias.getTableAlias(tableName, ast.WHERE.right);
        }

        if (extractedAlias === "" && ast.operator === "IN") {
            extractedAlias = TableAlias.getTableAlias(tableName, ast.right);
        }

        return extractedAlias;
    }

    /**
     * Search WHERE terms of SELECT to find table alias.
     * @param {String} tableAlias - default table alias
     * @param {String} tableName  - table name to search for.
     * @param {Object} ast - Abstract Syntax Tree to search.
     * @returns {String} - table alias
     */
    static getTableAliasWhereTerms(tableAlias, tableName, ast) {
        let extractedTableAlias = tableAlias;
        if (tableAlias === "" && typeof ast.WHERE !== 'undefined' && typeof ast.WHERE.terms !== 'undefined') {
            for (const term of ast.WHERE.terms) {
                if (extractedTableAlias === "")
                    extractedTableAlias = TableAlias.getTableAlias(tableName, term);
            }
        }

        return extractedTableAlias;
    }
}

/**
 * @classdesc Deals with extracting all TABLE names referenece inside SELECT.
 */
class TableExtract {
    /**
     * Create table definition array from select string.
     * @param {String} statement - full sql select statement.
     * @returns {String[][]} - table definition array.
     */
    static getReferencedTableNames(statement) {
        const ast = SqlParse.sql2ast(statement);
        return TableExtract.getReferencedTableNamesFromAst(ast);
    }

    /**
     * Create table definition array from select AST.
     * @param {Object} ast - AST for SELECT. 
     * @returns {any[]} - table definition array.
     * * [0] - table name.
     * * [1] - sheet tab name
     * * [2] - cache seconds
     * * [3] - output column title flag
     */
    static getReferencedTableNamesFromAst(ast) {
        const DEFAULT_CACHE_SECONDS = 60;
        const DEFAULT_COLUMNS_OUTPUT = true;
        const tableSet = new Map();

        TableExtract.extractAstTables(ast, tableSet);

        const tableList = [];
        // @ts-ignore
        for (const key of tableSet.keys()) {
            const tableDef = [key, key, DEFAULT_CACHE_SECONDS, DEFAULT_COLUMNS_OUTPUT];

            tableList.push(tableDef);
        }

        return tableList;
    }

    /**
     * Search for all referenced tables in SELECT.
     * @param {Object} ast - AST for SELECT.
     * @param {Map<String,String>} tableSet  - Function updates this map of table names and alias name.
     */
    static extractAstTables(ast, tableSet) {
        TableExtract.getTableNamesFrom(ast, tableSet);
        TableExtract.getTableNamesJoin(ast, tableSet);
        TableExtract.getTableNamesUnion(ast, tableSet);
        TableExtract.getTableNamesWhereIn(ast, tableSet);
        TableExtract.getTableNamesWhereTerms(ast, tableSet);
        TableExtract.getTableNamesCorrelatedSelect(ast, tableSet);
    }

    /**
     * Search for referenced table in FROM or JOIN part of select.
     * @param {Object} ast - AST for SELECT.
     * @param {Map<String,String>} tableSet  - Function updates this map of table names and alias name.
     */
    static getTableNamesFrom(ast, tableSet) {
        let fromAst = ast.FROM;
        while (typeof fromAst !== 'undefined') {
            if (typeof fromAst.isDerived === 'undefined') {
                tableSet.set(fromAst.table.toUpperCase(), typeof fromAst.as === 'undefined' ? '' : fromAst.as.toUpperCase());
            }
            else {
                TableExtract.extractAstTables(fromAst.FROM, tableSet);
                TableExtract.getTableNamesUnion(fromAst, tableSet);
            }
            fromAst = fromAst.FROM;
        }
    }

    /**
    * Search for referenced table in FROM or JOIN part of select.
    * @param {Object} ast - AST for SELECT.
    * @param {Map<String,String>} tableSet  - Function updates this map of table names and alias name.
    */
    static getTableNamesJoin(ast, tableSet) {
        if (typeof ast.JOIN === 'undefined')
            return;

        for (const astItem of ast.JOIN) {
            if (typeof astItem.table === 'string') {
                tableSet.set(astItem.table.toUpperCase(), typeof astItem.as === 'undefined' ? '' : astItem.as.toUpperCase());
            }
            else {
                TableExtract.extractAstTables(astItem.table, tableSet);
            }
        }
    }

    /**
     * Searches for table names within SELECT (union, intersect, except) statements.
     * @param {Object} ast - AST for SELECT
     * @param {Map<String,String>} tableSet - Function updates this map of table names and alias name.
     */
    static getTableNamesUnion(ast, tableSet) {
        const astRecursiveTableBlocks = ['UNION', 'UNION ALL', 'INTERSECT', 'EXCEPT'];

        for (const block of astRecursiveTableBlocks) {
            if (typeof ast[block] !== 'undefined') {
                for (const unionAst of ast[block]) {
                    this.extractAstTables(unionAst, tableSet);
                }
            }
        }
    }

    /**
     * Searches for tables names within SELECT (in, exists) statements.
     * @param {Object} ast - AST for SELECT
     * @param {Map<String,String>} tableSet - Function updates this map of table names and alias name.
     */
    static getTableNamesWhereIn(ast, tableSet) {
        //  where IN ().
        const subQueryTerms = ["IN", "NOT IN", "EXISTS", "NOT EXISTS"]
        if (typeof ast.WHERE !== 'undefined' && (subQueryTerms.indexOf(ast.WHERE.operator) !== -1)) {
            this.extractAstTables(ast.WHERE.right, tableSet);
        }

        if (subQueryTerms.indexOf(ast.operator) !== -1) {
            this.extractAstTables(ast.right, tableSet);
        }
    }

    /**
     * Search WHERE to find referenced table names.
     * @param {Object} ast -  AST to search.
     * @param {Map<String,String>} tableSet - Function updates this map of table names and alias name.
     */
    static getTableNamesWhereTerms(ast, tableSet) {
        if (typeof ast.WHERE !== 'undefined' && typeof ast.WHERE.terms !== 'undefined') {
            for (const term of ast.WHERE.terms) {
                this.extractAstTables(term, tableSet);
            }
        }
    }

    /**
     * Search for table references in the WHERE condition.
     * @param {Object} ast -  AST to search.
     * @param {Map<String,String>} tableSet - Function updates this map of table names and alias name. 
     */
    static getTableNamesWhereCondition(ast, tableSet) {
        const lParts = typeof ast.left === 'string' ? ast.left.split(".") : [];
        if (lParts.length > 1) {
            tableSet.set(lParts[0].toUpperCase(), "");
        }
        const rParts = typeof ast.right === 'string' ? ast.right.split(".") : [];
        if (rParts.length > 1) {
            tableSet.set(rParts[0].toUpperCase(), "");
        }
        if (typeof ast.terms !== 'undefined') {
            for (const term of ast.terms) {
                TableExtract.getTableNamesWhereCondition(term, tableSet);
            }
        }
    }

    /**
     * Search CORRELATES sub-query for table names.
     * @param {*} ast - AST to search
     * @param {*} tableSet - Function updates this map of table names and alias name.
     */
    static getTableNamesCorrelatedSelect(ast, tableSet) {
        if (typeof ast.SELECT !== 'undefined') {
            for (const term of ast.SELECT) {
                if (typeof term.subQuery !== 'undefined' && term.subQuery !== null) {
                    this.extractAstTables(term.subQuery, tableSet);
                }
            }
        }
    }
}

/**
 * @classdesc Manipulation of AST to handle PIVOT statement.
 */
class Pivot {
    /**
     * Add new column to AST for every AGGREGATE function and unique pivot column data.
     * @param {Object} ast - AST which is checked to see if a PIVOT is used.
     * @param {Map<String,Table>} tables - Map of table info.
     * @param {BindData} bindData - List of bind data.
     * @returns {Object} - Updated AST containing SELECT FIELDS for the pivot data OR original AST if no pivot.
     */
    static pivotField(ast, tables, bindData) {
        //  If we are doing a PIVOT, it then requires a GROUP BY.
        if (typeof ast.PIVOT !== 'undefined') {
            if (typeof ast['GROUP BY'] === 'undefined')
                throw new Error("PIVOT requires GROUP BY");
        }
        else {
            return ast;
        }

        // These are all of the unique PIVOT field data points.
        const pivotFieldData = Pivot.getUniquePivotData(ast, tables, bindData);

        ast.SELECT = Pivot.addCalculatedPivotFieldsToAst(ast, pivotFieldData);

        return ast;
    }

    /**
     * Find distinct pivot column data.
     * @param {Object} ast - Abstract Syntax Tree containing the PIVOT option.
     * @returns {any[][]} - All unique data points found in the PIVOT field for the given SELECT.
     */
    static getUniquePivotData(ast, tables, bindData) {
        const pivotAST = {};

        pivotAST.SELECT = ast.PIVOT;
        pivotAST.SELECT[0].name = `DISTINCT ${pivotAST.SELECT[0].name}`;
        pivotAST.FROM = ast.FROM;
        pivotAST.WHERE = ast.WHERE;

        const pivotSql = new Sql()
            .enableColumnTitle(false)
            .setBindValues(bindData)
            .copyTableData(tables);

        // These are all of the unique PIVOT field data points.
        const tableData = pivotSql.execute(pivotAST);

        return tableData;
    }

    /**
     * Add new calculated fields to the existing SELECT fields.  A field is add for each combination of
     * aggregate function and unqiue pivot data points.  The CASE function is used for each new field.
     * A test is made if the column data equal the pivot data.  If it is, the aggregate function data 
     * is returned, otherwise null.  The GROUP BY is later applied and the appropiate pivot data will
     * be calculated.
     * @param {Object} ast - AST to be updated.
     * @param {any[][]} pivotFieldData - Table data with unique pivot field data points. 
     * @returns {Object} - Abstract Sytax Tree with new SELECT fields with a CASE for each pivot data and aggregate function.
     */
    static addCalculatedPivotFieldsToAst(ast, pivotFieldData) {
        const newPivotAstFields = [];

        for (const selectField of ast.SELECT) {
            //  If this is an aggregrate function, we will add one for every pivotFieldData item
            const functionNameRegex = /^\w+\s*(?=\()/;
            const matches = selectField.name.match(functionNameRegex)
            if (matches !== null && matches.length > 0) {
                const args = SelectTables.parseForFunctions(selectField.name, matches[0].trim());

                for (const fld of pivotFieldData) {
                    const caseTxt = `${matches[0]}(CASE WHEN ${ast.PIVOT[0].name} = '${fld}' THEN ${args[1]} ELSE 'null' END)`;
                    const asField = `${fld[0]} ${typeof selectField.as !== 'undefined' && selectField.as !== "" ? selectField.as : selectField.name}`;
                    newPivotAstFields.push({ name: caseTxt, as: asField });
                }
            }
            else {
                newPivotAstFields.push(selectField);
            }
        }

        return newPivotAstFields;
    }

}

/**
 * @classdesc Deals with processing SET theory on SELECT table results.
 */
class SqlSets {
    /**
     * Get list of valid set types.
     * @returns {String[]}
     */
    static getUnionTypes() {
        return ['UNION', 'UNION ALL', 'INTERSECT', 'EXCEPT'];
    }

    /**
     * Determine what set type is applied to the select results.
     * @param {Object} ast 
     * @returns {String}
     */
    static getSetType(ast) {
        for (const type of SqlSets.getUnionTypes()) {
            if (typeof ast[type] !== 'undefined') {
                return type;
            }   
        }    

        return "";
    }

    /**
     * Apply set theory to data.
     * @param {String} type ("UNION", "UNION ALL", "INTERSECT", "EXCEPT")
     * @param {any[][]} leftTableData 
     * @param {any[][]} rightTableData 
     * @returns {any[][]}
     */
    static applySet(type, leftTableData, rightTableData) {
        if (leftTableData.length > 0 && rightTableData.length > 0 && leftTableData[0].length !== rightTableData[0].length) {
            throw new Error(`Invalid ${type}.  Selected field counts do not match.`);
        }

        switch (type) {
            case "UNION":
                leftTableData = leftTableData.concat(rightTableData);
                leftTableData = SqlSets.removeDuplicateRows(leftTableData);
                break;

            case "UNION ALL":
                //  Allow duplicates.
                leftTableData = leftTableData.concat(rightTableData);
                break;

            case "INTERSECT":
                //  Must exist in BOTH tables.
                leftTableData = SqlSets.intersectRows(leftTableData, rightTableData);
                break;

            case "EXCEPT":
                //  Remove from first table all rows that match in second table.
                leftTableData = SqlSets.exceptRows(leftTableData, rightTableData);
                break;

            default:
                throw new Error(`Internal error.  Unsupported UNION type: ${type}`);
        }

        return leftTableData;
    }

    /**
     * 
     * @param {Object} ast 
     * @returns {Boolean}
     */
    static isSqlSet(ast) {
        for (const type of SqlSets.getUnionTypes()) {
            if (typeof ast[type] !== 'undefined') {
                return true;
            }
        }

        return false;
    }

    /**
     * Remove all duplicate table rows
     * @param {any[][]} srcData 
     * @returns {any[][]}
     */
    static removeDuplicateRows(srcData) {
        const newTableData = [];
        const srcDataRecordKeys = new Map();

        for (const row of srcData) {
            const key = row.join("::");

            if (!srcDataRecordKeys.has(key)) {
                newTableData.push(row);
                srcDataRecordKeys.set(key, true);
            }
        }

        return newTableData;
    }

    /**
     * Finds the rows that are common between srcData and newData
     * @param {any[][]} srcData - table data
     * @param {any[][]} newData - table data
     * @returns {any[][]} - returns only rows that intersect srcData and newData.
     */
    static intersectRows(srcData, newData) {
        const srcMap = new Map();
        const intersectTable = [];

        for (const srcRow of srcData) {
            srcMap.set(srcRow.join("::"), true);
        }

        for (const newRow of newData) {
            if (srcMap.has(newRow.join("::"))) {
                intersectTable.push(newRow);
            }
        }
        return intersectTable;
    }

    /**
     * Returns all rows in srcData MINUS any rows that match it from newData.
     * @param {any[][]} srcData - starting table
     * @param {any[][]} newData  - minus table (if it matches srcData row)
     * @returns {any[][]} - srcData MINUS newData
     */
    static exceptRows(srcData, newData) {
        const srcMap = new Map();
        let rowNum = 0;
        for (const srcRow of srcData) {
            srcMap.set(srcRow.join("::"), rowNum);
            rowNum++;
        }

        const removeRowNum = [];
        for (const newRow of newData) {
            const key = newRow.join("::");
            if (srcMap.has(key)) {
                removeRowNum.push(srcMap.get(key));
            }
        }

        removeRowNum.sort((a, b) => b - a);
        for (rowNum of removeRowNum) {
            srcData.splice(rowNum, 1);
        }

        return srcData;
    }
}

/**
 * @classdesc 
 * Store and retrieve bind data for use in WHERE portion of SELECT statement.
 */
class BindData {
    constructor() {
        this.clear();
    }

    /**
     * Reset the bind data.
     */
    clear() {
        this.next = 1;
        this.bindMap = new Map();
        this.bindQueue = [];
    }

    /**
     * Add bind data 
     * @param {any} data - bind data
     * @returns {String} - bind variable name for reference in SQL.  e.g.  first data point would return '?1'.
     */
    add(data) {
        const key = `?${this.next.toString()}`;
        this.bindMap.set(key, data);
        this.bindQueue.push(data);

        this.next++;

        return key;
    }

    /**
     * Add a list of bind data points.
     * @param {any[]} bindList 
     */
    addList(bindList) {
        for (const data of bindList) {
            this.add(data);
        }
    }

    /**
     * Pull out a bind data entry.
     * @param {String} name - Get by name or get NEXT if empty.
     * @returns {any}
     */
    get(name = "") {
        return name === '' ? this.bindQueue.shift() : this.bindMap.get(name);
    }

    /**
     * Return the ordered list of bind data.
     * @returns {any[]} - Current list of bind data.
     */
    getBindDataList() {
        return this.bindQueue;
    }
}