• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

demmings / gsSQL / 3920713201

pending completion
3920713201

push

github

cdemmigs
JSDoc updates

1183 of 1254 branches covered (94.34%)

Branch coverage included in aggregate %.

807 of 807 new or added lines in 7 files covered. (100.0%)

9211 of 9623 relevant lines covered (95.72%)

490.2 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.84
/src/Sql.js
1
//  Remove comments for testing in NODE
1✔
2
/*  *** DEBUG START ***
1✔
3
export { Sql, gsSQL, parseTableSettings };
1✔
4
import { Table } from './Table.js';
1✔
5
import { TableData } from './TableData.js';
1✔
6
import { SqlParse } from './SimpleParser.js';
1✔
7
import { SelectTables } from './Views.js';
1✔
8

1✔
9
class Logger {
1✔
10
    static log(msg) {
1✔
11
        console.log(msg);
26✔
12
    }
26✔
13
}
1✔
14
//  *** DEBUG END  ***/
1✔
15

1✔
16
/**
1✔
17
 * @description
1✔
18
 * **CUSTOM FUNCTION**  
1✔
19
 * * Available as a custom function within your sheet.
1✔
20
 * * Query any sheet range using standard SQL SELECT syntax.
1✔
21
 * ### Parameters.
1✔
22
 * * Parameter 1.  SELECT statement.  All regular syntax is supported including JOIN. 
1✔
23
 *   * note i)  Bind variables (?) are replaced by bind data specified later.
1✔
24
 *   * note ii)  PIVOT field supported.  Similar to QUERY. e.g.  "SELECT date, sum(quantity) from sales group by date pivot customer_id".
1✔
25
 *   * note iii) If parm 2 not used and sheet name contains a space, use single quotes around table name.
1✔
26
 * * Parameter 2. (optional. referenced tables assumed to be SHEET NAME with column titles).  Define all tables referenced in SELECT. This is a DOUBLE ARRAY and is done using the curly bracket {{a,b,c}; {a,b,c}} syntax.
1✔
27
 *   * a)  table name - the table name referenced in SELECT for indicated range.
1✔
28
 *   * b)  sheet range - (optional) either NAMED RANGE, A1 notation range, SHEET NAME or empty (table name used as sheet name).  This input is a string.  The first row of each range MUST be unique column titles.
1✔
29
 *   * c)  cache seconds - (optional) time loaded range held in cache.  default=60.   
1✔
30
 *   * d)  has column title - (optional) first row of data is a title (for field name).  default=true 
1✔
31
 * * Parameter 3. (optional) Output result column title (true/false). default=true.   
1✔
32
 * * Parameter 4... (optional) Bind variables.  List as many as required to match '?' in SELECT statement.
1✔
33
 * <br>
1✔
34
 * * **Example** use inside Google Sheet Cell.
1✔
35
 * ```
1✔
36
 * =gsSQL("select title, (select count(*)  from Booksales where books.id = BookSales.book_id) as 'Quantity Sold' from books", {{"booksales","booksales", 60};{"books", "books", 60}})
1✔
37
 * ```
1✔
38
 * @param {String} statement - SQL (e.g.:  'select * from expenses')
1✔
39
 * @param {any[][]} tableArr - {{"tableName", "sheetRange", cacheSeconds, hasColumnTitle}; {"name","range",cache,true};...}"
1✔
40
 * @param {Boolean} columnTitle - TRUE will add column title to output (default=TRUE)
1✔
41
 * @param {...any} bindings - Bind variables to match '?' in SQL statement.
1✔
42
 * @returns {any[][]} - Double array of selected data.  First index ROW, Second index COLUMN.
1✔
43
 * @customfunction
1✔
44
 */
1✔
45
function gsSQL(statement, tableArr = [], columnTitle = true, ...bindings) {     //  skipcq: JS-0128
1✔
46
    const tableList = parseTableSettings(tableArr, statement);
1✔
47

1✔
48
    Logger.log(`gsSQL: tableList=${tableList}.  Statement=${statement}. List Len=${tableList.length}`);
1✔
49

1✔
50
    const sqlCmd = new Sql().enableColumnTitle(columnTitle);
1✔
51
    for (const bind of bindings) {
1!
52
        sqlCmd.addBindParameter(bind);
×
53
    }
×
54
    for (const tableDef of tableList) {
1✔
55
        sqlCmd.addTableData(tableDef[0], tableDef[1], tableDef[2], tableDef[3]);
1✔
56
    }
1✔
57
    return sqlCmd.execute(statement);
1✔
58
}
1✔
59

