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

demmings / gsSQL / 3959887931

pending completion
3959887931

push

github

cdemmigs
Enhanced support for CORRELATED sub-query.

1214 of 1286 branches covered (94.4%)

Branch coverage included in aggregate %.

254 of 254 new or added lines in 6 files covered. (100.0%)

9415 of 9813 relevant lines covered (95.94%)

517.96 hits per line

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

98.56
/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.*/
279✔
121
        this.tables = new Map();
279✔
122
        /** @property {Boolean} - Are column tables to be ouptout? */
279✔
123
        this.columnTitle = false;
279✔
124
        /** @property {any[]} - List of BIND data linked to '?' in statement. */
279✔
125
        this.bindParameters = [];
279✔
126
    }
279✔
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;
247✔
138

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

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

245✔
152
        return this;
245✔
153
    }
247✔
154

1✔
155
    /**
1✔
156
     * 
1✔
157
     * @param {Map<String,Table>} tableMap 
1✔
158
     */
1✔
159
    copyTableData(tableMap) {
1✔
160
        // @ts-ignore
15✔
161
        for (const tableName of tableMap.keys()) {
15✔
162
            const tableInfo = tableMap.get(tableName);
33✔
163
            this.addTableData(tableName, tableInfo.tableData);  
33✔
164
        }
33✔
165

15✔
166
        return this;
15✔
167
    }
15✔
168

1✔
169
    /**
1✔
170
     * Include column headers in return data.
1✔
171
     * @param {Boolean} value - true will return column names in first row of return data.
1✔
172
     * @returns {Sql}
1✔
173
     */
1✔
174
    enableColumnTitle(value) {
1✔
175
        this.columnTitle = value;
145✔
176
        return this;
145✔
177
    }
145✔
178

1✔
179
    /**
1✔
180
     * Query if this instance of Sql() will generate column titles.
1✔
181
     * @returns {Boolean}
1✔
182
     */
1✔
183
    areColumnTitlesOutput() {
1✔
184
        return this.columnTitle;
138✔
185
    }
138✔
186

1✔
187
    /**
1✔
188
     * Add a bind data value.  Must be added in order.  If bind data is a named range, use addBindNamedRangeParameter().
1✔
189
     * @param {any} value - literal data. 
1✔
190
     * @returns {Sql}
1✔
191
     */
1✔
192
    addBindParameter(value) {
1✔
193
        this.bindParameters.push(value);
37✔
194
        return this;
37✔
195
    }
37✔
196

1✔
197
    /**
1✔
198
     * List of bind data added so far.
1✔
199
     * @returns {any[]}
1✔
200
     */
1✔
201
    getBindData() {
1✔
202
        return this.bindParameters;
138✔
203
    }
138✔
204

1✔
205
    /**
1✔
206
     * The BIND data is a sheet named range that will be read and used for bind data.
1✔
207
     * @param {String} value - Sheets Named Range for SINGLE CELL only.
1✔
208
     * @returns {Sql}
1✔
209
     */
1✔
210
    addBindNamedRangeParameter(value) {
1✔
211
        const namedValue = TableData.getValueCached(value, 30);
12✔
212
        this.bindParameters.push(namedValue);
12✔
213
        Logger.log(`BIND=${value} = ${namedValue}`);
12✔
214
        return this;
12✔
215
    }
12✔
216

1✔
217
    /**
1✔
218
     * Set all bind data at once using array.
1✔
219
     * @param {any[]} value - List of all needed BIND data.
1✔
220
     * @returns {Sql}
1✔
221
     */
1✔
222
    setBindValues(value) {
1✔
223
        this.bindParameters = value;
142✔
224
        return this;
142✔
225
    }
142✔
226

1✔
227
    /**
1✔
228
     * Clears existing BIND data so Sql() instance can be used again with new bind parameters.
1✔
229
     * @returns {Sql}
1✔
230
     */
1✔
231
    clearBindParameters() {
1✔
232
        this.bindParameters = [];
1✔
233
        return this;
1✔
234
    }
1✔
235

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

281✔
270
        if (typeof statement === 'string') {
281✔
271
            this.ast = SqlParse.sql2ast(statement);
139✔
272
        }
139✔
273
        else {
142✔
274
            this.ast = statement;
142✔
275
        }
