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

dataunitylab / relational-playground / #107

09 Sep 2025 03:15PM UTC coverage: 78.436% (-7.9%) from 86.296%
#107

push

michaelmior
Update Node to 24

Signed-off-by: Michael Mior <mmior@mail.rit.edu>

517 of 731 branches covered (70.73%)

Branch coverage included in aggregate %.

1018 of 1226 relevant lines covered (83.03%)

14584.19 hits per line

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

88.24
/src/modules/relexp.js
1
// @flow
2
import fromEntries from 'fromentries';
3
import {produce} from 'immer';
4
import {joinOrderOptimization} from './joinOrderOptimization';
5
import {constructRelationalGraph} from './constructRelationalGraph';
6

7
export const EXPR_FROM_SQL = 'EXPR_FROM_SQL';
5✔
8
export const ENABLE_OPTIMIZATION = 'ENABLE_OPTIMIZATION';
5✔
9
export const DISABLE_OPTIMIZATION = 'DISABLE_OPTIMIZATION';
5✔
10

11
type ExprFromSqlAction = {
12
  type: 'EXPR_FROM_SQL',
13
  sql: {[string]: any},
14
  types: {[string]: Array<string>},
15
};
16

17
type EnableOptimizationAction = {
18
  type: 'ENABLE_OPTIMIZATION',
19
  optimization: string,
20
};
21

22
type DisableOptimizationAction = {
23
  type: 'DISABLE_OPTIMIZATION',
24
};
25

26
export type OrderByColumn = {
27
  column_name: string,
28
  ascending: boolean,
29
};
30

31
/**
32
 * @param sql - a parsed SQL query
33
 * @param types - an object mapping table names to lists of columns
34
 * @return a new EXPR_FROM_SQL action
35
 */
36
export function exprFromSql(
37
  sql: {[string]: any},
38
  types: {[string]: Array<string>}
39
): ExprFromSqlAction {
40
  return {type: EXPR_FROM_SQL, sql, types};
58✔
41
}
42

43
/**
44
 * @param optimization - a string denoting the type of optimization performed
45
 * @return a new ENABLE_OPTIMIZATION action
46
 */
47
export function enableOptimization(
48
  optimization: string
49
): EnableOptimizationAction {
50
  return {type: ENABLE_OPTIMIZATION, optimization};
5✔
51
}
52

53
/**
54
 * @return a new DISABLE_OPTIMIZATION action
55
 */
56
export function disableOptimization(): DisableOptimizationAction {
57
  return {type: DISABLE_OPTIMIZATION};
3✔
58
}
59

60
export type State = {
61
  expr: {[string]: any},
62
  unoptimizedExpr?: {[string]: any},
63
  optimized?: true,
64
};
65

66
const initialState = {
5✔
67
  expr: {},
68
};
69

70
const opMap = {
5✔
71
  '=': '$eq',
72
  '!=': '$ne',
73
  '>': '$gt',
74
  '>=': '$gte',
75
  '<': '$lt',
76
  '<=': '$lte',
77
};
78

79
/**
80
 * @param exprList - the current expression list
81
 * @param expr - a new expression to append to the list
82
 * @param types - an object mapping table names to lists of columns
83
 * @param tables - all tables used in the expression
84
 */
85
function addToExpr(
86
  exprList: Array<any>,
87
  expr: {[string]: any},
88
  types: {[string]: Array<string>},
89
  tables: Array<string>
90
) {
91
  const converted = convertExpr(expr, types, tables);
45✔
92
  if (Array.isArray(converted)) {
45!
93
    exprList.push(...converted);
×
94
  } else {
95
    exprList.push(converted);
45✔
96
  }
97
}
98

99
/**
100
 * Normalizes column names for comparison by extracting the base column name
101
 * @param columnName - column name (could be qualified like "Doctor.departmentId" or unqualified like "departmentId")
102
 * @return the base column name without table qualification
103
 */
104
function normalizeColumnName(columnName: string): string {
105
  if (typeof columnName !== 'string') {
36!
106
    return columnName;
×
107
  }
108
  const parts = columnName.split('.');
36✔
109
  return parts[parts.length - 1]; // Return the last part (column name)
36✔
110
}
111