1✔
60
/**
1✔
61
 * 
1✔
62
 * @param {any[][]} tableArr - Referenced Table list.  This is normally the second parameter in gsSQL() custom function.  
1✔
63
 * It is a double array with first index for TABLE, and the second index are settings in the table. 
1✔
64
 * The setting index for each table is as follows:
1✔
65
 * * 0 - Table Name.
1✔
66
 * * 1 - Sheet Range.
1✔
67
 * * 2 - Cache seconds.
1✔
68
 * * 3 - First row contains title (for field name)
1✔
69
 * @param {String} statement - SQL SELECT statement.  If no data specified in 'tableArr', the SELECT is 
1✔
70
 * parsed and each referenced table is assumed to be a TAB name on the sheet.
1✔
71
 * @param {Boolean} randomOrder - Returned table list is randomized.
1✔
72
 * @returns {any[][]} - Data from 'tableArr' PLUS any extracted tables referenced from SELECT statement.
1✔
73
 * It is a double array with first index for TABLE, and the second index are settings in the table. 
1✔
74
 * The setting index for each table is as follows:
1✔
75
 * * 0 - Table Name.
1✔
76
 * * 1 - Sheet Range.
1✔
77
 * * 2 - Cache seconds.
1✔
78
 * * 3 - First row contains title (for field name)
1✔
79
 */
1✔
80
function parseTableSettings(tableArr, statement = "", randomOrder = true) {
13✔
81
    let tableList = [];
13✔
82
    let referencedTableSettings = tableArr;
13✔
83

13✔
84
    //  Get table names from the SELECT statement when no table range info is given.
13✔
85
    if (tableArr.length === 0 && statement !== "") {
13✔
86
        referencedTableSettings = Sql.getReferencedTableNames(statement);
10✔
87
    }
10✔
88

13✔
89
    if (referencedTableSettings.length === 0) {
13!
90
        throw new Error('Missing table definition {{"name","range",cache};{...}}');
×
91
    }
×
92

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

29✔
106
        tableList.push(table);
29✔
107
    }
29✔
108

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

12✔
114
    return tableList;
12✔
115
}
13✔
116

1✔
117
/** Perform SQL SELECT using this class. */
1✔
118
class Sql {
1✔
119
    constructor() {
1✔
120
        /** @property {Map<String,Table>} - Map of referenced tables.*/
202✔
121
        this.tables = new Map();
202✔
122
        /** @property {Boolean} - Are column tables to be ouptout? */
202✔
123
        this.columnTitle = false;
202✔
124
        /** @property {any[]} - List of BIND data linked to '?' in statement. */
202✔
125
        this.bindParameters = [];
202✔
126
    }
202✔
127

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

207✔
139
        if (Array.isArray(tableData)) {
207✔
140
            tableInfo = new Table(tableName)
200✔
141
                .setHasColumnTitle(hasColumnTitle)
200✔
142
                .loadArrayData(tableData);
200✔
143
        }
200✔
144
        else {
7✔
145
            tableInfo = new Table(tableName)
7✔
146
                .setHasColumnTitle(hasColumnTitle)
7✔
147
                .loadNamedRangeData(tableData, cacheSeconds);
7✔
148
        }
7✔
149

205✔
150
        this.tables.set(tableName.toUpperCase(), tableInfo);
205✔
151

205✔
152
        return this;
205✔
153
    }
207✔
154

1✔
155
    /**
1✔
156
     * Include column headers in return data.
1✔
157
     * @param {Boolean} value - true will return column names in first row of return data.
1✔
158
     * @returns {Sql}
1✔
159
     */
1✔
160
    enableColumnTitle(value) {
1✔
161
        this.columnTitle = value;
133✔
162
        return this;
133✔
163
    }
133✔
164

1✔
165
    /**
1✔
166
     * Query if this instance of Sql() will generate column titles.
1✔
167
     * @returns {Boolean}
1✔
168
     */
1✔
169
    areColumnTitlesOutput() {
1✔
170
        return this.columnTitle;
132✔
171
    }
132✔
172

1✔
173
    /**
1✔
174
     * Add a bind data value.  Must be added in order.  If bind data is a named range, use addBindNamedRangeParameter().
1✔
175
     * @param {any} value - literal data. 
1✔
176
     * @returns {Sql}
1✔
177
     */
1✔
178
    addBindParameter(value) {
1✔
179
        this.bindParameters.push(value);
37✔
180
        return this;
37✔
181
    }
37✔
182

1✔
183
    /**
1✔
184
     * List of bind data added so far.
1✔
185
     * @returns {any[]}
1✔
186
     */
1✔
187
    getBindData() {
1✔
188
        return this.bindParameters;
132✔
189
    }
132✔
190

1✔
191
    /**
1✔
192
     * The BIND data is a sheet named range that will be read and used for bind data.
1✔
193
     * @param {String} value - Sheets Named Range for SINGLE CELL only.
1✔
194
     * @returns {Sql}
1✔
195
     */
1✔
196
    addBindNamedRangeParameter(value) {
1✔
197
        const namedValue = TableData.getValueCached(value, 30);
12✔
198
        this.bindParameters.push(namedValue);
12✔
199
        Logger.log(`BIND=${value} = ${namedValue}`);
12✔
200
        return this;
12✔
201
    }
12✔
202

1✔
203
    /**
1✔
204
     * Set all bind data at once using array.
1✔
205
     * @param {any[]} value - List of all needed BIND data.
1✔
206
     * @returns {Sql}
1✔
207
     */