142✔
276

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

280✔
282
        Sql.setTableAlias(this.tables, this.ast);
280✔
283
        Sql.loadSchema(this.tables);
280✔
284

280✔
285
        if (typeof this.ast.SELECT !== 'undefined')
280✔
286
            sqlData = this.select(this.ast);
281✔
287
        else
1✔
288
            throw new Error("Only SELECT statements are supported.");
1✔
289

260✔
290
        return sqlData;
260✔
291
    }
281✔
292

1✔
293
    /**
1✔
294
     * Updates 'tables' with table column information.
1✔
295
     * @param {Map<String,Table>} tables 
1✔
296
     */
1✔
297
    static loadSchema(tables) {
1✔
298
        // @ts-ignore
280✔
299
        for (const table of tables.keys()) {
280✔
300
            const tableInfo = tables.get(table.toUpperCase());
419✔
301
            tableInfo.loadSchema();
419✔
302
        }
419✔
303
    }
280✔
304

1✔
305
    /**
1✔
306
     * Updates 'tables' with associated table ALIAS name found in ast.
1✔
307
     * @param {Map<String,Table>} tables 
1✔
308
     * @param {Object} ast 
1✔
309
     */
1✔
310
    static setTableAlias(tables, ast) {
1✔
311
        // @ts-ignore
280✔
312
        for (const table of tables.keys()) {
280✔
313
            const tableAlias = Sql.getTableAlias(table, ast);
419✔
314
            const tableInfo = tables.get(table.toUpperCase());
419✔
315
            tableInfo.setTableAlias(tableAlias);
419✔
316
        }
419✔
317
    }
280✔
318

1✔
319
    /**
1✔
320
     * Sets all tables referenced SELECT.
1✔
321
    * @param {Map<String,Table>} mapOfTables - Map of referenced tables indexed by TABLE name.
1✔
322
    */
1✔
323
    setTables(mapOfTables) {
1✔
324
        this.tables = mapOfTables;
125✔
325
        return this;
125✔
326
    }
125✔
327

1✔
328
    /**
1✔
329
     * Returns a map of all tables configured for this SELECT.
1✔
330
     * @returns {Map<String,Table>} - Map of referenced tables indexed by TABLE name.
1✔
331
     */
1✔
332
    getTables() {
1✔
333
        return this.tables;
153✔
334
    }
153✔
335

1✔
336
    /**
1✔
337
    * Find table alias name (if any) for input actual table name.
1✔
338
    * @param {String} tableName - Actual table name.
1✔
339
    * @param {Object} ast - Abstract Syntax Tree for SQL.
1✔
340
    * @returns {String} - Table alias.  Empty string if not found.
1✔
341
    */
1✔
342
    static getTableAlias(tableName, ast) {
1✔
343
        let tableAlias = "";
581✔
344
        const ucTableName = tableName.toUpperCase();
581✔
345

581✔
346
        tableAlias = Sql.getTableAliasFromJoin(tableAlias, ucTableName, ast);
581✔
347
        tableAlias = Sql.getTableAliasUnion(tableAlias, ucTableName, ast);
581✔
348
        tableAlias = Sql.getTableAliasWhereIn(tableAlias, ucTableName, ast);
581✔
349
        tableAlias = Sql.getTableAliasWhereTerms(tableAlias, ucTableName, ast);
581✔
350

581✔
351
        return tableAlias;
581✔
352
    }
581✔
353

1✔
354
    /**
1✔
355
     * Modifies AST when FROM is a sub-query rather than a table name.
1✔
356
     */
1✔
357
    selectFromSubQuery() {
1✔
358
        if (typeof this.ast.FROM !== 'undefined' && typeof this.ast.FROM.SELECT !== 'undefined') {
280✔
359
            const data = new Sql()
2✔
360
                .setTables(this.tables)
2✔
361
                .enableColumnTitle(true)
2✔
362
                .execute(this.ast.FROM);
2✔
363
            this.addTableData(this.ast.FROM.FROM[0].as, data);
2✔
364
            this.ast.FROM = [{ table: this.ast.FROM.FROM[0].as, as: this.ast.FROM.FROM[0].as }];
2✔
365
        }
2✔
366
    }