112
/**
113
 * @param expr - a parsed expression from a SQL query
114
 * @param types - an object mapping table names to lists of columns
115
 * @param tables - all tables used in the expression
116
 * @return a relational algebra expression object
117
 */
118
/**
119
 * Extracts aggregate functions used in a HAVING expression
120
 * @param expr - the HAVING expression to analyze
121
 * @return array of aggregate objects
122
 */
123
function extractHavingAggregates(expr: {
124
  [string]: any,
125
}): Array<{[string]: any}> {
126
  const aggregates: Array<{[string]: any}> = [];
33✔
127

128
  switch (expr.type) {
33✔
129
    case 'FunctionCall':
130
      const funcName = expr.name.toUpperCase();
8✔
131
      if (['MAX', 'MIN', 'AVG', 'SUM', 'COUNT', 'STDEV'].includes(funcName)) {
8✔
132
        let param;
133
        if (expr.params[0] === '*') {
7✔
134
          param = '*';
4✔
135
        } else {
136
          // For now, assume it's a simple column reference
137
          param = expr.params[0].value || expr.params[0];
3!
138
        }
139
        aggregates.push({
7✔
140
          aggregate: {
141
            function: funcName,
142
            column: param,
143
          },
144
        });
145
      }
146
      return aggregates;
8✔
147

148
    case 'ComparisonBooleanPrimary':
149
      aggregates.push(...extractHavingAggregates(expr.left));
9✔
150
      aggregates.push(...extractHavingAggregates(expr.right));
9✔
151
      return aggregates;
9✔
152

153
    case 'AndExpression':
154
    case 'OrExpression':
155
      aggregates.push(...extractHavingAggregates(expr.left));
2✔
156
      aggregates.push(...extractHavingAggregates(expr.right));
2✔
157
      return aggregates;
2✔
158

159
    case 'BetweenPredicate':
160
      aggregates.push(...extractHavingAggregates(expr.left));
1✔
161
      aggregates.push(...extractHavingAggregates(expr.right.left));
1✔
162
      aggregates.push(...extractHavingAggregates(expr.right.right));
1✔
163
      return aggregates;
1✔
164

165
    default:
166
      return aggregates;
13✔
167
  }
168
}
169

170
/**
171
 * Converts a HAVING expression to work with the grouped/aggregated column names
172
 * @param expr - the HAVING expression to convert
173
 * @param types - an object mapping table names to lists of columns
174
 * @param tables - all tables used in the expression
175
 * @return a condition expression for selection after GROUP BY
176
 */
177
function convertHavingExpr(
178
  expr: {[string]: any},
179
  types: {[string]: Array<string>},
180
  tables: Array<string>
181
): {[string]: any} {
182
  switch (expr.type) {
10!
183
    case 'ComparisonBooleanPrimary':
184
      return {
7✔
185
        cmp: {
186
          lhs: convertHavingValue(expr.left, types, tables),
187
          op: opMap[expr.operator],
188
          rhs: convertHavingValue(expr.right, types, tables),
189
        },
190
      };
191

192
    case 'AndExpression':
193
      return {
1✔
194
        and: {
195
          clauses: [
196
            convertHavingExpr(expr.left, types, tables),
197
            convertHavingExpr(expr.right, types, tables),
198
          ],
199
        },
200
      };
201

202
    case 'OrExpression':
203
      return {
1✔
204
        or: {
205
          clauses: [
206
            convertHavingExpr(expr.left, types, tables),
207
            convertHavingExpr(expr.right, types, tables),
208
          ],
209
        },
210
      };
211

212
    case 'BetweenPredicate':
213
      const lhs = convertHavingValue(expr.left, types, tables);
1✔
214
      return {
1✔
215
        and: {
216
          clauses: [
217
            {
218
              cmp: {
219
                lhs,
220
                op: '$gte',
221
                rhs: convertHavingValue(expr.right.left, types, tables),
222
              },
223
            },
224
            {
225
              cmp: {
226
                lhs,
227
                op: '$lte',
228
                rhs: convertHavingValue(expr.right.right, types, tables),
229
              },
230
            },
231
          ],
232
        },
233
      };
234

235
    default:
236
      throw new Error(`Unsupported HAVING expression type: ${expr.type}`);
×
237
  }
238
}
239