1✔
208
    setBindValues(value) {
1✔
209
        this.bindParameters = value;
65✔
210
        return this;
65✔
211
    }
65✔
212

1✔
213
    /**
1✔
214
     * Clears existing BIND data so Sql() instance can be used again with new bind parameters.
1✔
215
     * @returns {Sql}
1✔
216
     */
1✔
217
    clearBindParameters() {
1✔
218
        this.bindParameters = [];
1✔
219
        return this;
1✔
220
    }
1✔
221

1✔
222
    /**
1✔
223
    * Parse SQL SELECT statement, performs SQL query and returns data ready for custom function return.
1✔
224
    * <br>Execute() can be called multiple times for different SELECT statements, provided that all required
1✔
225
    * table data was loaded in the constructor.  
1✔
226
    * Methods that would be used PRIOR to execute are:
1✔
227
    * <br>**enableColumnTitle()** - turn on/off column title in output
1✔
228
    * <br>**addBindParameter()** - If bind data is needed in select.  e.g. "select * from table where id = ?"
1✔
229
    * <br>**addTableData()** - At least ONE table needs to be added prior to execute. This tells **execute** where to find the data.
1✔
230
    * <br>**Example SELECT and RETURN Data**
1✔
231
    * ```js
1✔
232
    *   let stmt = "SELECT books.id, books.title, books.author_id " +
1✔
233
    *        "FROM books " +
1✔
234
    *        "WHERE books.author_id IN ('11','12') " +
1✔
235
    *        "ORDER BY books.title";
1✔
236
    *
1✔
237
    *    let data = new Sql()
1✔
238
    *        .addTableData("books", this.bookTable())
1✔
239
    *        .enableColumnTitle(true)
1✔
240
    *        .execute(stmt);
1✔
241
    * 
1✔
242
    *    Logger.log(data);
1✔
243
    * 
1✔
244
    * [["books.id", "books.title", "books.author_id"],
1✔
245
    *    ["4", "Dream Your Life", "11"],
1✔
246
    *    ["8", "My Last Book", "11"],
1✔
247
    *    ["5", "Oranges", "12"],
1✔
248
    *    ["1", "Time to Grow Up!", "11"]]
1✔
249
    * ```
1✔
250
    * @param {String} statement - SELECT statement.
1✔
251
    * @returns {any[][]} - Double array where first index is ROW and second index is COLUMN.
1✔
252
    */
1✔
253
    execute(statement) {
1✔
254
        let sqlData = [];
133✔
255

133✔
256
        this.ast = SqlParse.sql2ast(statement);
133✔
257

133✔
258
        //  "SELECT * from (select a,b,c from table) as derivedtable"
133✔
259
        //  Sub query data is loaded and given the name 'derivedtable'
133✔
260
        //  The AST.FROM is updated from the sub-query to the new derived table name. 
133✔
261
        this.selectFromSubQuery();
133✔
262

133✔
263
        // @ts-ignore
133✔
264
        for (const table of this.tables.keys()) {
133✔
265
            const tableAlias = this.getTableAlias(table, this.ast);
206✔
266
            const tableInfo = this.tables.get(table.toUpperCase());
206✔
267
            tableInfo
206✔
268
                .setTableAlias(tableAlias)
206✔
269
                .loadSchema();
206✔
270
        }
206✔
271

132✔
272
        if (typeof this.ast.SELECT !== 'undefined')
132✔
273
            sqlData = this.select(this.ast);
133✔
274
        else
1✔
275
            throw new Error("Only SELECT statements are supported.");
1✔
276

112✔
277
        return sqlData;
112✔
278
    }
133✔
279

1✔
280
    /**
1✔
281
     * Sets all tables referenced SELECT.
1✔
282
    * @param {Map<String,Table>} mapOfTables - Map of referenced tables indexed by TABLE name.
1✔
283
    */
1✔
284
    setTables(mapOfTables) {
1✔
285
        this.tables = mapOfTables;
69✔
286
        return this;
69✔
287
    }
69✔
288

1✔
289
    /**
1✔
290
     * Returns a map of all tables configured for this SELECT.
1✔
291
     * @returns {Map<String,Table>} - Map of referenced tables indexed by TABLE name.
1✔
292
     */
1✔
293
    getTables() {
1✔
294
        return this.tables;
132✔
295
    }
132✔
296

1✔
297
    /**
1✔
298
    * Find table alias name (if any) for input actual table name.
1✔
299
    * @param {String} tableName - Actual table name.
1✔
300
    * @param {Object} ast - Abstract Syntax Tree for SQL.
1✔
301
    * @returns {String} - Table alias.  Empty string if not found.
1✔
302
    */
1✔
303
    getTableAlias(tableName, ast) {
1✔
304
        let tableAlias = "";
345✔
305
        const ucTableName = tableName.toUpperCase();
345✔
306

345✔
307
        tableAlias = Sql.getTableAliasFromJoin(tableAlias, ucTableName, ast);
345✔
308
        tableAlias = this.getTableAliasUnion(tableAlias, ucTableName, ast);
345✔
309
        tableAlias = this.getTableAliasWhereIn(tableAlias, ucTableName, ast);
345✔
310
        tableAlias = this.getTableAliasWhereTerms(tableAlias, ucTableName, ast);
345✔
311

345✔
312
        return tableAlias;
345✔
313
    }