280✔
367

1✔
368
    /**
1✔
369
     * Searches the FROM and JOIN components of a SELECT to find the table alias.
1✔
370
     * @param {String} tableAlias - Default alias name
1✔
371
     * @param {String} tableName - table name to search for.
1✔
372
     * @param {Object} ast - Abstract Syntax Tree to search
1✔
373
     * @returns {String} - Table alias name.
1✔
374
     */
1✔
375
    static getTableAliasFromJoin(tableAlias, tableName, ast) {
1✔
376
        const astTableBlocks = ['FROM', 'JOIN'];
581✔
377
        let aliasNameFound = tableAlias;
581✔
378

581✔
379
        let i = 0;
581✔
380
        while (aliasNameFound === "" && i < astTableBlocks.length) {
581✔
381
            aliasNameFound = Sql.locateAstTableAlias(tableName, ast, astTableBlocks[i]);
1,133✔
382
            i++;
1,133✔
383
        }
1,133✔
384

581✔
385
        return aliasNameFound;
581✔
386
    }
581✔
387

1✔
388
    /**
1✔
389
     * Searches the UNION portion of the SELECT to locate the table alias.
1✔
390
     * @param {String} tableAlias - default table alias.
1✔
391
     * @param {String} tableName - table name to search for.
1✔
392
     * @param {Object} ast - Abstract Syntax Tree to search
1✔
393
     * @returns {String} - table alias
1✔
394
     */
1✔
395
    static getTableAliasUnion(tableAlias, tableName, ast) {
1✔
396
        const astRecursiveTableBlocks = ['UNION', 'UNION ALL', 'INTERSECT', 'EXCEPT'];
581✔
397
        let extractedAlias = tableAlias;
581✔
398

581✔
399
        let i = 0;
581✔
400
        while (extractedAlias === "" && i < astRecursiveTableBlocks.length) {
581✔
401
            if (typeof ast[astRecursiveTableBlocks[i]] !== 'undefined') {
2,177✔
402
                for (const unionAst of ast[astRecursiveTableBlocks[i]]) {
23✔
403
                    extractedAlias = Sql.getTableAlias(tableName, unionAst);
29✔
404

29✔
405
                    if (extractedAlias !== "")
29✔
406
                        break;
29✔
407
                }
29✔
408
            }
23✔
409
            i++;
2,177✔
410
        }
2,177✔
411

581✔
412
        return extractedAlias;
581✔
413
    }
581✔
414

1✔
415
    /**
1✔
416
     * Search WHERE IN component of SELECT to find table alias.
1✔
417
     * @param {String} tableAlias - default table alias
1✔
418
     * @param {String} tableName - table name to search for
1✔
419
     * @param {Object} ast - Abstract Syntax Tree to search
1✔
420
     * @returns {String} - table alias
1✔
421
     */
1✔
422
    static getTableAliasWhereIn(tableAlias, tableName, ast) {
1✔
423
        let extractedAlias = tableAlias;
581✔
424
        if (tableAlias === "" && typeof ast.WHERE !== 'undefined' && ast.WHERE.operator === "IN") {
581✔
425
            extractedAlias = Sql.getTableAlias(tableName, ast.WHERE.right);
29✔
426
        }
29✔
427

581✔
428
        if (extractedAlias === "" && ast.operator === "IN") {
581✔
429
            extractedAlias = Sql.getTableAlias(tableName, ast.right);
11✔
430
        }
11✔
431

581✔
432
        return extractedAlias;
581✔
433
    }
581✔
434

1✔
435
    /**
1✔
436
     * Search WHERE terms of SELECT to find table alias.
1✔
437
     * @param {String} tableAlias - default table alias
1✔
438
     * @param {String} tableName  - table name to search for.
1✔
439
     * @param {Object} ast - Abstract Syntax Tree to search.
1✔
440
     * @returns {String} - table alias
1✔
441
     */
1✔
442
    static getTableAliasWhereTerms(tableAlias, tableName, ast) {
1✔
443
        let extractedTableAlias = tableAlias;
581✔
444
        if (tableAlias === "" && typeof ast.WHERE !== 'undefined' && typeof ast.WHERE.terms !== 'undefined') {
581✔
445
            for (const term of ast.WHERE.terms) {
44✔
446
                if (extractedTableAlias === "")
96✔
447
                    extractedTableAlias = Sql.getTableAlias(tableName, term);
96✔
448
            }
96✔
449
        }
44✔
450

581✔
451
        return extractedTableAlias;
581✔
452
    }