240
/**
241
 * Converts a value in a HAVING expression (could be aggregate, column, or literal)
242
 */
243
function convertHavingValue(
244
  expr: {[string]: any},
245
  types: {[string]: Array<string>},
246
  tables: Array<string>
247
): string {
248
  if (typeof expr === 'string' || typeof expr === 'number') {
20!
249
    return expr.toString();
×
250
  }
251

252
  switch (expr.type) {
20!
253
    case 'FunctionCall':
254
      // Convert aggregate function to column name
255
      const funcName = expr.name.toUpperCase();
7✔
256
      let param;
257
      if (expr.params[0] === '*') {
7✔
258
        param = '*';
4✔
259
      } else {
260
        param = convertHavingValue(expr.params[0], types, tables);
3✔
261
      }
262
      return `${funcName}(${param})`;
7✔
263

264
    case 'Identifier':
265
      // Convert column reference to actual column name
266
      return expr.value;
4✔
267

268
    case 'Number':
269
    case 'String':
270
      return expr.value;
9✔
271

272
    default:
273
      throw new Error(`Unsupported HAVING value type: ${expr.type}`);
×
274
  }
275
}
276

277
/**
278
 * Validates that a HAVING expression only uses aggregate functions or GROUP BY columns
279
 * @param expr - the expression to validate
280
 * @param groupByColumns - columns that are in the GROUP BY clause (converted expressions)
281
 * @return true if valid, throws error if invalid
282
 */
283
function validateHavingExpression(
284
  expr: {[string]: any},
285
  groupByColumns: Array<string>
286
): boolean {
287
  switch (expr.type) {
31!
288
    case 'FunctionCall':
289
      const funcName = expr.name.toUpperCase();
8✔
290
      if (['MAX', 'MIN', 'AVG', 'SUM', 'COUNT', 'STDEV'].includes(funcName)) {
8✔
291
        return true; // Aggregate functions are allowed
7✔
292
      }
293
      throw new Error(
1✔
294
        `Function '${expr.name}' is not allowed in HAVING clause`
295
      );
296

297
    case 'Identifier':
298
      const normalizedColumn = normalizeColumnName(expr.value);
2✔
299
      const isInGroupBy = groupByColumns.some(
2✔
300
        (groupCol) => normalizeColumnName(groupCol) === normalizedColumn
2✔
301
      );
302
      if (!isInGroupBy) {
2✔
303
        throw new Error(
1✔
304
          `Column '${expr.value}' in HAVING clause must appear in the GROUP BY clause or be used in an aggregate function`
305
        );
306
      }
307
      return true;
1✔
308

309
    case 'Number':
310
    case 'String':
311
      return true; // Literals are allowed
9✔
312

313
    case 'ComparisonBooleanPrimary':
314
      validateHavingExpression(expr.left, groupByColumns);
9✔
315
      validateHavingExpression(expr.right, groupByColumns);
7✔
316
      return true;
7✔
317

318
    case 'AndExpression':
319
    case 'OrExpression':
320
      validateHavingExpression(expr.left, groupByColumns);
2✔
321
      validateHavingExpression(expr.right, groupByColumns);
2✔
322
      return true;
2✔
323

324
    case 'BetweenPredicate':
325
      validateHavingExpression(expr.left, groupByColumns);
1✔
326
      validateHavingExpression(expr.right.left, groupByColumns);
1✔
327
      validateHavingExpression(expr.right.right, groupByColumns);
1✔
328
      return true;
1✔
329

330
    default:
331
      throw new Error(
×
332
        `Unsupported expression type '${expr.type}' in HAVING clause`
333
      );
334
  }
335
}
336