345✔
314

1✔
315
    /**
1✔
316
     * Modifies AST when FROM is a sub-query rather than a table name.
1✔
317
     */
1✔
318
    selectFromSubQuery() {
1✔
319
        if (typeof this.ast.FROM !== 'undefined' && typeof this.ast.FROM.SELECT !== 'undefined') {
132✔
320
            const inSQL = new Sql().setTables(this.tables).enableColumnTitle(true);
2✔
321
            const data = inSQL.select(this.ast.FROM);
2✔
322
            this.addTableData(this.ast.FROM.FROM[0].as, data);
2✔
323
            this.ast.FROM = [{ table: this.ast.FROM.FROM[0].as, as: this.ast.FROM.FROM[0].as }];
2✔
324
        }
2✔
325
    }
132✔
326

1✔
327
    /**
1✔
328
     * Searches the FROM and JOIN components of a SELECT to find the table alias.
1✔
329
     * @param {String} tableAlias - Default alias name
1✔
330
     * @param {String} tableName - table name to search for.
1✔
331
     * @param {Object} ast - Abstract Syntax Tree to search
1✔
332
     * @returns {String} - Table alias name.
1✔
333
     */
1✔
334
    static getTableAliasFromJoin(tableAlias, tableName, ast) {
1✔
335
        const astTableBlocks = ['FROM', 'JOIN'];
345✔
336
        let aliasNameFound = tableAlias;
345✔
337

345✔
338
        let i = 0;
345✔
339
        while (aliasNameFound === "" && i < astTableBlocks.length) {
345✔
340
            aliasNameFound = Sql.locateAstTableAlias(tableName, ast, astTableBlocks[i]);
680✔
341
            i++;
680✔
342
        }
680✔
343

345✔
344
        return aliasNameFound;
345✔
345
    }
345✔
346

1✔
347
    /**
1✔
348
     * Searches the UNION portion of the SELECT to locate the table alias.
1✔
349
     * @param {String} tableAlias - default table alias.
1✔
350
     * @param {String} tableName - table name to search for.
1✔
351
     * @param {Object} ast - Abstract Syntax Tree to search
1✔
352
     * @returns {String} - table alias
1✔
353
     */
1✔
354
    getTableAliasUnion(tableAlias, tableName, ast) {
1✔
355
        const astRecursiveTableBlocks = ['UNION', 'UNION ALL', 'INTERSECT', 'EXCEPT'];
345✔
356
        let extractedAlias = tableAlias;
345✔
357

345✔
358
        let i = 0;
345✔
359
        while (extractedAlias === "" && i < astRecursiveTableBlocks.length) {
345✔
360
            if (typeof ast[astRecursiveTableBlocks[i]] !== 'undefined') {
1,309✔
361
                for (const unionAst of ast[astRecursiveTableBlocks[i]]) {
23✔
362
                    extractedAlias = this.getTableAlias(tableName, unionAst);
29✔
363

29✔
364
                    if (extractedAlias !== "")
29✔
365
                        break;
29✔
366
                }
29✔
367
            }
23✔
368
            i++;
1,309✔
369
        }
1,309✔
370

345✔
371
        return extractedAlias;
345✔
372
    }
345✔
373

1✔
374
    /**
1✔
375
     * Search WHERE IN component of SELECT to find table alias.
1✔
376
     * @param {String} tableAlias - default table alias
1✔
377
     * @param {String} tableName - table name to search for
1✔
378
     * @param {Object} ast - Abstract Syntax Tree to search
1✔
379
     * @returns {String} - table alias
1✔
380
     */
1✔
381
    getTableAliasWhereIn(tableAlias, tableName, ast) {
1✔
382
        let extractedAlias = tableAlias;
345✔
383
        if (tableAlias === "" && typeof ast.WHERE !== 'undefined' && ast.WHERE.operator === "IN") {
345✔
384
            extractedAlias = this.getTableAlias(tableName, ast.WHERE.right);
20✔
385
        }
20✔
386

345✔
387
        if (extractedAlias === "" && ast.operator === "IN") {
345✔
388
            extractedAlias = this.getTableAlias(tableName, ast.right);
11✔
389
        }
11✔
390

345✔
391
        return extractedAlias;
345✔
392
    }
345✔
393

1✔
394
    /**
1✔
395
     * Search WHERE terms of SELECT to find table alias.
1✔
396
     * @param {String} tableAlias - default table alias
1✔
397
     * @param {String} tableName  - table name to search for.
1✔
398
     * @param {Object} ast - Abstract Syntax Tree to search.
1✔
399
     * @returns {String} - table alias
1✔
400
     */