581✔
453

1✔
454
    /**
1✔
455
     * Create table definition array from select string.
1✔
456
     * @param {String} statement - full sql select statement.
1✔
457
     * @returns {String[][]} - table definition array.
1✔
458
     */
1✔
459
    static getReferencedTableNames(statement) {
1✔
460
        const ast = SqlParse.sql2ast(statement);
10✔
461
        return this.getReferencedTableNamesFromAst(ast);
10✔
462
    }
10✔
463

1✔
464
    /**
1✔
465
     * Create table definition array from select AST.
1✔
466
     * @param {Object} ast - AST for SELECT. 
1✔
467
     * @returns {any[]} - table definition array.
1✔
468
     * * [0] - table name.
1✔
469
     * * [1] - sheet tab name
1✔
470
     * * [2] - cache seconds
1✔
471
     * * [3] - output column title flag
1✔
472
     */
1✔
473
    static getReferencedTableNamesFromAst(ast) {
1✔
474
        const DEFAULT_CACHE_SECONDS = 60;
30✔
475
        const DEFAULT_COLUMNS_OUTPUT = true;
30✔
476
        const tableSet = new Map();
30✔
477

30✔
478
        Sql.extractAstTables(ast, tableSet);
30✔
479

30✔
480
        const tableList = [];
30✔
481
        // @ts-ignore
30✔
482
        for (const key of tableSet.keys()) {
30✔
483
            const tableDef = [key, key, DEFAULT_CACHE_SECONDS, DEFAULT_COLUMNS_OUTPUT];
44✔
484

44✔
485
            tableList.push(tableDef);
44✔
486
        }
44✔
487

30✔
488
        return tableList;
30✔
489
    }
30✔
490

1✔
491
    /**
1✔
492
     * Search for all referenced tables in SELECT.
1✔
493
     * @param {Object} ast - AST for SELECT.
1✔
494
     * @param {Map<String,String>} tableSet  - Function updates this map of table names and alias name.
1✔
495
     */
1✔
496
    static extractAstTables(ast, tableSet) {
1✔
497
        Sql.getTableNamesFromOrJoin(ast, tableSet);
75✔
498
        Sql.getTableNamesUnion(ast, tableSet);
75✔
499
        Sql.getTableNamesWhereIn(ast, tableSet);
75✔
500
        Sql.getTableNamesWhereTerms(ast, tableSet);
75✔
501
        Sql.getTableNamesCorrelatedSelect(ast, tableSet);
75✔
502
    }
75✔
503

1✔
504
    /**
1✔
505
     * Search for referenced table in FROM or JOIN part of select.
1✔
506
     * @param {Object} ast - AST for SELECT.
1✔
507
     * @param {Map<String,String>} tableSet  - Function updates this map of table names and alias name.
1✔
508
     */
1✔
509
    static getTableNamesFromOrJoin(ast, tableSet) {
1✔
510
        const astTableBlocks = ['FROM', 'JOIN'];
75✔
511

75✔
512
        for (const astBlock of astTableBlocks) {
75✔
513
            if (typeof ast[astBlock] === 'undefined')
150✔
514
                continue;
150✔
515

71✔
516
            let blockData = ast[astBlock];
71✔
517

71✔
518
            //  In the case where FROM (select sub-query) it will not be iterable.
71✔
519
            if (!this.isIterable(blockData) && astBlock === 'FROM') {
150✔
520
                blockData = blockData.FROM;
1✔
521
            }
1✔
522

71✔
523
            for (const astItem of blockData) {
150✔
524
                tableSet.set(astItem.table.toUpperCase(), astItem.as.toUpperCase());
74✔
525
            }
74✔
526
        }
71✔
527
    }
75✔
528

1✔
529
    /**
1✔
530
     * Check if input is iterable.
1✔
531
     * @param {any} input - Check this object to see if it can be iterated. 
1✔
532
     * @returns {Boolean} - true - can be iterated.  false - cannot be iterated.
1✔
533
     */
