Table.js

//  *** DEBUG START ***
//  Remove comments for testing in NODE

export { Table, Schema };
import { DERIVEDTABLE, VirtualFields, VirtualField, CalculatedField } from './Views.js';
import { TableData } from './TableData.js';

class Logger {
    static log(msg) {
        console.log(msg);
    }
}

//  *** DEBUG END ***/

/** 
 * @classdesc 
 * Data and methods for each (logical) SQL table. 
 */
class Table {       //  skipcq: JS-0128
    /**
     * @param {String} tableName - name of sql table.
     */
    constructor(tableName) {
        /** @property {String} - table name. */
        this.tableName = tableName.toUpperCase();

        /** @property {any[][]} - table data. */
        this.tableData = [];

        /** @property {Boolean} */
        this.hasColumnTitle = true;

        /** @property {Schema} */
        this.schema = new Schema()
            .setTableName(tableName)
            .setTable(this);
    }

    /**
     * Set associated table alias name to object.
     * @param {String} tableAlias - table alias that may be used to prefix column names.
     * @returns {Table}
     */
    setTableAlias(tableAlias) {
        this.schema.setTableAlias(tableAlias);
        return this;
    }

    /**
     * Indicate if data contains a column title row.
     * @param {Boolean} hasTitle 
     * * true - first row of data will contain unique column names
     * * false - first row of data will contain data.  Column names are then referenced as letters (A, B, ...)
     * @returns {Table}
     */
    setHasColumnTitle(hasTitle) {
        this.hasColumnTitle = hasTitle;

        return this;
    }

    /**
     * Load sheets named range of data into table.
     * @param {String} namedRange - defines where data is located in sheets.
     * * sheet name - reads entire sheet from top left corner.
     * * named range - reads named range for data.
     * * A1 notation - range of data using normal sheets notation like 'A1:C10'.  This may also include the sheet name like 'stocks!A1:C100'.
     * @param {Number} cacheSeconds - How many seconds to cache data so we don't need to make time consuming
     * getValues() from sheets.  
     * @returns {Table}
     */
    loadNamedRangeData(namedRange, cacheSeconds = 0) {
        this.tableData = TableData.loadTableData(namedRange, cacheSeconds);

        if (!this.hasColumnTitle) {
            this.addColumnLetters(this.tableData);
        }

        Logger.log(`Load Data: Range=${namedRange}. Items=${this.tableData.length}`);
        this.loadSchema();

        return this;
    }

    /**
     * Read table data from a double array rather than from sheets.
     * @param {any[]} tableData - Loaded table data with first row titles included.
     * @returns {Table}
     */
    loadArrayData(tableData) {
        if (typeof tableData === 'undefined' || tableData.length === 0)
            return this;

        if (!this.hasColumnTitle) {
            this.addColumnLetters(tableData);
        }

        this.tableData = Table.removeEmptyRecordsAtEndOfTable(tableData);

        this.loadSchema();

        return this;
    }

    /**
     * It is common to have extra empty records loaded at end of table.
     * Remove those empty records at END of table only.
     * @param {any[][]} tableData 
     * @returns {any[][]}
     */
    static removeEmptyRecordsAtEndOfTable(tableData) {
        let blankLines = 0;
        for (let i = tableData.length - 1; i > 0; i--) {
            if (tableData[i].join().replace(/,/g, "").length > 0)
                break;
            blankLines++;
        }

        return tableData.slice(0, tableData.length - blankLines);
    }

    /**
     * Internal function for updating the loaded data to include column names using letters, starting from 'A', 'B',...
     * @param {any[][]} tableData - table data that does not currently contain a first row with column names.
     * @returns {any[][]} - updated table data that includes a column title row.
     */
    addColumnLetters(tableData) {
        if (tableData.length === 0)
            return [[]];

        const newTitleRow = [];

        for (let i = 1; i <= tableData[0].length; i++) {
            newTitleRow.push(this.numberToSheetColumnLetter(i));
        }
        tableData.unshift(newTitleRow);

        return tableData;
    }

    /**
     * Find the sheet column letter name based on position.  
     * @param {Number} number - Returns the sheets column name.  
     * 1 = 'A'
     * 2 = 'B'
     * 26 = 'Z'
     * 27 = 'AA'
     * @returns {String} - the column letter.
     */
    numberToSheetColumnLetter(number) {
        const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
        let result = ""

        let charIndex = number % alphabet.length
        let quotient = number / alphabet.length
        if (charIndex - 1 === -1) {
            charIndex = alphabet.length
            quotient--;
        }
        result = alphabet.charAt(charIndex - 1) + result;
        if (quotient >= 1) {
            result = this.numberToSheetColumnLetter(quotient) + result;
        }

        return result;
    }

    /**
     * Read loaded table data and updates internal list of column information
     * @returns {Table}
     */
    loadSchema() {
        this.schema
            .setTableData(this.tableData)
            .load();

        return this;
    }

    /**
     * Find column number using the field name.
     * @param {String} fieldName - Valid field name.
     * @returns {Number} - column offset number starting at zero.
     */
    getFieldColumn(fieldName) {
        return this.schema.getFieldColumn(fieldName);
    }