1✔
401
    getTableAliasWhereTerms(tableAlias, tableName, ast) {
1✔
402
        let extractedTableAlias = tableAlias;
345✔
403
        if (tableAlias === "" && typeof ast.WHERE !== 'undefined' && typeof ast.WHERE.terms !== 'undefined') {
345✔
404
            for (const term of ast.WHERE.terms) {
37✔
405
                if (extractedTableAlias === "")
82✔
406
                    extractedTableAlias = this.getTableAlias(tableName, term);
82✔
407
            }
82✔
408
        }
37✔
409

345✔
410
        return extractedTableAlias;
345✔
411
    }
345✔
412

1✔
413
    /**
1✔
414
     * Create table definition array from select string.
1✔
415
     * @param {String} statement - full sql select statement.
1✔
416
     * @returns {String[][]} - table definition array.
1✔
417
     */
1✔
418
    static getReferencedTableNames(statement) {
1✔
419
        const ast = SqlParse.sql2ast(statement);
10✔
420
        return this.getReferencedTableNamesFromAst(ast);
10✔
421
    }
10✔
422

1✔
423
    /**
1✔
424
     * Create table definition array from select AST.
1✔
425
     * @param {Object} ast - AST for SELECT. 
1✔
426
     * @returns {any[]} - table definition array.
1✔
427
     * * [0] - table name.
1✔
428
     * * [1] - sheet tab name
1✔
429
     * * [2] - cache seconds
1✔
430
     * * [3] - output column title flag
1✔
431
     */
1✔
432
    static getReferencedTableNamesFromAst(ast) {
1✔
433
        const DEFAULT_CACHE_SECONDS = 60;
32✔
434
        const DEFAULT_COLUMNS_OUTPUT = true;
32✔
435
        const tableSet = new Map();
32✔
436

32✔
437
        Sql.extractAstTables(ast, tableSet);
32✔
438

32✔
439
        const tableList = [];
32✔
440
        // @ts-ignore
32✔
441
        for (const key of tableSet.keys()) {
32✔
442
            const tableDef = [key, key, DEFAULT_CACHE_SECONDS, DEFAULT_COLUMNS_OUTPUT];
46✔
443

46✔
444
            tableList.push(tableDef);
46✔
445
        }
46✔
446

32✔
447
        return tableList;
32✔
448
    }
32✔
449

1✔
450
    /**
1✔
451
     * Search for all referenced tables in SELECT.
1✔
452
     * @param {Object} ast - AST for SELECT.
1✔
453
     * @param {Map<String,String>} tableSet  - Function updates this map of table names and alias name.
1✔
454
     */
1✔
455
    static extractAstTables(ast, tableSet) {
1✔
456
        Sql.getTableNamesFromOrJoin(ast, tableSet);
47✔
457
        Sql.getTableNamesUnion(ast, tableSet);
47✔
458
        Sql.getTableNamesWhereIn(ast, tableSet);
47✔
459
        Sql.getTableNamesWhereTerms(ast, tableSet);
47✔
460
        Sql.getTableNamesCorrelatedSelect(ast, tableSet);
47✔
461
    }
47✔
462

1✔
463
    /**
1✔
464
     * Search for referenced table in FROM or JOIN part of select.
1✔
465
     * @param {Object} ast - AST for SELECT.
1✔
466
     * @param {Map<String,String>} tableSet  - Function updates this map of table names and alias name.
1✔
467
     */
1✔
468
    static getTableNamesFromOrJoin(ast, tableSet) {
1✔
469
        const astTableBlocks = ['FROM', 'JOIN'];
47✔
470

47✔
471
        for (const astBlock of astTableBlocks) {
47✔
472
            if (typeof ast[astBlock] === 'undefined')
94✔
473
                continue;
94✔
474

43✔
475
            let blockData = ast[astBlock];
43✔
476

43✔
477
            //  In the case where FROM (select sub-query) it will not be iterable.
43✔
478
            if (!this.isIterable(blockData) && astBlock === 'FROM') {
94✔
479
                blockData = blockData.FROM;
1✔
480
            }
1✔
481

43✔
482
            for (const astItem of blockData) {
94✔
483
                tableSet.set(astItem.table.toUpperCase(), astItem.as.toUpperCase());
46✔
484
            }
46✔
485
        }
43✔
486
    }
47✔
487

1✔
488
    /**
1✔
489
     * Check if input is iterable.
1✔
490
     * @param {any} input - Check this object to see if it can be iterated. 
1✔
491
     * @returns {Boolean} - true - can be iterated.  false - cannot be iterated.
1✔
492
     */
1✔
493
    static isIterable(input) {
1✔
494
        if (input === null || input === undefined) {
43!
495
            return false
×
496
        }
×
497

43✔
498
        return typeof input[Symbol.iterator] === 'function'
43✔
499
    }
43✔
500

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

47✔
509
        for (const block of astRecursiveTableBlocks) {
47✔
510
            if (typeof ast[block] !== 'undefined') {
188✔
511
                for (const unionAst of ast[block]) {
1✔
512
                    this.extractAstTables(unionAst, tableSet);
1✔
513
                }
1✔
514
            }
1✔
515
        }
188✔
516
    }
47✔
517

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