1✔
534
    static isIterable(input) {
1✔
535
        if (input === null || input === undefined) {
71!
536
            return false
×
537
        }
×
538

71✔
539
        return typeof input[Symbol.iterator] === 'function'
71✔
540
    }
71✔
541

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

75✔
550
        for (const block of astRecursiveTableBlocks) {
75✔
551
            if (typeof ast[block] !== 'undefined') {
300✔
552
                for (const unionAst of ast[block]) {
1✔
553
                    this.extractAstTables(unionAst, tableSet);
1✔
554
                }
1✔
555
            }
1✔
556
        }
300✔
557
    }
75✔
558

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

75✔
571
        if (subQueryTerms.indexOf(ast.operator) !== -1) {
75✔
572
            this.extractAstTables(ast.right, tableSet);
3✔
573
        }
3✔
574
    }
75✔
575

1✔
576
    /**
1✔
577
     * Search WHERE to find referenced table names.
1✔
578
     * @param {Object} ast -  AST to search.
1✔
579
     * @param {Map<String,String>} tableSet - Function updates this map of table names and alias name.
1✔
580
     */
1✔
581
    static getTableNamesWhereTerms(ast, tableSet) {
1✔
582
        if (typeof ast.WHERE !== 'undefined' && typeof ast.WHERE.terms !== 'undefined') {
75✔
583
            for (const term of ast.WHERE.terms) {
3✔
584
                this.extractAstTables(term, tableSet);
7✔
585
            }
7✔
586
        }
3✔
587
    }
75✔
588

1✔
589
    static getTableNamesWhereCondition(ast, tableSet) {
1✔
590
        if (typeof ast.WHERE !== 'undefined') {
29✔
591
            const lParts = typeof ast.WHERE.left === 'string' ? ast.WHERE.left.split(".") : [];
18!
592
            if (lParts.length > 1) {
18✔
593
                tableSet.set(lParts[0].toUpperCase(), "");
9✔
594
            }
9✔
595
            const rParts = typeof ast.WHERE.right === 'string' ? ast.WHERE.right.split(".") : [];
18✔
596
            if (rParts.length > 1) {
18✔
597
                tableSet.set(rParts[0].toUpperCase(), "");
5✔
598
            }
5✔
599

18✔
600
            if (typeof ast.WHERE.terms !== 'undefined') {
18!
601
                Sql.getTableNamesWhereCondition(ast.WHERE.terms, tableSet);
×
602
            }
×
603
        }
18✔
604
    }
29✔
605

1✔
606
    /**
1✔
607
     * Search CORRELATES sub-query for table names.
1✔
608
     * @param {*} ast - AST to search
1✔
609
     * @param {*} tableSet - Function updates this map of table names and alias name.
1✔
610
     */
1✔
611
    static getTableNamesCorrelatedSelect(ast, tableSet) {
1✔
612
        if (typeof ast.SELECT !== 'undefined') {
75✔
613
            for (const term of ast.SELECT) {
68✔
614
                if (typeof term.subQuery !== 'undefined' && term.subQuery !== null) {
92✔
615
                    this.extractAstTables(term.subQuery, tableSet);
1✔
616
                }
1✔
617
            }
92✔
618
        }
68✔
619
    }
75✔
620

1✔
621
    /**
1✔
622
     * Search a property of AST for table alias name.
1✔
623
     * @param {String} tableName - Table name to find in AST.
1✔
624
     * @param {Object} ast - AST of SELECT.
1✔
625
     * @param {String} astBlock - AST property to search.
1✔
626
     * @returns {String} - Alias name or "" if not found.
1✔
627
     */
1✔
628
    static locateAstTableAlias(tableName, ast, astBlock) {
1✔
629
        if (typeof ast[astBlock] === 'undefined')
1,133✔
630
            return "";
1,133✔
631

552✔
632
        for (const astItem of ast[astBlock]) {
1,133✔
633
            if (tableName === astItem.table.toUpperCase() && astItem.as !== "") {
624✔
634
                return astItem.as;
36✔
635
            }
36✔
636
        }
624✔
637

516✔
638
        return "";
516✔
639
    }
1,133✔
640