337
function convertExpr(
338
  expr: {[string]: any},
339
  types: {[string]: Array<string>},
340
  tables: Array<string>
341
): {[string]: any} {
342
  switch (expr.type) {
286!
343
    case 'BetweenPredicate':
344
      const lhs = convertExpr(expr.left, types, tables);
1✔
345
      return {
1✔
346
        and: {
347
          clauses: [
348
            {
349
              cmp: {
350
                lhs,
351
                op: '$gte',
352
                rhs: convertExpr(expr.right.left, types, tables),
353
              },
354
            },
355
            {
356
              cmp: {
357
                lhs,
358
                op: '$lte',
359
                rhs: convertExpr(expr.right.right, types, tables),
360
              },
361
            },
362
          ],
363
        },
364
      };
365

366
    case 'AndExpression':
367
      // Collect all expressions on either side of the AND
368
      let and: Array<any> = [];
10✔
369
      let exprLeft = Object.assign({}, expr);
10✔
370
      let exprRight: Array<any> = [];
10✔
371
      while (exprLeft.type === 'AndExpression') {
10✔
372
        exprRight.unshift(exprLeft.right);
19✔
373
        exprLeft = exprLeft.left;
19✔
374
      }
375
      addToExpr(and, exprLeft, types, tables);
10✔
376
      exprRight.forEach((element) => addToExpr(and, element, types, tables));
19✔
377

378
      return {and: {clauses: and}};
10✔
379

380
    case 'OrExpression':
381
      // Collect all expressions on either side of the AND
382
      let or: Array<any> = [];
6✔
383
      addToExpr(or, expr.left, types, tables);
6✔
384
      addToExpr(or, expr.right, types, tables);
6✔
385

386
      return {or: {clauses: or}};
6✔
387

388
    case 'NotExpression':
389
      return {not: {clause: convertExpr(expr.value, types, tables)}};
1✔
390

391
    case 'SimpleExprParentheses':
392
      if (
2!
393
        expr.value.type === 'ExpressionList' &&
4✔
394
        expr.value.value.length === 1
395
      ) {
396
        return convertExpr(expr.value.value[0], types, tables);
2✔
397
      } else {
398
        throw new Error(
×
399
          'Parenthesized expressions can only contain a single value'
400
        );
401
      }
402

403
    case 'ComparisonBooleanPrimary':
404
      return {
55✔
405
        cmp: {
406
          lhs: convertExpr(expr.left, types, tables),
407
          op: opMap[expr.operator],
408
          rhs: convertExpr(expr.right, types, tables),
409
        },
410
      };
411

412
    case 'OrderBy':
413
      const values = [];
4✔
414
      for (const value of expr.value) {
4✔
415
        values.push(convertExpr(value, types, tables));
5✔
416
      }
417
      return {
4✔
418
        order_by: values,
419
      };
420

421
    case 'GroupByOrderByItem':
422
      return {
5✔
423
        column_name: convertExpr(expr.value, types, tables),
424
        ascending: (expr.sortOpt || 'ASC').toUpperCase() === 'ASC',
7✔
425
      };
426

427
    case 'Identifier':
428
      // Splt into table, column parts
429
      let [table, column] = expr.value.split('.');
128✔
430
      if (!column) {
128✔
431
        column = table;
91✔
432
        table = undefined;
91✔
433
      }
434

435
      if (table) {
128✔
436
        // Ensure the given table exists
437
        if (!types[table]) {
37!
438
          throw new Error('Table ' + table + ' not found');
×
439
        }
440

441
        // Make sure the column of the table exists
442
        if (!types[table].includes(column)) {
37!
443
          throw new Error('Column ' + column + ' not found in ' + table);
×
444
        }
445

446
        if (!tables.includes(table)) {
37✔
447
          throw new Error('Table ' + table + ' is not referenced in query');
1✔
448
        }
449

450
        return expr.value;
36✔
451
      } else {
452
        // Find all tables which contain the column
453
        const columnTables = [];
91✔
454
        for (const table of tables) {
91✔
455
          if (types[table].includes(column)) {
91!
456
            columnTables.push(table);
91✔
457
          }
458
        }
459

460
        // Check if the column was found in any table
461
        if (!columnTables.length) {
91!
462
          throw new Error('Column  ' + column + ' not found');
×
463
        }
464

465
        // Ensure the column was found in only one table
466
        if (columnTables.length > 1) {
91!
467
          throw new Error('Column ' + column + ' is ambiguous');
×
468
        }
469

470
        return column;
91✔
471
      }
472

473
    case 'Number':
474
      // For literals, just return the value object as-is
475
      return expr.value;
47✔
476

477
    case 'String':
478
      // If needed, strip quotes
479
      if (
1!
480
        expr.value &&
6✔
481
        typeof expr.value === 'string' &&
482
        expr.value.length > 1 &&
483
        (expr.value[0] === "'" || expr.value[0] === '"') &&
484
        expr.value.charAt(expr.value.length - 1) === expr.value[0]
485
      ) {
486
        return expr.value.slice(1, -1);
1✔
487
      } else {
488
        return expr.value;
×
489
      }
490

491
    case 'InExpressionListPredicate':
492
      if (expr.right.type !== 'ExpressionList') {
2!
493
        // Currently IN expressions are only supported with lists of values
494
        throw new Error('Query not supported');
×
495
      }
496

497
      let orIn: Array<any> = [];
2✔
498
      for (const inSetElem of expr.right.value) {
2✔
499
        const inExpr = {
4✔
500
          type: 'ComparisonBooleanPrimary',
501
          left: expr.left,
502
          operator: '=',
503
          right: inSetElem,
504
        };
505
        addToExpr(orIn, inExpr, types, tables);
4✔
506
      }
507
      const inOrExpr = {or: {clauses: orIn}};
2✔
508

509
      if (expr.hasNot === 'NOT') {
2✔
510
        return {not: {clause: inOrExpr}};
1✔
511
      } else {
512
        return inOrExpr;
1✔
513
      }
514

515
    case 'FunctionCall':
516
      // Handle aggregate functions like MAX, MIN, AVG, SUM
517
      const funcName = expr.name.toUpperCase();
24✔
518
      if (!['MAX', 'MIN', 'AVG', 'SUM', 'COUNT', 'STDEV'].includes(funcName)) {
24!
519
        throw new Error('Unsupported aggregate function: ' + expr.name);
×
520
      }
521

522
      if (expr.params.length !== 1) {
24!
523
        throw new Error('Aggregate functions must have exactly one parameter');
×
524
      }
525

526
      // Handle special case for COUNT(*)
527
      let param;
528
      if (expr.params[0] === '*') {
24✔
529
        param = '*';
6✔
530
      } else {
531
        const paramObj = convertExpr(expr.params[0], types, tables);
18✔
532
        param =
18✔
533
          typeof paramObj === 'string' ? paramObj : JSON.stringify(paramObj);
18!
534
      }
535

536
      return {
24✔
537
        aggregate: {
538
          function: funcName,
539
          column: param,
540
        },
541
      };
542

543
    default:
544
      // Produce an error if the expression is unsupported
545
      throw new Error('Invalid expression.');
×
546
  }
547
}
548