47✔
530
        if (subQueryTerms.indexOf(ast.operator) !== -1) {
47✔
531
            this.extractAstTables(ast.right, tableSet);
3✔
532
        }
3✔
533
    }
47✔
534

1✔
535
    /**
1✔
536
     * Search WHERE to find referenced table names.
1✔
537
     * @param {Object} ast -  AST to search.
1✔
538
     * @param {Map<String,String>} tableSet - Function updates this map of table names and alias name.
1✔
539
     */
1✔
540
    static getTableNamesWhereTerms(ast, tableSet) {
1✔
541
        if (typeof ast.WHERE !== 'undefined' && typeof ast.WHERE.terms !== 'undefined') {
47✔
542
            for (const term of ast.WHERE.terms) {
3✔
543
                this.extractAstTables(term, tableSet);
7✔
544
            }
7✔
545
        }
3✔
546
    }
47✔
547

1✔
548
    /**
1✔
549
     * Search CORRELATES sub-query for table names.
1✔
550
     * @param {*} ast - AST to search
1✔
551
     * @param {*} tableSet - Function updates this map of table names and alias name.
1✔
552
     */
1✔
553
    static getTableNamesCorrelatedSelect(ast, tableSet) {
1✔
554
        if (typeof ast.SELECT !== 'undefined') {
47✔
555
            for (const term of ast.SELECT) {
40✔
556
                if (typeof term.subQuery !== 'undefined' && term.subQuery !== null) {
64✔
557
                    this.extractAstTables(term.subQuery, tableSet);
1✔
558
                }
1✔
559
            }
64✔
560
        }
40✔
561
    }
47✔
562

1✔
563
    /**
1✔
564
     * Search a property of AST for table alias name.
1✔
565
     * @param {String} tableName - Table name to find in AST.
1✔
566
     * @param {Object} ast - AST of SELECT.
1✔
567
     * @param {String} astBlock - AST property to search.
1✔
568
     * @returns {String} - Alias name or "" if not found.
1✔
569
     */
1✔
570
    static locateAstTableAlias(tableName, ast, astBlock) {
1✔
571
        if (typeof ast[astBlock] === 'undefined')
680✔
572
            return "";
680✔
573

330✔
574
        for (const astItem of ast[astBlock]) {
680✔
575
            if (tableName === astItem.table.toUpperCase() && astItem.as !== "") {
402✔
576
                return astItem.as;
17✔
577
            }
17✔
578
        }
402✔
579

313✔
580
        return "";
313✔
581
    }
680✔
582

1✔
583
    /**
1✔
584
     * Load SELECT data and return in double array.
1✔
585
     * @param {Object} selectAst - Abstract Syntax Tree of SELECT
1✔
586
     * @returns {any[][]} - double array useable by Google Sheet in custom function return value.
1✔
587
     * * First row of data will be column name if column title output was requested.
1✔
588
     * * First Array Index - ROW
1✔
589
     * * Second Array Index - COLUMN
1✔
590
     */
1✔
591
    select(selectAst) {
1✔
592
        let recordIDs = [];
208✔
593
        let viewTableData = [];
208✔
594
        let ast = selectAst;
208✔
595

208✔
596
        if (typeof ast.FROM === 'undefined')
208✔
597
            throw new Error("Missing keyword FROM");
208✔
598

207✔
599
        //  Manipulate AST to add GROUP BY if DISTINCT keyword.
207✔
600
        ast = Sql.distinctField(ast);
207✔
601

207✔
602
        //  Manipulate AST add pivot fields.
207✔
603
        ast = this.pivotField(ast);
207✔
604

207✔
605
        const view = new SelectTables(ast, this.tables, this.bindParameters);
207✔
606

207✔
607
        //  JOIN tables to create a derived table.
207✔
608
        view.join(ast);
207✔
609

207✔
610
        //  Get the record ID's of all records matching WHERE condition.
207✔
611
        recordIDs = view.whereCondition(ast);
207✔
612

207✔
613
        //  Get selected data records.
207✔
614
        viewTableData = view.getViewData(recordIDs);
207✔
615

207✔
616
        //  Compress the data.
207✔
617
        viewTableData = view.groupBy(ast, viewTableData);
207✔
618

207✔
619
        //  Sort our selected data.
207✔
620
        view.orderBy(ast, viewTableData);
207✔
621

207✔
622
        //  Fields referenced but not included in SELECT field list.
207✔
623
        view.removeTempColumns(viewTableData);
207✔
624

207✔
625
        if (typeof ast.LIMIT !== 'undefined') {
208✔
626
            const maxItems = ast.LIMIT.nb;
2✔
627
            if (viewTableData.length > maxItems)
2✔
628
                viewTableData.splice(maxItems);
2✔
629
        }
2✔
630

187✔
631
        //  Apply SET rules for various union types.
187✔
632
        viewTableData = this.unionSets(ast, viewTableData);
187✔
633

187✔
634
        if (this.columnTitle) {
208✔
635
            viewTableData.unshift(view.getColumnTitles());
108✔
636
        }
108✔
637

186✔
638
        if (viewTableData.length === 0) {
208✔
639
            viewTableData.push([""]);
7✔
640
        }
7✔
641

186✔
642
        if (viewTableData.length === 1 && viewTableData[0].length === 0) {
208✔
643
            viewTableData[0] = [""];
4✔
644
        }
4✔
645

186✔
646
        return viewTableData;
186✔
647
    }