1✔
641
    /**
1✔
642
     * Load SELECT data and return in double array.
1✔
643
     * @param {Object} selectAst - Abstract Syntax Tree of SELECT
1✔
644
     * @returns {any[][]} - double array useable by Google Sheet in custom function return value.
1✔
645
     * * First row of data will be column name if column title output was requested.
1✔
646
     * * First Array Index - ROW
1✔
647
     * * Second Array Index - COLUMN
1✔
648
     */
1✔
649
    select(selectAst) {
1✔
650
        let recordIDs = [];
279✔
651
        let viewTableData = [];
279✔
652
        let ast = selectAst;
279✔
653

279✔
654
        if (typeof ast.FROM === 'undefined')
279✔
655
            throw new Error("Missing keyword FROM");
279✔
656

278✔
657
        //  Manipulate AST to add GROUP BY if DISTINCT keyword.
278✔
658
        ast = Sql.distinctField(ast);
278✔
659

278✔
660
        //  Manipulate AST add pivot fields.
278✔
661
        ast = this.pivotField(ast);
278✔
662

278✔
663
        const view = new SelectTables(ast, this.tables, this.bindParameters);
278✔
664

278✔
665
        //  JOIN tables to create a derived table.
278✔
666
        view.join(ast);
278✔
667

278✔
668
        //  Get the record ID's of all records matching WHERE condition.
278✔
669
        recordIDs = view.whereCondition(ast);
278✔
670

278✔
671
        //  Get selected data records.
278✔
672
        viewTableData = view.getViewData(recordIDs);
278✔
673

278✔
674
        //  Compress the data.
278✔
675
        viewTableData = view.groupBy(ast, viewTableData);
278✔
676

278✔
677
        //  Sort our selected data.
278✔
678
        view.orderBy(ast, viewTableData);
278✔
679

278✔
680
        //  Remove fields referenced but not included in SELECT field list.
278✔
681
        view.removeTempColumns(viewTableData);
278✔
682

278✔
683
        if (typeof ast.LIMIT !== 'undefined') {
279✔
684
            const maxItems = ast.LIMIT.nb;
2✔
685
            if (viewTableData.length > maxItems)
2✔
686
                viewTableData.splice(maxItems);
2✔
687
        }
2✔
688

261✔
689
        //  Apply SET rules for various union types.
261✔
690
        viewTableData = this.unionSets(ast, viewTableData);
261✔
691

261✔
692
        if (this.columnTitle) {
279✔
693
            viewTableData.unshift(view.getColumnTitles());
114✔
694
        }
114✔
695

260✔
696
        if (viewTableData.length === 0) {
279✔
697
            viewTableData.push([""]);
7✔
698
        }
7✔
699

260✔
700
        if (viewTableData.length === 1 && viewTableData[0].length === 0) {
279✔
701
            viewTableData[0] = [""];
7✔
702
        }
7✔
703

260✔
704
        return viewTableData;
260✔
705
    }
279✔
706

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

278✔
715
        if (astFields.length > 0) {
278✔
716
            const firstField = astFields[0].name.toUpperCase();
278✔
717
            if (firstField.startsWith("DISTINCT")) {
278✔
718
                astFields[0].name = firstField.replace("DISTINCT", "").trim();
7✔
719

7✔
720
                if (typeof ast['GROUP BY'] === 'undefined') {
7✔
721
                    const groupBy = [];
7✔
722

7✔
723
                    for (const astItem of astFields) {
7✔
724
                        groupBy.push({ column: astItem.name });
7✔
725
                    }
7✔
726

7✔
727
                    ast["GROUP BY"] = groupBy;
7✔
728
                }
7✔
729
            }
7✔
730
        }
278✔
731

278✔
732
        return ast;
278✔
733
    }
278✔
734

1✔
735
    /**
1✔
736
     * Add new column to AST for every AGGREGATE function and unique pivot column data.
1✔
737
     * @param {Object} ast - AST which is checked to see if a PIVOT is used.
1✔
738
     * @returns {Object} - Updated AST containing SELECT FIELDS for the pivot data OR original AST if no pivot.
1✔
739
     */