549
/**
550
 * @param sql - a parsed SQL query
551
 * @param types - an object mapping table names to lists of columns
552
 * @param tables - all tables used in the expression
553
 * @return a relational algebra expression object representing the query
554
 */
555
function buildRelExp(
556
  sql: {[string]: any},
557
  types: {[string]: Array<string>},
558
  tables: Array<string>
559
): {[string]: any} {
560
  switch (sql.type) {
209!
561
    case 'Except':
562
    case 'Intersect':
563
    case 'Union':
564
      const distinct = (sql.distinctOpt || '').toUpperCase();
5✔
565
      if (distinct && distinct !== 'ALL') {
5!
566
        throw new Error('Invalid distinct option');
×
567
      }
568

569
      const setType = sql.type.toLowerCase();
5✔
570

571
      // Ensure we use a different set of tables for each side
572
      const setTablesBackup = tables.slice();
5✔
573
      const leftSetExp = buildRelExp(sql.left, types, tables);
5✔
574
      const rightSetExp = buildRelExp(sql.right, types, setTablesBackup);
5✔
575

576
      return {
5✔
577
        [setType]: {
578
          left: leftSetExp,
579
          right: rightSetExp,
580
          distinct: !distinct,
581
        },
582
      };
583

584
    case 'Select':
585
      // Ensure we have a FROM clause
586
      if (!sql.from) {
62✔
587
        throw new Error('A FROM clause must be specified.');
1✔
588
      }
589

590
      // Build an expression for everything in the FROM clause
591
      let from = sql.from.value.map((v) => buildRelExp(v, types, tables));
61✔
592
      if (from.length !== 1) {
61!
593
        throw new Error('Only single table queries currently supported.');
×
594
      }
595

596
      // Wrap the table in a selection operator if there are any conditions
597
      if (sql.where) {
61✔
598
        from = [
20✔
599
          {
600
            selection: {
601
              arguments: {select: convertExpr(sql.where, types, tables)},
602
              children: from,
603
            },
604
          },
605
        ];
606
      }
607

608
      // Check for aggregates in SELECT clause (with or without GROUP BY)
609
      const select = sql.selectItems.value;
61✔
610
      const hasAggregates = select.some((field) => containsAggregate(field));
77✔
611

612
      // Helper function to check if a field contains aggregates without converting
613
      function containsAggregate(field: any): boolean {
614
        if (!field || typeof field !== 'object') return false;
77!
615

616
        // Direct function call
617
        if (field.type === 'FunctionCall') {
77✔
618
          const funcName = field.name?.toUpperCase?.();
22✔
619
          return ['MAX', 'MIN', 'AVG', 'SUM'].includes(funcName);
22✔
620
        }
621

622
        // Check nested structures recursively
623
        if (field.value && typeof field.value === 'object') {
55!
624
          return containsAggregate(field.value);
×
625
        }
626

627
        return false;
55✔
628
      }
629

630
      // Add group by operator if there's a GROUP BY clause OR aggregates are present
631
      if (sql.groupBy || hasAggregates) {
61✔
632
        // Now we need to convert expressions for validation and GROUP BY processing
633
        const aggregates = [];
20✔
634
        const groupColumns = [];
20✔
635

636
        for (const field of select) {
20✔
637
          const converted = convertExpr(field, types, tables);
38✔
638
          if (
38✔
639
            converted &&
100✔
640
            typeof converted === 'object' &&
641
            converted.aggregate
642
          ) {
643
            aggregates.push(converted);
24✔
644
          } else {
645
            groupColumns.push(converted);
14✔
646
          }
647
        }
648
        let groupByColumns: Array<string> = [];
20✔
649

650
        if (sql.groupBy) {
20✔
651
          groupByColumns = sql.groupBy.value.map((item) =>
19✔
652
            convertExpr(item.value, types, tables)
19✔
653
          );
654
        }
655

656
        // Validate GROUP BY rules: all non-aggregate SELECT columns must be in GROUP BY
657
        if (aggregates.length > 0 && groupColumns.length > 0) {
20✔
658
          for (const selectColumn of groupColumns) {
14✔
659
            const selectColumnStr =
660
              typeof selectColumn === 'string'
14!
661
                ? selectColumn
662
                : JSON.stringify(selectColumn);
663
            const normalizedSelectColumn = normalizeColumnName(selectColumnStr);
14✔
664
            const isInGroupBy = groupByColumns.some(
14✔
665
              (groupCol) =>
666
                normalizeColumnName(groupCol) === normalizedSelectColumn
14✔
667
            );
668

669
            if (!isInGroupBy) {
14✔
670
              throw new Error(
1✔
671
                `Column '${selectColumnStr}' must appear in the GROUP BY clause or be used in an aggregate function`
672
              );
673
            }
674
          }
675
        }
676

677
        // Validate ORDER BY rules when GROUP BY is present
678
        if (sql.orderBy) {
19✔
679
          const orderByColumns = sql.orderBy.value;
2✔
680
          for (const orderCol of orderByColumns) {
2✔
681
            const orderColumn = convertExpr(orderCol.value, types, tables);
2✔
682
            // Check if the ORDER BY column is either in GROUP BY or is an aggregate
683
            const isAggregate =
684
              orderColumn &&
2!
685
              typeof orderColumn === 'object' &&
686
              orderColumn.aggregate;
687

688
            if (!isAggregate) {
2!
689
              const orderColumnStr =
690
                typeof orderColumn === 'string'
2!
691
                  ? orderColumn
692
                  : JSON.stringify(orderColumn);
693
              const normalizedOrderColumn = normalizeColumnName(orderColumnStr);
2✔
694
              const isInGroupBy = groupByColumns.some(
2✔
695
                (groupCol) =>
696
                  normalizeColumnName(groupCol) === normalizedOrderColumn
2✔
697
              );
698

699
              if (!isInGroupBy) {
2✔
700
                throw new Error(
1✔
701
                  `Column '${orderColumnStr}' in ORDER BY clause must appear in the GROUP BY clause or be used in an aggregate function`
702
                );
703
              }
704
            }
705
          }
706
        }
707

708
        // Extract aggregates from HAVING clause if it exists
709
        let havingAggregates: Array<{[string]: any}> = [];
18✔
710
        if (sql.having) {
18✔
711
          havingAggregates = extractHavingAggregates(sql.having);
8✔
712
        }
713

714
        // Combine SELECT aggregates with HAVING aggregates, removing duplicates
715
        const allAggregates = [...aggregates];
18✔
716
        for (const havingAgg of havingAggregates) {
18✔
717
          const isDuplicate = allAggregates.some(
7✔
718
            (selectAgg) =>
719
              selectAgg.aggregate.function === havingAgg.aggregate.function &&
9✔
720
              selectAgg.aggregate.column === havingAgg.aggregate.column
721
          );
722
          if (!isDuplicate) {
7✔
723
            allAggregates.push(havingAgg);
1✔
724
          }
725
        }
726

727
        from = [
18✔
728
          {
729
            group_by: {
730
              arguments: {
731
                groupBy: groupByColumns,
732
                aggregates: allAggregates, // Include both SELECT and HAVING aggregates
733
                selectColumns: groupColumns, // Non-aggregate columns from SELECT
734
              },
735
              children: from,
736
            },
737
          },
738
        ];
739

740
        // Add HAVING clause if it exists
741
        if (sql.having) {
18✔
742
          // Validate HAVING expression
743
          validateHavingExpression(sql.having, groupByColumns);
8✔
744

745
          // Apply HAVING as a selection (restriction) operation after GROUP BY
746
          from = [
6✔
747
            {
748
              selection: {
749
                arguments: {
750
                  select: convertHavingExpr(sql.having, types, tables),
751
                },
752
                children: from,
753
              },
754
            },
755
          ];
756

757
          // If HAVING clause introduced additional aggregates not in SELECT,
758
          // add a projection to only return the originally requested columns
759
          const extraHavingAggregates = havingAggregates.filter((havingAgg) => {
6✔
760
            return !aggregates.some(
7✔
761
              (selectAgg) =>
762
                selectAgg.aggregate.function === havingAgg.aggregate.function &&
9✔
763
                selectAgg.aggregate.column === havingAgg.aggregate.column
764
            );
765
          });
766

767
          if (extraHavingAggregates.length > 0) {
6✔
768
            const originalColumns = groupColumns.map((col) =>
1✔
769
              typeof col === 'string' ? col : JSON.stringify(col)
1!
770
            ); // Non-aggregate SELECT columns
771

772
            // Add aggregate columns that were in the original SELECT
773
            for (const agg of aggregates) {
1✔
774
              const columnName = `${agg.aggregate.function}(${agg.aggregate.column})`;
1✔
775
              originalColumns.push(columnName);
1✔
776
            }
777

778
            from = [
1✔
779
              {
780
                projection: {
781
                  arguments: {project: originalColumns},
782
                  children: from,
783
                },
784
              },
785
            ];
786
          }
787
        }
788
      }
789

790
      if (sql.orderBy) {
57✔
791
        from = [
4✔
792
          {
793
            order_by: {
794
              arguments: convertExpr(sql.orderBy, types, tables),
795
              children: from,
796
            },
797
          },
798
        ];
799
      }
800

801
      // Add projections as needed for the SELECT clause
802

803
      // If GROUP BY is used or aggregates are present, return from without additional projection
804
      if (sql.groupBy || hasAggregates) {
57✔
805
        return from[0];
16✔
806
      }
807

808
      if (select.length === 1 && select[0].value === '*') {
41✔
809
        // Don't project anything if SELECT * is used
810
        return from[0];
36✔
811
      } else {
812
        const project = select.map((field) =>
5✔
813
          convertExpr(field, types, tables)
5✔
814
        );
815
        const projection = {
4✔
816
          projection: {
817
            arguments: {project},
818
            children: from,
819
          },
820
        };
821

822
        // Check for any aliased columns (e.g. SELECT foo AS bar...)
823
        const rename = select
4✔
824
          .filter((field) => field.hasAs)
4✔
825
          .map((field) => [field.value, field.alias]);
1✔
826
        if (rename.length === 0) {
4✔
827
          // Don't add a rename if not needed
828
          return projection;
3✔
829
        } else {
830
          // Perform any necessary renames
831
          return {
1✔
832
            rename: {
833
              arguments: {rename: {columns: fromEntries(rename)}},
834
              children: [projection],
835
            },
836
          };
837
        }
838
      }
839

840
    case 'SubQuery':
841
    case 'TableReference':
842
      return buildRelExp(sql.value, types, tables);
61✔
843

844
    case 'TableFactor':
845
      // Store this table as one referenced by the query
846
      tables.push(sql.value.value);
71✔
847

848
      return {relation: sql.value.value};
71✔
849

850
    case 'InnerCrossJoinTable':
851
      // Add the condition if it exists
852
      if (sql.condition) {
8✔
853
        return {
7✔
854
          join: {
855
            left: buildRelExp(sql.left, types, tables),
856
            right: buildRelExp(sql.right, types, tables),
857
            type: 'inner',
858
            condition: convertExpr(sql.condition.value, types, tables),
859
          },
860
        };
861
      } else {
862
        return {
1✔
863
          product: {
864
            left: buildRelExp(sql.left, types, tables),
865
            right: buildRelExp(sql.right, types, tables),
866
          },
867
        };
868
      }
869

870
    case 'LeftRightJoinTable':
871
      // Add the condition if it exists
872
      if (sql.condition) {
2!
873
        return {
2✔
874
          join: {
875
            left: buildRelExp(sql.left, types, tables),
876
            right: buildRelExp(sql.right, types, tables),
877
            type: sql.leftRight.toLowerCase(),
878
            condition: convertExpr(sql.condition.value, types, tables),
879
          },
880
        };
881
      } else {
882
        throw new Error('Condition-less ' + sql.leftRight + ' Join');
×
883
      }
884

885
    default:
886
      throw new Error('Unsupported statement ' + sql.type + '.');
×
887
  }
888
}
889