    /**
    * Get field column index (starts at 0) for field names.
    * @param {String[]} fieldNames - list of valid field names.
    * @returns {Number[]} - list of column offsets, starting at zero corresponding to the input list of names.
    */
    getFieldColumns(fieldNames) {
        return this.schema.getFieldColumns(fieldNames);
    }

    /**
     * Find all field data for this table (or the derived table)
     * @returns {VirtualField[]} - field column information list
     */
    getAllVirtualFields() {
        return this.schema.getAllVirtualFields();
    }

    /**
     * Returns a list of all possible field names that could be used in the SELECT.
     * @returns {String[]} - List of field names.
     */
    getAllFieldNames() {
        return this.schema.getAllFieldNames();
    }

    /**
     * Returns table field names that are prefixed with table name.
     * @returns {String[]} - field names
     */
    getAllExtendedNotationFieldNames() {
        return this.schema.getAllExtendedNotationFieldNames();
    }

    /**
     * Find number of columns in table.
     * @returns {Number} - column count.
     */
    getColumnCount() {
        const fields = this.getAllExtendedNotationFieldNames();
        return fields.length;
    }

    /**
     * Return range of records from table.
     * @param {Number} startRecord - 1 is first record
     * @param {Number} lastRecord - -1 for all. Last = RecordCount().    
     * @param {Number[]} fields - fields to include in output
     * @returns {any[][]} - subset table data.
     */
    getRecords(startRecord, lastRecord, fields) {
        const selectedRecords = [];
        const minStartRecord = startRecord < 1 ? 1 : startRecord;
        const maxLastRecord = lastRecord < 0 ? this.tableData.length - 1 : lastRecord;

        for (let i = minStartRecord; i <= maxLastRecord && i < this.tableData.length; i++) {
            const row = [];

            for (const col of fields) {
                row.push(this.tableData[i][col]);
            }

            selectedRecords.push(row);
        }

        return selectedRecords;
    }

    /**
     * Create a logical table index on input field name.
     * The resulting map is stored with the table.
     * The Map<fieldDataItem, [rowNumbers]> is stored.
     * @param {String} fieldName - field name to index.
     * @param {CalculatedField} calcSqlField
     * @param {String} calcField
     * @returns {Map<String,Number[]>}
     */
    createKeyFieldRecordMap(fieldName, calcSqlField = null, calcField = "") {
        const indexedFieldName = fieldName.trim().toUpperCase();
        /** @type {Map<String,Number[]>} */
        const fieldValuesMap = new Map();

        let value = null;
        const fieldIndex = calcSqlField === null ? this.schema.getFieldColumn(indexedFieldName) : null;

        for (let i = 1; i < this.tableData.length; i++) {
            value = calcSqlField === null ? this.tableData[i][fieldIndex] : calcSqlField.evaluateCalculatedField(calcField, i);
            value = (value !== null) ? value.toString() : value;

            if (value !== "") {
                const rowNumbers = fieldValuesMap.has(value) ? fieldValuesMap.get(value) : [];
                rowNumbers.push(i);

                fieldValuesMap.set(value, rowNumbers);
            }
        }

        return fieldValuesMap;
    }

    /**
     * The calculated field is evaluated for every record in the table.  Each unique calculated value
     * will map to a list of table record numbers where the calculated value will be found.
     * @param {CalculatedField} calcSqlField 
     * @param {String} calcField 
     * @returns  {Map<String,Number[]>}
     */
    createCalcFieldRecordMap(calcSqlField, calcField) {
        return this.createKeyFieldRecordMap("", calcSqlField, calcField);
    }

    /**
     * Append table data from 'concatTable' to the end of this tables existing data.
     * @param {Table} concatTable - Append 'concatTable' data to end of current table data.
     * @returns {void}
     */
    concat(concatTable) {
        const fieldsThisTable = this.schema.getAllFieldNames();
        const fieldColumns = concatTable.getFieldColumns(fieldsThisTable);
        const data = concatTable.getRecords(1, -1, fieldColumns);
        this.tableData = this.tableData.concat(data);
    }
}

/** 
 * @classdesc
 * Class contains information about each column in the SQL table. 
 */
class Schema {
    constructor() {
        /** @property {String} - Table name. */
        this.tableName = "";

        /** @property {String} - Alias name of table. */
        this.tableAlias = "";

        /** @property {any[][]} - Table data double array. */
        this.tableData = [];

        /** @property {Table} - Link to table info object. */
        this.tableInfo = null;

        /** @property {Boolean} - Is this a derived table. */
        this.isDerivedTable = this.tableName === DERIVEDTABLE;

        /** @property {Map<String,Number>} - String=Field Name, Number=Column Number */
        this.fields = new Map();
        
        /** @property {VirtualFields} */
        this.virtualFields = new VirtualFields();
    }

    /**
     * Set table name in this object.
     * @param {String} tableName - Table name to remember.
     * @returns {Schema}
     */
    setTableName(tableName) {
        this.tableName = tableName.toUpperCase();
        return this;
    }