1✔
740
    pivotField(ast) {
1✔
741
        //  If we are doing a PIVOT, it then requires a GROUP BY.
278✔
742
        if (typeof ast.PIVOT !== 'undefined') {
278✔
743
            if (typeof ast['GROUP BY'] === 'undefined')
7✔
744
                throw new Error("PIVOT requires GROUP BY");
7✔
745
        }
7✔
746
        else
271✔
747
            return ast;
271✔
748

6✔
749
        // These are all of the unique PIVOT field data points.
6✔
750
        const pivotFieldData = this.getUniquePivotData(ast);
6✔
751

6✔
752
        ast.SELECT = Sql.addCalculatedPivotFieldsToAst(ast, pivotFieldData);
6✔
753

6✔
754
        return ast;
6✔
755
    }
278✔
756

1✔
757
    /**
1✔
758
     * Find distinct pivot column data.
1✔
759
     * @param {Object} ast - Abstract Syntax Tree containing the PIVOT option.
1✔
760
     * @returns {any[][]} - All unique data points found in the PIVOT field for the given SELECT.
1✔
761
     */
1✔
762
    getUniquePivotData(ast) {
1✔
763
        const pivotAST = {};
6✔
764

6✔
765
        pivotAST.SELECT = ast.PIVOT;
6✔
766
        pivotAST.SELECT[0].name = `DISTINCT ${pivotAST.SELECT[0].name}`;
6✔
767
        pivotAST.FROM = ast.FROM;
6✔
768
        pivotAST.WHERE = ast.WHERE;
6✔
769

6✔
770
        const oldBindVariables = [...this.bindParameters];
6✔
771

6✔
772
        const pivotSql = new Sql()
6✔
773
            .enableColumnTitle(false)
6✔
774
            .setBindValues(this.bindParameters)
6✔
775
            .copyTableData(this.getTables());
6✔
776

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

6✔
780
        this.setBindValues(oldBindVariables);
6✔
781

6✔
782
        return tableData;
6✔
783
    }
6✔
784

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

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

11✔
805
                for (const fld of pivotFieldData) {
11✔
806
                    const caseTxt = `${matches[0]}(CASE WHEN ${ast.PIVOT[0].name} = '${fld}' THEN ${args[1]} ELSE 'null' END)`;
44✔
807
                    const asField = `${fld[0]} ${typeof selectField.as !== 'undefined' && selectField.as !== "" ? selectField.as : selectField.name}`;
44✔
808
                    newPivotAstFields.push({ name: caseTxt, as: asField });
44✔
809
                }
44✔
810
            }
11✔
811
            else
6✔
812
                newPivotAstFields.push(selectField);
6✔
813
        }
17✔
814

6✔
815
        return newPivotAstFields;
6✔
816
    }
6✔
817

1✔
818
    /**
1✔
819
     * If any SET commands are found (like UNION, INTERSECT,...) the additional SELECT is done.  The new
1✔
820
     * data applies the SET rule against the income viewTableData, and the result data set is returned.
1✔
821
     * @param {Object} ast - SELECT AST.
1✔
822
     * @param {any[][]} viewTableData - SELECTED data before UNION.
1✔
823
     * @returns {any[][]} - New data with set rules applied.
1✔
824
     */
1✔
825
    unionSets(ast, viewTableData) {
1✔
826
        const unionTypes = ['UNION', 'UNION ALL', 'INTERSECT', 'EXCEPT'];
261✔
827
        let unionTableData = viewTableData;
261✔
828

261✔
829
        for (const type of unionTypes) {
261✔
830
            if (typeof ast[type] !== 'undefined') {
1,041✔
831
                const unionSQL = new Sql()
9✔
832
                    .setBindValues(this.bindParameters)
9✔
833
                    .copyTableData(this.getTables());
9✔
834
                for (const union of ast[type]) {
9✔
835
                    const unionData = unionSQL.execute(union);
11✔
836
                    if (unionTableData.length > 0 && unionData.length > 0 && unionTableData[0].length !== unionData[0].length)
11✔
837
                        throw new Error(`Invalid ${type}.  Selected field counts do not match.`);
11✔
838

10✔
839
                    switch (type) {
10✔
840
                        case "UNION":
11✔
841
                            //  Remove duplicates.
4✔
842
                            unionTableData = Sql.appendUniqueRows(unionTableData, unionData);
4✔
843
                            break;
4✔
844

11✔
845
                        case "UNION ALL":
11✔
846
                            //  Allow duplicates.
4✔
847
                            unionTableData = unionTableData.concat(unionData);
4✔
848
                            break;
4✔
849

11✔
850
                        case "INTERSECT":
11✔
851
                            //  Must exist in BOTH tables.
1✔
852
                            unionTableData = Sql.intersectRows(unionTableData, unionData);
1✔
853
                            break;
1✔
854

11✔
855
                        case "EXCEPT":
11✔
856
                            //  Remove from first table all rows that match in second table.
1✔
857
                            unionTableData = Sql.exceptRows(unionTableData, unionData);
1✔
858
                            break;
1✔
859

11✔
860
                        default:
11!
861
                            throw new Error(`Internal error.  Unsupported UNION type: ${type}`);
×
862
                    }
11✔
863
                }
11✔
864
            }
8✔
865
        }
1,041✔
866

260✔
867
        return unionTableData;
260✔
868
    }