890
/**
891
 * Optimizes a given relational algebra expression, if possible
892
 * @param type string denoting the type of optimization
893
 * @param expr object denoting the expression to optimize
894
 * @returns {{join: {condition: {cmp: {op, lhs, rhs}}, left: {[p: string]: *}, right: {[p: string]: *}, type: *}}|{[p: string]: *}}
895
 */
896
function optimize(type: string, expr: {[key: string]: any}) {
897
  switch (type) {
4!
898
    case 'join':
899
      const {graph, globalSelections, canOptimize} =
900
        constructRelationalGraph(expr);
4✔
901
      if (!canOptimize) return expr;
4!
902
      const optimizedExpr = joinOrderOptimization(graph, globalSelections);
4✔
903
      return optimizedExpr;
4✔
904
    default:
905
      return expr;
×
906
  }
907
}
908

909
const reducer: (
910
  State,
911
  ExprFromSqlAction | EnableOptimizationAction | DisableOptimizationAction
912
) => State = produce<
5✔
913
  State,
914
  ExprFromSqlAction | EnableOptimizationAction | DisableOptimizationAction,
915
>(
916
  (
917
    draft: State,
918
    action:
919
      | ExprFromSqlAction
920
      | EnableOptimizationAction
921
      | DisableOptimizationAction
922
  ) => {
923
    // eslint-disable-next-line default-case
924
    switch (action.type) {
66✔
925
      case EXPR_FROM_SQL:
926
        draft.expr = buildRelExp(action.sql, action.types, []);
57✔
927
        delete draft.unoptimizedExpr;
51✔
928
        delete draft.optimized;
51✔
929
        break;
51✔
930
      case ENABLE_OPTIMIZATION:
931
        draft.unoptimizedExpr = draft.expr;
4✔
932
        draft.expr = optimize(action.optimization, draft.expr);
4✔
933
        draft.optimized = true;
4✔
934
        break;
4✔
935
      case DISABLE_OPTIMIZATION:
936
        if (draft.unoptimizedExpr) {
2!
937
          draft.expr = draft.unoptimizedExpr;
2✔
938
          delete draft.unoptimizedExpr;
2✔
939
        }
940
        delete draft.optimized;
2✔
941
    }
942
  },
943
  initialState
944
);
945

946
export default reducer;
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