UNPKG

@demmings/gssql

Version:

Google Sheets QUERY function replacement using real SQL select syntax.

550 lines (461 loc) 17.1 kB
/* *** 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) { Table.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 (tableData === undefined || tableData.length === 0) return this; if (!this.hasColumnTitle) { Table.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().replaceAll(',', "").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. */ static addColumnLetters(tableData) { if (tableData.length === 0) return [[]]; const newTitleRow = []; for (let i = 1; i <= tableData[0].length; i++) { newTitleRow.push(Table.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. */ static 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 = Table.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. * @param {String} aliasName * @returns {String[]} - field names */ getAllExtendedNotationFieldNames(aliasName = '') { return this.schema.getAllExtendedNotationFieldNames(aliasName); } /** * 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 = Math.max(startRecord, 1); 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 : value.toString(); if (value !== "") { value = typeof value === 'string' ? value.toUpperCase() : 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) { const uniqueAliasSet = new Set(); for (const alias of tableAlias) { if (alias !== '') { uniqueAliasSet.add(alias.toUpperCase()); } } this.tableAlias = Array.from(uniqueAliasSet); 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'. * @param {String} aliasName * @returns {String[]} - list of all field names with table prefix. */ getAllExtendedNotationFieldNames(aliasName) { /** @type {String[]} */ const fieldNames = []; const tableName = aliasName === '' ? this.tableName : aliasName; // @ts-ignore for (const [key, value] of this.fields.entries()) { if (value !== null) { const fieldParts = key.split("."); if (fieldNames[value] === undefined || (fieldParts.length === 2 && (fieldParts[0] === 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[]} columnNameVariants */ /** * 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, "_"); const columnNameVariants = []; if (!columnName.includes(".")) { columnNameVariants.push(`${this.tableName}.${columnName}`); for (const tableAlias of this.tableAlias) { columnNameVariants.push(`${tableAlias}.${columnName}`); } } return { columnName, columnNameVariants }; } /** * 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) { for (const fld of fieldVariants.columnNameVariants) { this.fields.set(fld, colNum); } } } } }