261✔
869

1✔
870
    /**
1✔
871
     * Appends any row in newData that does not exist in srcData.
1✔
872
     * @param {any[][]} srcData - existing table data
1✔
873
     * @param {any[][]} newData - new table data
1✔
874
     * @returns {any[][]} - srcData rows PLUS any row in newData that is NOT in srcData.
1✔
875
     */
1✔
876
    static appendUniqueRows(srcData, newData) {
1✔
877
        const srcMap = new Map();
4✔
878

4✔
879
        for (const srcRow of srcData) {
4✔
880
            srcMap.set(srcRow.join("::"), true);
13✔
881
        }
13✔
882

4✔
883
        for (const newRow of newData) {
4✔
884
            const key = newRow.join("::");
22✔
885
            if (!srcMap.has(key)) {
22✔
886
                srcData.push(newRow);
20✔
887
                srcMap.set(key, true);
20✔
888
            }
20✔
889
        }
22✔
890
        return srcData;
4✔
891
    }
4✔
892

1✔
893
    /**
1✔
894
     * Finds the rows that are common between srcData and newData
1✔
895
     * @param {any[][]} srcData - table data
1✔
896
     * @param {any[][]} newData - table data
1✔
897
     * @returns {any[][]} - returns only rows that intersect srcData and newData.
1✔
898
     */
1✔
899
    static intersectRows(srcData, newData) {
1✔
900
        const srcMap = new Map();
1✔
901
        const intersectTable = [];
1✔
902

1✔
903
        for (const srcRow of srcData) {
1✔
904
            srcMap.set(srcRow.join("::"), true);
10✔
905
        }
10✔
906

1✔
907
        for (const newRow of newData) {
1✔
908
            if (srcMap.has(newRow.join("::"))) {
5✔
909
                intersectTable.push(newRow);
1✔
910
            }
1✔
911
        }
5✔
912
        return intersectTable;
1✔
913
    }
1✔
914

1✔
915
    /**
1✔
916
     * Returns all rows in srcData MINUS any rows that match it from newData.
1✔
917
     * @param {any[][]} srcData - starting table
1✔
918
     * @param {any[][]} newData  - minus table (if it matches srcData row)
1✔
919
     * @returns {any[][]} - srcData MINUS newData
1✔
920
     */
1✔
921
    static exceptRows(srcData, newData) {
1✔
922
        const srcMap = new Map();
1✔
923
        let rowNum = 0;
1✔
924
        for (const srcRow of srcData) {
1✔
925
            srcMap.set(srcRow.join("::"), rowNum);
5✔
926
            rowNum++;
5✔
927
        }
5✔
928

1✔
929
        const removeRowNum = [];
1✔
930
        for (const newRow of newData) {
1✔
931
            const key = newRow.join("::");
2✔
932
            if (srcMap.has(key)) {
2✔
933
                removeRowNum.push(srcMap.get(key));
2✔
934
            }
2✔
935
        }
2✔
936

1✔
937
        removeRowNum.sort(function (a, b) { return b - a });
1✔
938
        for (rowNum of removeRowNum) {
1✔
939
            srcData.splice(rowNum, 1);
2✔
940
        }
2✔
941

1✔
942
        return srcData;
1✔
943
    }
1✔
944
}
1✔
945

946

947

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