208✔
648

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

207✔
657
        if (astFields.length > 0) {
207✔
658
            const firstField = astFields[0].name.toUpperCase();
207✔
659
            if (firstField.startsWith("DISTINCT")) {
207✔
660
                astFields[0].name = firstField.replace("DISTINCT", "").trim();
7✔
661

7✔
662
                if (typeof ast['GROUP BY'] === 'undefined') {
7✔
663
                    const groupBy = [];
7✔
664

7✔
665
                    for (const astItem of astFields) {
7✔
666
                        groupBy.push({ column: astItem.name });
7✔
667
                    }
7✔
668

7✔
669
                    ast["GROUP BY"] = groupBy;
7✔
670
                }
7✔
671
            }
7✔
672
        }
207✔
673

207✔
674
        return ast;
207✔
675
    }
207✔
676

1✔
677
    /**
1✔
678
     * Add new column to AST for every AGGREGATE function and unique pivot column data.
1✔
679
     * @param {Object} ast - AST which is checked to see if a PIVOT is used.
1✔
680
     * @returns {Object} - Updated AST containing SELECT FIELDS for the pivot data OR original AST if no pivot.
1✔
681
     */
1✔
682
    pivotField(ast) {
1✔
683
        //  If we are doing a PIVOT, it then requires a GROUP BY.
207✔
684
        if (typeof ast.PIVOT !== 'undefined') {
207✔
685
            if (typeof ast['GROUP BY'] === 'undefined')
7✔
686
                throw new Error("PIVOT requires GROUP BY");
7✔
687
        }
7✔
688
        else
200✔
689
            return ast;
200✔
690

6✔
691
        // These are all of the unique PIVOT field data points.
6✔
692
        const pivotFieldData = this.getUniquePivotData(ast);
6✔
693

6✔
694
        ast.SELECT = Sql.addCalculatedPivotFieldsToAst(ast, pivotFieldData);
6✔
695

6✔
696
        return ast;
6✔
697
    }
207✔
698

1✔
699
    /**
1✔
700
     * Find distinct pivot column data.
1✔
701
     * @param {Object} ast - Abstract Syntax Tree containing the PIVOT option.
1✔
702
     * @returns {any[][]} - All unique data points found in the PIVOT field for the given SELECT.
1✔
703
     */
1✔
704
    getUniquePivotData(ast) {
1✔
705
        const pivotAST = {};
6✔
706

6✔
707
        pivotAST.SELECT = ast.PIVOT;
6✔
708
        pivotAST.SELECT[0].name = `DISTINCT ${pivotAST.SELECT[0].name}`;
6✔
709
        pivotAST.FROM = ast.FROM;
6✔
710
        pivotAST.WHERE = ast.WHERE;
6✔
711

6✔
712
        // These are all of the unique PIVOT field data points.
6✔
713
        const oldSetting = this.columnTitle;
6✔
714
        const oldBindVariables = [...this.bindParameters];
6✔
715
        this.columnTitle = false;
6✔
716
        const tableData = this.select(pivotAST);
6✔
717
        this.columnTitle = oldSetting;
6✔
718
        this.bindParameters = oldBindVariables;
6✔
719

6✔
720
        return tableData;
6✔
721
    }
6✔
722

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

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

11✔
743
                for (const fld of pivotFieldData) {
11✔
744
                    const caseTxt = `${matches[0]}(CASE WHEN ${ast.PIVOT[0].name} = '${fld}' THEN ${args[1]} ELSE 'null' END)`;
44✔
745
                    const asField = `${fld[0]} ${typeof selectField.as !== 'undefined' && selectField.as !== "" ? selectField.as : selectField.name}`;
44✔
746
                    newPivotAstFields.push({ name: caseTxt, as: asField });
44✔
747
                }
44✔
748
            }
11✔
749
            else
6✔
750
                newPivotAstFields.push(selectField);
6✔
751
        }
17✔
752

6✔
753
        return newPivotAstFields;
6✔
754
    }
6✔
755

1✔
756
    /**
1✔
757
     * If any SET commands are found (like UNION, INTERSECT,...) the additional SELECT is done.  The new
1✔
758
     * data applies the SET rule against the income viewTableData, and the result data set is returned.
1✔
759
     * @param {Object} ast - SELECT AST.
1✔
760
     * @param {any[][]} viewTableData - SELECTED data before UNION.
1✔
761
     * @returns {any[][]} - New data with set rules applied.
1✔
762
     */
