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

dataunitylab / relational-playground / #109

10 Sep 2025 06:35PM UTC coverage: 78.51% (+0.1%) from 78.407%
#109

push

michaelmior
Fix SqlEditor test for React context refactoring

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

526 of 743 branches covered (70.79%)

Branch coverage included in aggregate %.

1034 of 1244 relevant lines covered (83.12%)

14373.8 hits per line

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

88.26
/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
// Supported aggregate functions
12
export const SUPPORTED_AGGREGATE_FUNCTIONS = [
5✔
13
  'MAX',
14
  'MIN',
15
  'AVG',
16
  'SUM',
17
  'COUNT',
18
  'STDEV',
19
];
20

21
type ExprFromSqlAction = {
22
  type: 'EXPR_FROM_SQL',
23
  sql: {[string]: any},
24
  types: {[string]: Array<string>},
25
};
26

27
type EnableOptimizationAction = {
28
  type: 'ENABLE_OPTIMIZATION',
29
  optimization: string,
30
};
31

32
type DisableOptimizationAction = {
33
  type: 'DISABLE_OPTIMIZATION',
34
};
35

36
export type OrderByColumn = {
37
  column_name: string,
38
  ascending: boolean,
39
};
40

41
/**
42
 * @param sql - a parsed SQL query
43
 * @param types - an object mapping table names to lists of columns
44
 * @return a new EXPR_FROM_SQL action
45
 */
46
export function exprFromSql(
47
  sql: {[string]: any},
48
  types: {[string]: Array<string>}
49
): ExprFromSqlAction {
50
  return {type: EXPR_FROM_SQL, sql, types};
65✔
51
}
52

53
/**
54
 * @param optimization - a string denoting the type of optimization performed
55
 * @return a new ENABLE_OPTIMIZATION action
56
 */
57
export function enableOptimization(
58
  optimization: string
59
): EnableOptimizationAction {
60
  return {type: ENABLE_OPTIMIZATION, optimization};
5✔
61
}
62

63
/**
64
 * @return a new DISABLE_OPTIMIZATION action
65
 */
66
export function disableOptimization(): DisableOptimizationAction {
67
  return {type: DISABLE_OPTIMIZATION};
3✔
68
}
69

70
export type State = {
71
  expr: {[string]: any},
72
  unoptimizedExpr?: {[string]: any},
73
  optimized?: true,
74
};
75

76
const initialState = {
5✔
77
  expr: {},
78
};
79

80
const opMap = {
5✔
81
  '=': '$eq',
82
  '!=': '$ne',
83
  '>': '$gt',
84
  '>=': '$gte',
85
  '<': '$lt',
86
  '<=': '$lte',
87
};
88

89
/**
90
 * @param exprList - the current expression list
91
 * @param expr - a new expression to append to the list
92
 * @param types - an object mapping table names to lists of columns
93
 * @param tables - all tables used in the expression
94
 */
95
function addToExpr(
96
  exprList: Array<any>,
97
  expr: {[string]: any},
98
  types: {[string]: Array<string>},
99
  tables: Array<string>
100
) {
101
  const converted = convertExpr(expr, types, tables);
45✔
102
  if (Array.isArray(converted)) {
45!
103
    exprList.push(...converted);
×
104
  } else {
105
    exprList.push(converted);
45✔
106
  }
107
}
108

109
/**
110
 * Normalizes column names for comparison by extracting the base column name
111
 * @param columnName - column name (could be qualified like "Doctor.departmentId" or unqualified like "departmentId")
112
 * @return the base column name without table qualification
113
 */
114
function normalizeColumnName(columnName: string): string {
115
  if (typeof columnName !== 'string') {
38!
116
    return columnName;
×
117
  }
118
  const parts = columnName.split('.');
38✔
119
  return parts[parts.length - 1]; // Return the last part (column name)
38✔
120
}
121

122
/**
123
 * @param expr - a parsed expression from a SQL query
124
 * @param types - an object mapping table names to lists of columns
125
 * @param tables - all tables used in the expression
126
 * @return a relational algebra expression object
127
 */
128
/**
129
 * Extracts aggregate functions used in a HAVING expression
130
 * @param expr - the HAVING expression to analyze
131
 * @return array of aggregate objects
132
 */
133
function extractHavingAggregates(expr: {
134
  [string]: any,
135
}): Array<{[string]: any}> {
136
  const aggregates: Array<{[string]: any}> = [];
36✔
137

138
  switch (expr.type) {
36✔
139
    case 'FunctionCall':
140
      const funcName = expr.name.toUpperCase();
9✔
141
      if (SUPPORTED_AGGREGATE_FUNCTIONS.includes(funcName)) {
9✔
142
        let param;
143
        if (expr.params[0] === '*') {
8✔
144
          param = '*';
4✔
145
        } else {
146
          // For now, assume it's a simple column reference
147
          param = expr.params[0].value || expr.params[0];
4!
148
        }
149
        aggregates.push({
8✔
150
          aggregate: {
151
            function: funcName,
152
            column: param,
153
          },
154
        });
155
      }
156
      return aggregates;
9✔
157

158
    case 'ComparisonBooleanPrimary':
159
      aggregates.push(...extractHavingAggregates(expr.left));
10✔
160
      aggregates.push(...extractHavingAggregates(expr.right));
10✔
161
      return aggregates;
10✔
162

163
    case 'AndExpression':
164
    case 'OrExpression':
165
      aggregates.push(...extractHavingAggregates(expr.left));
2✔
166
      aggregates.push(...extractHavingAggregates(expr.right));
2✔
167
      return aggregates;
2✔
168

169
    case 'BetweenPredicate':
170
      aggregates.push(...extractHavingAggregates(expr.left));
1✔
171
      aggregates.push(...extractHavingAggregates(expr.right.left));
1✔
172
      aggregates.push(...extractHavingAggregates(expr.right.right));
1✔
173
      return aggregates;
1✔
174

175
    default:
176
      return aggregates;
14✔
177
  }
178
}
179

180
/**
181
 * Converts a HAVING expression to work with the grouped/aggregated column names
182
 * @param expr - the HAVING expression to convert
183
 * @param types - an object mapping table names to lists of columns
184
 * @param tables - all tables used in the expression
185
 * @return a condition expression for selection after GROUP BY
186
 */
187
function convertHavingExpr(
188
  expr: {[string]: any},
189
  types: {[string]: Array<string>},
190
  tables: Array<string>
191
): {[string]: any} {
192
  switch (expr.type) {
11!
193
    case 'ComparisonBooleanPrimary':
194
      return {
8✔
195
        cmp: {
196
          lhs: convertHavingValue(expr.left, types, tables),
197
          op: opMap[expr.operator],
198
          rhs: convertHavingValue(expr.right, types, tables),
199
        },
200
      };
201

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

212
    case 'OrExpression':
213
      return {
1✔
214
        or: {
215
          clauses: [
216
            convertHavingExpr(expr.left, types, tables),
217
            convertHavingExpr(expr.right, types, tables),
218
          ],
219
        },
220
      };
221

222
    case 'BetweenPredicate':
223
      const lhs = convertHavingValue(expr.left, types, tables);
1✔
224
      return {
1✔
225
        and: {
226
          clauses: [
227
            {
228
              cmp: {
229
                lhs,
230
                op: '$gte',
231
                rhs: convertHavingValue(expr.right.left, types, tables),
232
              },
233
            },
234
            {
235
              cmp: {
236
                lhs,
237
                op: '$lte',
238
                rhs: convertHavingValue(expr.right.right, types, tables),
239
              },
240
            },
241
          ],
242
        },
243
      };
244

245
    default:
246
      throw new Error(`Unsupported HAVING expression type: ${expr.type}`);
×
247
  }
248
}
249

250
/**
251
 * Converts a value in a HAVING expression (could be aggregate, column, or literal)
252
 */
253
function convertHavingValue(
254
  expr: {[string]: any},
255
  types: {[string]: Array<string>},
256
  tables: Array<string>
257
): string {
258
  if (typeof expr === 'string' || typeof expr === 'number') {
23!
259
    return expr.toString();
×
260
  }
261

262
  switch (expr.type) {
23!
263
    case 'FunctionCall':
264
      // Convert aggregate function to column name
265
      const funcName = expr.name.toUpperCase();
8✔
266
      let param;
267
      if (expr.params[0] === '*') {
8✔
268
        param = '*';
4✔
269
      } else {
270
        param = convertHavingValue(expr.params[0], types, tables);
4✔
271
      }
272
      return `${funcName}(${param})`;
8✔
273

274
    case 'Identifier':
275
      // Convert column reference to actual column name
276
      return expr.value;
5✔
277

278
    case 'Number':
279
    case 'String':
280
      return expr.value;
10✔
281

282
    default:
283
      throw new Error(`Unsupported HAVING value type: ${expr.type}`);
×
284
  }
285
}
286

287
/**
288
 * Validates that a HAVING expression only uses aggregate functions or GROUP BY columns
289
 * @param expr - the expression to validate
290
 * @param groupByColumns - columns that are in the GROUP BY clause (converted expressions)
291
 * @return true if valid, throws error if invalid
292
 */
293
function validateHavingExpression(
294
  expr: {[string]: any},
295
  groupByColumns: Array<string>
296
): boolean {
297
  switch (expr.type) {
34!
298
    case 'FunctionCall':
299
      const funcName = expr.name.toUpperCase();
9✔
300
      if (SUPPORTED_AGGREGATE_FUNCTIONS.includes(funcName)) {
9✔
301
        return true; // Aggregate functions are allowed
8✔
302
      }
303
      throw new Error(
1✔
304
        `Function '${expr.name}' is not allowed in HAVING clause`
305
      );
306

307
    case 'Identifier':
308
      const normalizedColumn = normalizeColumnName(expr.value);
2✔
309
      const isInGroupBy = groupByColumns.some(
2✔
310
        (groupCol) => normalizeColumnName(groupCol) === normalizedColumn
2✔
311
      );
312
      if (!isInGroupBy) {
2✔
313
        throw new Error(
1✔
314
          `Column '${expr.value}' in HAVING clause must appear in the GROUP BY clause or be used in an aggregate function`
315
        );
316
      }
317
      return true;
1✔
318

319
    case 'Number':
320
    case 'String':
321
      return true; // Literals are allowed
10✔
322

323
    case 'ComparisonBooleanPrimary':
324
      validateHavingExpression(expr.left, groupByColumns);
10✔
325
      validateHavingExpression(expr.right, groupByColumns);
8✔
326
      return true;
8✔
327

328
    case 'AndExpression':
329
    case 'OrExpression':
330
      validateHavingExpression(expr.left, groupByColumns);
2✔
331
      validateHavingExpression(expr.right, groupByColumns);
2✔
332
      return true;
2✔
333

334
    case 'BetweenPredicate':
335
      validateHavingExpression(expr.left, groupByColumns);
1✔
336
      validateHavingExpression(expr.right.left, groupByColumns);
1✔
337
      validateHavingExpression(expr.right.right, groupByColumns);
1✔
338
      return true;
1✔
339

340
    default:
341
      throw new Error(
×
342
        `Unsupported expression type '${expr.type}' in HAVING clause`
343
      );
344
  }
345
}
346

347
function convertExpr(
348
  expr: {[string]: any},
349
  types: {[string]: Array<string>},
350
  tables: Array<string>
351
): {[string]: any} {
352
  switch (expr.type) {
306!
353
    case 'BetweenPredicate':
354
      const lhs = convertExpr(expr.left, types, tables);
1✔
355
      return {
1✔
356
        and: {
357
          clauses: [
358
            {
359
              cmp: {
360
                lhs,
361
                op: '$gte',
362
                rhs: convertExpr(expr.right.left, types, tables),
363
              },
364
            },
365
            {
366
              cmp: {
367
                lhs,
368
                op: '$lte',
369
                rhs: convertExpr(expr.right.right, types, tables),
370
              },
371
            },
372
          ],
373
        },
374
      };
375

376
    case 'AndExpression':
377
      // Collect all expressions on either side of the AND
378
      let and: Array<any> = [];
10✔
379
      let exprLeft = Object.assign({}, expr);
10✔
380
      let exprRight: Array<any> = [];
10✔
381
      while (exprLeft.type === 'AndExpression') {
10✔
382
        exprRight.unshift(exprLeft.right);
19✔
383
        exprLeft = exprLeft.left;
19✔
384
      }
385
      addToExpr(and, exprLeft, types, tables);
10✔
386
      exprRight.forEach((element) => addToExpr(and, element, types, tables));
19✔
387

388
      return {and: {clauses: and}};
10✔
389

390
    case 'OrExpression':
391
      // Collect all expressions on either side of the AND
392
      let or: Array<any> = [];
6✔
393
      addToExpr(or, expr.left, types, tables);
6✔
394
      addToExpr(or, expr.right, types, tables);
6✔
395

396
      return {or: {clauses: or}};
6✔
397

398
    case 'NotExpression':
399
      return {not: {clause: convertExpr(expr.value, types, tables)}};
1✔
400

401
    case 'SimpleExprParentheses':
402
      if (
2!
403
        expr.value.type === 'ExpressionList' &&
4✔
404
        expr.value.value.length === 1
405
      ) {
406
        return convertExpr(expr.value.value[0], types, tables);
2✔
407
      } else {
408
        throw new Error(
×
409
          'Parenthesized expressions can only contain a single value'
410
        );
411
      }
412

413
    case 'ComparisonBooleanPrimary':
414
      return {
55✔
415
        cmp: {
416
          lhs: convertExpr(expr.left, types, tables),
417
          op: opMap[expr.operator],
418
          rhs: convertExpr(expr.right, types, tables),
419
        },
420
      };
421

422
    case 'OrderBy':
423
      const values = [];
4✔
424
      for (const value of expr.value) {
4✔
425
        values.push(convertExpr(value, types, tables));
5✔
426
      }
427
      return {
4✔
428
        order_by: values,
429
      };
430

431
    case 'GroupByOrderByItem':
432
      return {
5✔
433
        column_name: convertExpr(expr.value, types, tables),
434
        ascending: (expr.sortOpt || 'ASC').toUpperCase() === 'ASC',
7✔
435
      };
436

437
    case 'Identifier':
438
      // Splt into table, column parts
439
      let [table, column] = expr.value.split('.');
141✔
440
      if (!column) {
141✔
441
        column = table;
104✔
442
        table = undefined;
104✔
443
      }
444

445
      if (table) {
141✔
446
        // Ensure the given table exists
447
        if (!types[table]) {
37!
448
          throw new Error('Table ' + table + ' not found');
×
449
        }
450

451
        // Make sure the column of the table exists
452
        if (!types[table].includes(column)) {
37!
453
          throw new Error('Column ' + column + ' not found in ' + table);
×
454
        }
455

456
        if (!tables.includes(table)) {
37✔
457
          throw new Error('Table ' + table + ' is not referenced in query');
1✔
458
        }
459

460
        return expr.value;
36✔
461
      } else {
462
        // Find all tables which contain the column
463
        const columnTables = [];
104✔
464
        for (const table of tables) {
104✔
465
          if (types[table].includes(column)) {
104!
466
            columnTables.push(table);
104✔
467
          }
468
        }
469

470
        // Check if the column was found in any table
471
        if (!columnTables.length) {
104!
472
          throw new Error('Column  ' + column + ' not found');
×
473
        }
474

475
        // Ensure the column was found in only one table
476
        if (columnTables.length > 1) {
104!
477
          throw new Error('Column ' + column + ' is ambiguous');
×
478
        }
479

480
        return column;
104✔
481
      }
482

483
    case 'Number':
484
      // For literals, just return the value object as-is
485
      return expr.value;
47✔
486

487
    case 'String':
488
      // If needed, strip quotes
489
      if (
1!
490
        expr.value &&
6✔
491
        typeof expr.value === 'string' &&
492
        expr.value.length > 1 &&
493
        (expr.value[0] === "'" || expr.value[0] === '"') &&
494
        expr.value.charAt(expr.value.length - 1) === expr.value[0]
495
      ) {
496
        return expr.value.slice(1, -1);
1✔
497
      } else {
498
        return expr.value;
×
499
      }
500

501
    case 'InExpressionListPredicate':
502
      if (expr.right.type !== 'ExpressionList') {
2!
503
        // Currently IN expressions are only supported with lists of values
504
        throw new Error('Query not supported');
×
505
      }
506

507
      let orIn: Array<any> = [];
2✔
508
      for (const inSetElem of expr.right.value) {
2✔
509
        const inExpr = {
4✔
510
          type: 'ComparisonBooleanPrimary',
511
          left: expr.left,
512
          operator: '=',
513
          right: inSetElem,
514
        };
515
        addToExpr(orIn, inExpr, types, tables);
4✔
516
      }
517
      const inOrExpr = {or: {clauses: orIn}};
2✔
518

519
      if (expr.hasNot === 'NOT') {
2✔
520
        return {not: {clause: inOrExpr}};
1✔
521
      } else {
522
        return inOrExpr;
1✔
523
      }
524

525
    case 'FunctionCall':
526
      // Handle aggregate functions like MAX, MIN, AVG, SUM
527
      const funcName = expr.name.toUpperCase();
31✔
528
      if (!SUPPORTED_AGGREGATE_FUNCTIONS.includes(funcName)) {
31!
529
        throw new Error('Unsupported aggregate function: ' + expr.name);
×
530
      }
531

532
      if (expr.params.length !== 1) {
31!
533
        throw new Error('Aggregate functions must have exactly one parameter');
×
534
      }
535

536
      // Handle special case for COUNT(*)
537
      let param;
538
      if (expr.params[0] === '*') {
31✔
539
        param = '*';
7✔
540
      } else {
541
        const paramObj = convertExpr(expr.params[0], types, tables);
24✔
542
        param =
24✔
543
          typeof paramObj === 'string' ? paramObj : JSON.stringify(paramObj);
24!
544
      }
545

546
      return {
31✔
547
        aggregate: {
548
          function: funcName,
549
          column: param,
550
        },
551
      };
552

553
    default:
554
      // Produce an error if the expression is unsupported
555
      throw new Error('Invalid expression.');
×
556
  }
557
}
558

559
/**
560
 * @param sql - a parsed SQL query
561
 * @param types - an object mapping table names to lists of columns
562
 * @param tables - all tables used in the expression
563
 * @return a relational algebra expression object representing the query
564
 */
565
function buildRelExp(
566
  sql: {[string]: any},
567
  types: {[string]: Array<string>},
568
  tables: Array<string>
569
): {[string]: any} {
570
  switch (sql.type) {
230!
571
    case 'Except':
572
    case 'Intersect':
573
    case 'Union':
574
      const distinct = (sql.distinctOpt || '').toUpperCase();
5✔
575
      if (distinct && distinct !== 'ALL') {
5!
576
        throw new Error('Invalid distinct option');
×
577
      }
578

579
      const setType = sql.type.toLowerCase();
5✔
580

581
      // Ensure we use a different set of tables for each side
582
      const setTablesBackup = tables.slice();
5✔
583
      const leftSetExp = buildRelExp(sql.left, types, tables);
5✔
584
      const rightSetExp = buildRelExp(sql.right, types, setTablesBackup);
5✔
585

586
      return {
5✔
587
        [setType]: {
588
          left: leftSetExp,
589
          right: rightSetExp,
590
          distinct: !distinct,
591
        },
592
      };
593

594
    case 'Select':
595
      // Ensure we have a FROM clause
596
      if (!sql.from) {
69✔
597
        throw new Error('A FROM clause must be specified.');
1✔
598
      }
599

600
      // Build an expression for everything in the FROM clause
601
      let from = sql.from.value.map((v) => buildRelExp(v, types, tables));
68✔
602
      if (from.length !== 1) {
68!
603
        throw new Error('Only single table queries currently supported.');
×
604
      }
605

606
      // Wrap the table in a selection operator if there are any conditions
607
      if (sql.where) {
68✔
608
        from = [
20✔
609
          {
610
            selection: {
611
              arguments: {select: convertExpr(sql.where, types, tables)},
612
              children: from,
613
            },
614
          },
615
        ];
616
      }
617

618
      // Check for aggregates in SELECT clause (with or without GROUP BY)
619
      const select = sql.selectItems.value;
68✔
620
      const hasAggregates = select.some((field) => containsAggregate(field));
83✔
621

622
      // Helper function to check if a field contains aggregates without converting
623
      function containsAggregate(field: any): boolean {
624
        if (!field || typeof field !== 'object') return false;
83!
625

626
        // Direct function call
627
        if (field.type === 'FunctionCall') {
83✔
628
          const funcName = field.name?.toUpperCase?.();
27✔
629
          return SUPPORTED_AGGREGATE_FUNCTIONS.includes(funcName);
27✔
630
        }
631

632
        // Check nested structures recursively
633
        if (field.value && typeof field.value === 'object') {
56!
634
          return containsAggregate(field.value);
×
635
        }
636

637
        return false;
56✔
638
      }
639

640
      // Add group by operator if there's a GROUP BY clause OR aggregates are present
641
      if (sql.groupBy || hasAggregates) {
68✔
642
        // Now we need to convert expressions for validation and GROUP BY processing
643
        const aggregates = [];
27✔
644
        const groupColumns = [];
27✔
645

646
        for (const field of select) {
27✔
647
          const converted = convertExpr(field, types, tables);
46✔
648
          if (
46✔
649
            converted &&
123✔
650
            typeof converted === 'object' &&
651
            converted.aggregate
652
          ) {
653
            aggregates.push(converted);
31✔
654
          } else {
655
            groupColumns.push(converted);
15✔
656
          }
657
        }
658
        let groupByColumns: Array<string> = [];
27✔
659

660
        if (sql.groupBy) {
27✔
661
          groupByColumns = sql.groupBy.value.map((item) =>
25✔
662
            convertExpr(item.value, types, tables)
25✔
663
          );
664
        }
665

666
        // Validate GROUP BY rules: all non-aggregate SELECT columns must be in GROUP BY
667
        if (aggregates.length > 0 && groupColumns.length > 0) {
27✔
668
          for (const selectColumn of groupColumns) {
15✔
669
            const selectColumnStr =
670
              typeof selectColumn === 'string'
15!
671
                ? selectColumn
672
                : JSON.stringify(selectColumn);
673
            const normalizedSelectColumn = normalizeColumnName(selectColumnStr);
15✔
674
            const isInGroupBy = groupByColumns.some(
15✔
675
              (groupCol) =>
676
                normalizeColumnName(groupCol) === normalizedSelectColumn
15✔
677
            );
678

679
            if (!isInGroupBy) {
15✔
680
              throw new Error(
1✔
681
                `Column '${selectColumnStr}' must appear in the GROUP BY clause or be used in an aggregate function`
682
              );
683
            }
684
          }
685
        }
686

687
        // Validate ORDER BY rules when GROUP BY is present
688
        if (sql.orderBy) {
26✔
689
          const orderByColumns = sql.orderBy.value;
2✔
690
          for (const orderCol of orderByColumns) {
2✔
691
            const orderColumn = convertExpr(orderCol.value, types, tables);
2✔
692
            // Check if the ORDER BY column is either in GROUP BY or is an aggregate
693
            const isAggregate =
694
              orderColumn &&
2!
695
              typeof orderColumn === 'object' &&
696
              orderColumn.aggregate;
697

698
            if (!isAggregate) {
2!
699
              const orderColumnStr =
700
                typeof orderColumn === 'string'
2!
701
                  ? orderColumn
702
                  : JSON.stringify(orderColumn);
703
              const normalizedOrderColumn = normalizeColumnName(orderColumnStr);
2✔
704
              const isInGroupBy = groupByColumns.some(
2✔
705
                (groupCol) =>
706
                  normalizeColumnName(groupCol) === normalizedOrderColumn
2✔
707
              );
708

709
              if (!isInGroupBy) {
2✔
710
                throw new Error(
1✔
711
                  `Column '${orderColumnStr}' in ORDER BY clause must appear in the GROUP BY clause or be used in an aggregate function`
712
                );
713
              }
714
            }
715
          }
716
        }
717

718
        // Extract aggregates from HAVING clause if it exists
719
        let havingAggregates: Array<{[string]: any}> = [];
25✔
720
        if (sql.having) {
25✔
721
          havingAggregates = extractHavingAggregates(sql.having);
9✔
722
        }
723

724
        // Combine SELECT aggregates with HAVING aggregates, removing duplicates
725
        const allAggregates = [...aggregates];
25✔
726
        for (const havingAgg of havingAggregates) {
25✔
727
          const isDuplicate = allAggregates.some(
8✔
728
            (selectAgg) =>
729
              selectAgg.aggregate.function === havingAgg.aggregate.function &&
10✔
730
              selectAgg.aggregate.column === havingAgg.aggregate.column
731
          );
732
          if (!isDuplicate) {
8✔
733
            allAggregates.push(havingAgg);
1✔
734
          }
735
        }
736

737
        from = [
25✔
738
          {
739
            group_by: {
740
              arguments: {
741
                groupBy: groupByColumns,
742
                aggregates: allAggregates, // Include both SELECT and HAVING aggregates
743
                selectColumns: groupColumns, // Non-aggregate columns from SELECT
744
              },
745
              children: from,
746
            },
747
          },
748
        ];
749

750
        // Add HAVING clause if it exists
751
        if (sql.having) {
25✔
752
          // Validate HAVING expression
753
          validateHavingExpression(sql.having, groupByColumns);
9✔
754

755
          // Apply HAVING as a selection (restriction) operation after GROUP BY
756
          from = [
7✔
757
            {
758
              selection: {
759
                arguments: {
760
                  select: convertHavingExpr(sql.having, types, tables),
761
                },
762
                children: from,
763
              },
764
            },
765
          ];
766

767
          // If HAVING clause introduced additional aggregates not in SELECT,
768
          // add a projection to only return the originally requested columns
769
          const extraHavingAggregates = havingAggregates.filter((havingAgg) => {
7✔
770
            return !aggregates.some(
8✔
771
              (selectAgg) =>
772
                selectAgg.aggregate.function === havingAgg.aggregate.function &&
10✔
773
                selectAgg.aggregate.column === havingAgg.aggregate.column
774
            );
775
          });
776

777
          if (extraHavingAggregates.length > 0) {
7✔
778
            const originalColumns = groupColumns.map((col) =>
1✔
779
              typeof col === 'string' ? col : JSON.stringify(col)
1!
780
            ); // Non-aggregate SELECT columns
781

782
            // Add aggregate columns that were in the original SELECT
783
            for (const agg of aggregates) {
1✔
784
              const columnName = `${agg.aggregate.function}(${agg.aggregate.column})`;
1✔
785
              originalColumns.push(columnName);
1✔
786
            }
787

788
            from = [
1✔
789
              {
790
                projection: {
791
                  arguments: {project: originalColumns},
792
                  children: from,
793
                },
794
              },
795
            ];
796
          }
797
        }
798
      }
799

800
      if (sql.orderBy) {
64✔
801
        from = [
4✔
802
          {
803
            order_by: {
804
              arguments: convertExpr(sql.orderBy, types, tables),
805
              children: from,
806
            },
807
          },
808
        ];
809
      }
810

811
      // Add projections as needed for the SELECT clause
812

813
      // If GROUP BY is used or aggregates are present, return from without additional projection
814
      if (sql.groupBy || hasAggregates) {
64✔
815
        return from[0];
23✔
816
      }
817

818
      if (select.length === 1 && select[0].value === '*') {
41✔
819
        // Don't project anything if SELECT * is used
820
        return from[0];
36✔
821
      } else {
822
        const project = select.map((field) =>
5✔
823
          convertExpr(field, types, tables)
5✔
824
        );
825
        const projection = {
4✔
826
          projection: {
827
            arguments: {project},
828
            children: from,
829
          },
830
        };
831

832
        // Check for any aliased columns (e.g. SELECT foo AS bar...)
833
        const rename = select
4✔
834
          .filter((field) => field.hasAs)
4✔
835
          .map((field) => [field.value, field.alias]);
1✔
836
        if (rename.length === 0) {
4✔
837
          // Don't add a rename if not needed
838
          return projection;
3✔
839
        } else {
840
          // Perform any necessary renames
841
          return {
1✔
842
            rename: {
843
              arguments: {rename: {columns: fromEntries(rename)}},
844
              children: [projection],
845
            },
846
          };
847
        }
848
      }
849

850
    case 'SubQuery':
851
    case 'TableReference':
852
      return buildRelExp(sql.value, types, tables);
68✔
853

854
    case 'TableFactor':
855
      // Store this table as one referenced by the query
856
      tables.push(sql.value.value);
78✔
857

858
      return {relation: sql.value.value};
78✔
859

860
    case 'InnerCrossJoinTable':
861
      // Add the condition if it exists
862
      if (sql.condition) {
8✔
863
        return {
7✔
864
          join: {
865
            left: buildRelExp(sql.left, types, tables),
866
            right: buildRelExp(sql.right, types, tables),
867
            type: 'inner',
868
            condition: convertExpr(sql.condition.value, types, tables),
869
          },
870
        };
871
      } else {
872
        return {
1✔
873
          product: {
874
            left: buildRelExp(sql.left, types, tables),
875
            right: buildRelExp(sql.right, types, tables),
876
          },
877
        };
878
      }
879

880
    case 'LeftRightJoinTable':
881
      // Add the condition if it exists
882
      if (sql.condition) {
2!
883
        return {
2✔
884
          join: {
885
            left: buildRelExp(sql.left, types, tables),
886
            right: buildRelExp(sql.right, types, tables),
887
            type: sql.leftRight.toLowerCase(),
888
            condition: convertExpr(sql.condition.value, types, tables),
889
          },
890
        };
891
      } else {
892
        throw new Error('Condition-less ' + sql.leftRight + ' Join');
×
893
      }
894

895
    default:
896
      throw new Error('Unsupported statement ' + sql.type + '.');
×
897
  }
898
}
899

900
/**
901
 * Optimizes a given relational algebra expression, if possible
902
 * @param type string denoting the type of optimization
903
 * @param expr object denoting the expression to optimize
904
 * @returns {{join: {condition: {cmp: {op, lhs, rhs}}, left: {[p: string]: *}, right: {[p: string]: *}, type: *}}|{[p: string]: *}}
905
 */
906
function optimize(type: string, expr: {[key: string]: any}) {
907
  switch (type) {
4!
908
    case 'join':
909
      const {graph, globalSelections, canOptimize} =
910
        constructRelationalGraph(expr);
4✔
911
      if (!canOptimize) return expr;
4!
912
      const optimizedExpr = joinOrderOptimization(graph, globalSelections);
4✔
913
      return optimizedExpr;
4✔
914
    default:
915
      return expr;
×
916
  }
917
}
918

919
const reducer: (
920
  State,
921
  ExprFromSqlAction | EnableOptimizationAction | DisableOptimizationAction
922
) => State = produce<
5✔
923
  State,
924
  ExprFromSqlAction | EnableOptimizationAction | DisableOptimizationAction,
925
>(
926
  (
927
    draft: State,
928
    action:
929
      | ExprFromSqlAction
930
      | EnableOptimizationAction
931
      | DisableOptimizationAction
932
  ) => {
933
    // eslint-disable-next-line default-case
934
    switch (action.type) {
73✔
935
      case EXPR_FROM_SQL:
936
        draft.expr = buildRelExp(action.sql, action.types, []);
64✔
937
        delete draft.unoptimizedExpr;
58✔
938
        delete draft.optimized;
58✔
939
        break;
58✔
940
      case ENABLE_OPTIMIZATION:
941
        draft.unoptimizedExpr = draft.expr;
4✔
942
        draft.expr = optimize(action.optimization, draft.expr);
4✔
943
        draft.optimized = true;
4✔
944
        break;
4✔
945
      case DISABLE_OPTIMIZATION:
946
        if (draft.unoptimizedExpr) {
2!
947
          draft.expr = draft.unoptimizedExpr;
2✔
948
          delete draft.unoptimizedExpr;
2✔
949
        }
950
        delete draft.optimized;
2✔
951
    }
952
  },
953
  initialState
954
);
955

956
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