    /**
     * Associate the table alias to this object.
     * @param {String} tableAlias - table alias name
     * @returns {Schema}  
     */
    setTableAlias(tableAlias) {
        this.tableAlias = tableAlias.toUpperCase();
        return this;
    }

    /**
     * Associate table data with this object.
     * @param {any[][]} tableData - double array of table data.
     * @returns {Schema}
     */
    setTableData(tableData) {
        this.tableData = tableData;
        return this;
    }

    /**
     * Set the existing 'Table' info.
     * @param {Table} tableInfo - table object.
     * @returns {Schema}
     */
    setTable(tableInfo) {
        this.tableInfo = tableInfo;
        return this;
    }

    /**
     * Retrieve all field names for this table.
     * @returns {String[]} - List of field names.
     */
    getAllFieldNames() {
        /** @type {String[]} */
        const fieldNames = [];

        // @ts-ignore
        for (const key of this.fields.keys()) {
            if (key !== "*")
                fieldNames.push(key);
        }

        return fieldNames;
    }

    /**
     * All table fields names with 'TABLE.field_name'.
     * @returns {String[]} - list of all field names with table prefix.
     */
    getAllExtendedNotationFieldNames() {
        /** @type {String[]} */
        const fieldNames = [];

        // @ts-ignore
        for (const [key, value] of this.fields.entries()) {
            if (value !== null) {
                const fieldParts = key.split(".");
                if (typeof fieldNames[value] === 'undefined' ||
                    (fieldParts.length === 2 && (fieldParts[0] === this.tableName || this.isDerivedTable)))
                    fieldNames[value] = key;
            }
        }

        return fieldNames;
    }

    /**
     * Get a list of all virtual field data associated with this table.
     * @returns {VirtualField[]}
     */
    getAllVirtualFields() {
        return this.virtualFields.getAllVirtualFields();
    }

    /**
     * Get the column number for the specified field name.
     * @param {String} field - Field name to find column number for.
     * @returns {Number} - Column number.
     */
    getFieldColumn(field) {
        const cols = this.getFieldColumns([field]);
        return cols[0];
    }

    /**
    * Get field column index (starts at 0) for field names.
    * @param {String[]} fieldNames - find columns for specific fields in table.
    * @returns {Number[]} - column numbers for each specified field.
    */
    getFieldColumns(fieldNames) {
        const fieldIndex = fieldNames.map(f => this.fields.has(f.trim().toUpperCase()) ? this.fields.get(f.trim().toUpperCase()) : -1)

        return fieldIndex;
    }

    /**
     * The field name is found in TITLE row of sheet.  These column titles
     * are TRIMMED, UPPERCASE and SPACES removed (made to UNDERSCORE).
     * SQL statements MUST reference fields with spaces converted to underscore.
     * @returns {Schema}
     */
    load() {
        this.fields = new Map();
        this.virtualFields = new VirtualFields();

        if (this.tableData.length === 0)
            return this;

        /** @type {any[]} */
        const titleRow = this.tableData[0];

        let colNum = 0;
        /** @type {FieldVariants} */
        let fieldVariants = null;
        for (const baseColumnName of titleRow) {
            //  Find possible variations of the field column name.
            try {
                fieldVariants = this.getColumnNameVariants(baseColumnName);
            }
            catch (ex) {
                throw new Error(`Invalid column title: ${baseColumnName}`);
            }
            const columnName = fieldVariants.columnName;

            this.setFieldVariantsColumNumber(fieldVariants, colNum);

            if (columnName !== "") {
                const virtualField = new VirtualField(columnName);
                this.virtualFields.add(virtualField, true);
            }

            colNum++;
        }

        //  Add special field for every table.
        //  The asterisk represents ALL fields in table.
        this.fields.set("*", null);

        return this;
    }

    /**
     * @typedef {Object} FieldVariants
     * @property {String} columnName
     * @property {String} fullColumnName
     * @property {String} fullColumnAliasName
     */

    /**
     * Find all valid variations for a column name.  This will include base column name,
     * the column name prefixed with full table name, and the column name prefixed with table alias.
     * @param {String} colName 
     * @returns {FieldVariants}
     */
    getColumnNameVariants(colName) {
        const columnName = colName.trim().toUpperCase().replace(/\s/g, "_");
        let fullColumnName = columnName;
        let fullColumnAliasName = "";
        if (columnName.indexOf(".") === -1) {
            fullColumnName = `${this.tableName}.${columnName}`;
            if (this.tableAlias !== "")
                fullColumnAliasName = `${this.tableAlias}.${columnName}`;
        }

        return { columnName, fullColumnName, fullColumnAliasName };
    }

    /**
     * Associate table column number to each possible variation of column name.
     * @param {FieldVariants} fieldVariants 
     * @param {Number} colNum 
     */
    setFieldVariantsColumNumber(fieldVariants, colNum) {
        if (fieldVariants.columnName !== "") {
            this.fields.set(fieldVariants.columnName, colNum);

            if (!this.isDerivedTable) {
                this.fields.set(fieldVariants.fullColumnName, colNum);

                if (fieldVariants.fullColumnAliasName !== "") {
                    this.fields.set(fieldVariants.fullColumnAliasName, colNum);
                }
            }
        }
    }
}