1✔
763
    unionSets(ast, viewTableData) {
1✔
764
        const unionTypes = ['UNION', 'UNION ALL', 'INTERSECT', 'EXCEPT'];
187✔
765
        let unionTableData = viewTableData;
187✔
766

187✔
767
        for (const type of unionTypes) {
187✔
768
            if (typeof ast[type] !== 'undefined') {
745✔
769
                const unionSQL = new Sql()
9✔
770
                    .setBindValues(this.bindParameters)
9✔
771
                    .setTables(this.tables);
9✔
772
                for (const union of ast[type]) {
9✔
773
                    const unionData = unionSQL.select(union);
11✔
774
                    if (unionTableData.length > 0 && unionData.length > 0 && unionTableData[0].length !== unionData[0].length)
11✔
775
                        throw new Error(`Invalid ${type}.  Selected field counts do not match.`);
11✔
776

10✔
777
                    switch (type) {
10✔
778
                        case "UNION":
11✔
779
                            //  Remove duplicates.
4✔
780
                            unionTableData = Sql.appendUniqueRows(unionTableData, unionData);
4✔
781
                            break;
4✔
782

11✔
783
                        case "UNION ALL":
11✔
784
                            //  Allow duplicates.
4✔
785
                            unionTableData = unionTableData.concat(unionData);
4✔
786
                            break;
4✔
787

11✔
788
                        case "INTERSECT":
11✔
789
                            //  Must exist in BOTH tables.
1✔
790
                            unionTableData = Sql.intersectRows(unionTableData, unionData);
1✔
791
                            break;
1✔
792

11✔
793
                        case "EXCEPT":
11✔
794
                            //  Remove from first table all rows that match in second table.
1✔
795
                            unionTableData = Sql.exceptRows(unionTableData, unionData);
1✔
796
                            break;
1✔
797

11✔
798
                        default:
11!
799
                            throw new Error(`Internal error.  Unsupported UNION type: ${type}`);
×
800
                    }
11✔
801
                }
11✔
802
            }
8✔
803
        }
745✔
804

186✔
805
        return unionTableData;
186✔
806
    }
187✔
807

1✔
808
    /**
1✔
809
     * Appends any row in newData that does not exist in srcData.
1✔
810
     * @param {any[][]} srcData - existing table data
1✔
811
     * @param {any[][]} newData - new table data
1✔
812
     * @returns {any[][]} - srcData rows PLUS any row in newData that is NOT in srcData.
1✔
813
     */
1✔
814
    static appendUniqueRows(srcData, newData) {
1✔
815
        const srcMap = new Map();
4✔
816

4✔
817
        for (const srcRow of srcData) {
4✔
818
            srcMap.set(srcRow.join("::"), true);
13✔
819
        }
13✔
820

4✔
821
        for (const newRow of newData) {
4✔
822
            const key = newRow.join("::");
22✔
823
            if (!srcMap.has(key)) {
22✔
824
                srcData.push(newRow);
20✔
825
                srcMap.set(key, true);
20✔
826
            }
20✔
827
        }
22✔
828
        return srcData;
4✔
829
    }
4✔
830

1✔
831
    /**
1✔
832
     * Finds the rows that are common between srcData and newData
1✔
833
     * @param {any[][]} srcData - table data
1✔
834
     * @param {any[][]} newData - table data
1✔
835
     * @returns {any[][]} - returns only rows that intersect srcData and newData.
1✔
836
     */
1✔
837
    static intersectRows(srcData, newData) {
1✔
838
        const srcMap = new Map();
1✔
839
        const intersectTable = [];
1✔
840

1✔
841
        for (const srcRow of srcData) {
1✔
842
            srcMap.set(srcRow.join("::"), true);
10✔
843
        }
10✔
844

1✔
845
        for (const newRow of newData) {
1✔
846
            if (srcMap.has(newRow.join("::"))) {
5✔
847
                intersectTable.push(newRow);
1✔
848
            }
1✔
849
        }
5✔
850
        return intersectTable;
1✔
851
    }
1✔
852

1✔
853
    /**
1✔
854
     * Returns all rows in srcData MINUS any rows that match it from newData.
1✔
855
     * @param {any[][]} srcData - starting table
1✔
856
     * @param {any[][]} newData  - minus table (if it matches srcData row)
1✔
857
     * @returns {any[][]} - srcData MINUS newData
1✔
858
     */
1✔
859
    static exceptRows(srcData, newData) {
1✔
860
        const srcMap = new Map();
1✔
861
        let rowNum = 0;
1✔
862
        for (const srcRow of srcData) {
1✔
863
            srcMap.set(srcRow.join("::"), rowNum);
5✔
864
            rowNum++;
5✔
865
        }
5✔
866

1✔
867
        const removeRowNum = [];
1✔
868
        for (const newRow of newData) {
1✔
869
            const key = newRow.join("::");
2✔
870
            if (srcMap.has(key)) {
2✔
871
                removeRowNum.push(srcMap.get(key));
2✔
872
            }
2✔
873
        }
2✔
874

1✔
875
        removeRowNum.sort(function (a, b) { return b - a });
1✔
876
        for (rowNum of removeRowNum) {
1✔
877
            srcData.splice(rowNum, 1);
2✔
878
        }
2✔
879

1✔
880
        return srcData;
1✔
881
    }
1✔
882
}
1✔
883

884

885

STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc