• 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

89.33
/src/modules/data.js
1
// @flow
2
import {deepEqual} from 'fast-equals';
3
import {produce} from 'immer';
4

5
import department from '../resources/Department.json';
6
import doctor from '../resources/Doctor.json';
7
import patient from '../resources/Patient.json';
8

9
export const CHANGE_EXPR = 'CHANGE_EXPR';
11✔
10
export const RESET_EXPR = 'RESET_EXPR';
11✔
11

12
type ChangeAction = {
13
  type: 'CHANGE_EXPR',
14
  expr: {[string]: any},
15
  element: ?HTMLElement,
16
};
17

18
type ResetAction = {
19
  type: 'RESET_EXPR',
20
};
21

22
type Action = ChangeAction | ResetAction;
23

24
type Output = {
25
  name: string,
26
  columns: Array<string>,
27
  data: Array<{[string]: any}>,
28
};
29

30
/**
31
 * @param expr - a relational algebra expression object
32
 * @param element
33
 * @return a new CHANGE_EXPR action
34
 */
35
export function changeExpr(
36
  expr: {[string]: any},
37
  element: ?HTMLElement
38
): ChangeAction {
39
  return {type: CHANGE_EXPR, expr, element};
42✔
40
}
41

42
export function resetAction(): ResetAction {
43
  return {type: RESET_EXPR};
×
44
}
45

46
export type Data = {
47
  name: string,
48
  columns: Array<string>,
49
  data: Array<{[string]: any}>,
50
};
51

52
export type State = {
53
  current?: Data,
54
  sourceData: {[string]: Data},
55
  element: ?HTMLElement,
56
  expr: {[string]: any},
57
};
58

59
// Source data which can be used in SQL queries
60
export const initialState = {
11✔
61
  sourceData: {
62
    Department: department,
63
    Doctor: doctor,
64
    Patient: patient,
65
  },
66
  element: undefined,
67
  expr: {},
68
};
69

70
function getCombinedColumns(left: {[string]: any}, right: {[string]: any}) {
71
  // Combine columns adding relation name where needed
72
  const combinedColumns: Array<string> = [];
50✔
73
  for (const leftColumn of left.columns) {
50✔
74
    if (right.columns.includes(leftColumn)) {
256✔
75
      combinedColumns.push(left.name + '.' + leftColumn);
84✔
76
    } else {
77
      combinedColumns.push(leftColumn);
172✔
78
    }
79
  }
80
  for (const rightColumn of right.columns) {
50✔
81
    if (left.columns.includes(rightColumn)) {
213✔
82
      combinedColumns.push(right.name + '.' + rightColumn);
84✔
83
    } else {
84
      combinedColumns.push(rightColumn);
129✔
85
    }
86
  }
87

88
  return combinedColumns;
50✔
89
}
90

91
function getCombinedData(
92
  leftName: string,
93
  leftRow: {[string]: any},
94
  rightName: string,
95
  rightRow: {[string]: any},
96
  combinedColumns: Array<string>,
97
  outerJoin: ?boolean
98
): {[string]: any} {
99
  // Combine data from the two objects including the relation name
100
  const combinedData: {[string]: any} = {};
41,400✔
101
  for (const leftKey in leftRow) {
41,400✔
102
    combinedData[leftName + '.' + leftKey] = leftRow[leftKey];
239,721✔
103
  }
104
  for (const rightKey in rightRow) {
41,400✔
105
    if (outerJoin) {
221,687✔
106
      combinedData[rightName + '.' + rightKey] = null;
3✔
107
    } else {
108
      combinedData[rightName + '.' + rightKey] = rightRow[rightKey];
221,684✔
109
    }
110
  }
111

112
  // Resolve the output data according to the combined data
113
  // This may remove relation names where they are not needed
114
  const outputData: {[string]: any} = {};
41,400✔
115
  for (const column of combinedColumns) {
41,400✔
116
    outputData[column] = combinedData[resolveColumn(column, combinedData)];
461,408✔
117
  }
118

119
  return outputData;
41,400✔
120
}
121

122
function resolveColumn(path: string, row: {[string]: any}): string {
123
  // Avoid an error if we're projecting nothing
124
  if (!row) {
545,100!
125
    return path;
×
126
  }
127

128
  const pathParts = path.split('.');
545,100✔
129
  let [table, maybeColumn]: [?string, ?string] = [pathParts[0], pathParts[1]];
545,100✔
130
  const column: string = maybeColumn || pathParts[0];
545,100✔
131
  if (!maybeColumn) {
545,100✔
132
    table = undefined;
231,489✔
133
  }
134

135
  if (table) {
545,100✔
136
    if (row.hasOwnProperty(path)) {
313,611✔
137
      // Use the dotted path
138
      return path;
248,080✔
139
    } else if (row.hasOwnProperty(column)) {
65,531✔
140
      // Use the column name without the table qualifier
141
      return column;
40,731✔
142
    }
143
  }
144

145
  // Check for bare columns first
146
  const columns = [];
256,289✔
147
  for (const rowCol in row) {
256,289✔
148
    if (rowCol === column) {
2,936,911✔
149
      columns.push(rowCol);
232✔
150
    }
151
  }
152

153
  // Check if we found the correct column
154
  if (columns.length === 1) {
256,289✔
155
    return columns[0];
232✔
156
  } else if (columns.length > 1) {
256,057!
157
    throw new Error('Invalid column ' + path);
×
158
  }
159

160
  // Then check for the column with a prefix
161
  columns.splice(0);
256,057✔
162
  for (const rowCol in row) {
256,057✔
163
    const rowColParts = rowCol.split('.').length;
2,936,146✔
164
    if (!table && rowColParts < 3 && rowCol.endsWith('.' + column)) {
2,936,146✔
165
      columns.push(rowCol);
231,168✔
166
    }
167
  }
168

169
  // Check if we found the correct column
170
  if (columns.length === 1) {
256,057✔
171
    return columns[0];
231,168✔
172
  } else if (columns.length > 1) {
24,889!
173
    throw new Error('Invalid column ' + path);
×
174
  }
175

176
  // Finally check with a table and column prefix
177
  columns.splice(0);
24,889✔
178
  for (const rowCol in row) {
24,889✔
179
    if (table && rowCol.endsWith('.' + table + '.' + column)) {
322,610✔
180
      columns.push(rowCol);
24,800✔
181
    }
182
  }
183

184
  // Check if we found the correct column
185
  if (columns.length === 1) {
24,889✔
186
    return columns[0];
24,800✔
187
  } else {
188
    throw new Error('Invalid column ' + path);
89✔
189
  }
190
}
191

192
// Try to resolve a column, otherwise treat it as a literal
193
function resolveValue(path: string, row: {[string]: any}): string {
194
  let value = path;
83,554✔
195
  try {
83,554✔
196
    value = row[resolveColumn(path, row)];
83,554✔
197
  } catch {}
198
  return value;
83,554✔
199
}
200

201
/**
202
 * Normalizes column names for comparison by extracting the base column name
203
 * @param columnName - column name (could be qualified like "Doctor.departmentId" or unqualified like "departmentId")
204
 * @return the base column name without table qualification
205
 */
206
function normalizeColumnName(columnName: string): string {
207
  if (typeof columnName !== 'string') {
42!
208
    return columnName;
×
209
  }
210
  const parts = columnName.split('.');
42✔
211
  return parts[parts.length - 1]; // Return the last part (column name)
42✔
212
}
213

214
/**
215
 * @param expr - a relational algebra expression to evaluate
216
 * @param item - an item to evaluate against
217
 * @return result of evaluating the expression
218
 */
219
function applyItem(expr: {[string]: any}, item: {[string]: any}): any {
220
  const type = Object.keys(expr)[0];
49,325✔
221
  switch (type) {
49,325!
222
    case 'cmp':
223
      // Get the values to compare and the comparison operator
224
      const lhs = resolveValue(expr.cmp.lhs, item);
41,777✔
225
      const op = expr.cmp.op;
41,777✔
226
      let rhs = resolveValue(expr.cmp.rhs, item);
41,777✔
227

228
      switch (op) {
41,777!
229
        case '$gte':
230
          return lhs >= rhs;
10✔
231
        case '$gt':
232
          return lhs > rhs;
43✔
233
        case '$lt':
234
          return lhs < rhs;
3✔
235
        case '$lte':
236
          return lhs <= rhs;
3✔
237
        case '$ne':
238
          // eslint-disable-next-line eqeqeq
239
          return lhs != rhs;
15✔
240
        case '$eq':
241
          // eslint-disable-next-line eqeqeq
242
          return lhs == rhs;
41,703✔
243
        default:
244
          throw new Error('Invaid comparison operator');
×
245
      }
246

247
    case 'and':
248
      let andResult = true;
7,533✔
249

250
      // Loop over all expressions to be evaluated
251
      for (var i = 0; andResult && i < expr.and.clauses.length; i++) {
7,533✔
252
        andResult = andResult && applyItem(expr.and.clauses[i], item);
7,865✔
253
      }
254
      return andResult;
7,533✔
255

256
    case 'or':
257
      let orResult = false;
12✔
258

259
      // Loop over all expressions to be evaluated
260
      for (var i2 = 0; !orResult && i2 < expr.or.clauses.length; i2++) {
12✔
261
        orResult = orResult || applyItem(expr.or.clauses[i2], item);
17✔
262
      }
263
      return orResult;
12✔
264

265
    case 'not':
266
      return !applyItem(expr.not.clause, item);
3✔
267

268
    default:
269
      console.log(expr);
×
270
      throw new Error('Invalid expression');
×
271
  }
272
}
273

274
/**
275
 * @param expr - a relational algebra expression to evaluate
276
 * @param sourceData - source data from relations
277
 * @return result of evaluating the expression
278
 */
279
export function applyExpr(
280
  expr: {[string]: any},
281
  sourceData: {[string]: any}
282
): {[string]: any} {
283
  const type = Object.keys(expr)[0];
220✔
284
  switch (type) {
220!
285
    case 'projection':
286
      // Evaluate the single child of this expression
287
      let projData = applyExpr(expr.projection.children[0], sourceData);
4✔
288

289
      // Get the columns which should be deleted
290
      const columns = projData.columns.map((col) =>
4✔
291
        resolveColumn(col, projData.data[0])
11✔
292
      );
293
      const keep = expr.projection.arguments.project.map((col) =>
4✔
294
        resolveColumn(col, projData.data[0])
6✔
295
      );
296
      const deleted = columns.filter((column) => keep.indexOf(column) === -1);
11✔
297

298
      // Make a copy of the list of columns to project
299
      projData.columns = keep;
4✔
300

301
      // Delete data values which should not be included
302
      for (let i = 0; i < deleted.length; i++) {
4✔
303
        for (let j = 0; j < projData.data.length; j++) {
5✔
304
          delete projData.data[j][deleted[i]];
12✔
305
        }
306
      }
307
      return projData;
4✔
308

309
    case 'selection':
310
      // Evaluate the single child of this expression
311
      let selData = applyExpr(expr.selection.children[0], sourceData);
16✔
312

313
      let select = expr.selection.arguments.select;
16✔
314
      selData.data = selData.data.filter((item) => applyItem(select, item));
48✔
315

316
      return selData;
16✔
317

318
    case 'rename':
319
      // Evaluate the single child of this expression
320
      let renData = applyExpr(expr.rename.children[0], sourceData);
1✔
321

322
      // Loop over all pairs of things to rename
323
      Object.entries(expr.rename.arguments.rename.columns).forEach(
1✔
324
        ([from, to]) => {
325
          // Ensure target name is a string
326
          if (typeof to !== 'string') {
1!
327
            throw new Error('Invalid target for rename');
×
328
          }
329

330
          // Add a new column with the new name
331
          const fromColumn = resolveColumn(from, renData.data[0]);
1✔
332
          renData.columns[renData.columns.indexOf(fromColumn)] = to;
1✔
333

334
          // Copy all column data and delete the original column
335
          for (let j = 0; j < renData.data.length; j++) {
1✔
336
            renData.data[j][to] = renData.data[j][fromColumn];
3✔
337
            delete renData.data[j][fromColumn];
3✔
338
          }
339
        }
340
      );
341
      return renData;
1✔
342

343
    case 'relation':
344
      // Make a copy of the data from a source table and return it
345
      return {...sourceData[expr.relation]};
128✔
346

347
    case 'order_by':
348
      let ordData = applyExpr(expr.order_by.children[0], sourceData);
3✔
349

350
      ordData.data.sort((a, b) => {
3✔
351
        let sortOrder = 0;
10✔
352
        expr.order_by.arguments.order_by.every((c) => {
10✔
353
          // Continue as long as column values are equal
354
          if (sortOrder !== 0) {
16✔
355
            return false;
4✔
356
          }
357

358
          if (a[c.column_name] < b[c.column_name]) {
12✔
359
            sortOrder = c.ascending ? -1 : 1;
5✔
360
          } else if (a[c.column_name] > b[c.column_name]) {
7✔
361
            sortOrder = c.ascending ? 1 : -1;
5✔
362
          }
363

364
          return true;
12✔
365
        });
366

367
        return sortOrder;
10✔
368
      });
369

370
      return ordData;
3✔
371

372
    case 'group_by':
373
      let groupData = applyExpr(expr.group_by.children[0], sourceData);
14✔
374
      const groupByColumns = expr.group_by.arguments.groupBy;
14✔
375
      const aggregates = expr.group_by.arguments.aggregates;
14✔
376
      const selectColumns = expr.group_by.arguments.selectColumns || [];
14!
377

378
      // Group the data by the specified columns
379
      const groups: {[string]: Array<{[string]: any}>} = {};
14✔
380
      for (const row of groupData.data) {
14✔
381
        // Create a group key from the group by columns
382
        // If no grouping columns, use a single group for all data
383
        const groupKey =
384
          groupByColumns.length > 0
70✔
385
            ? groupByColumns
386
                .map((col) => {
387
                  const resolvedCol = resolveColumn(col, row);
45✔
388
                  return row[resolvedCol];
45✔
389
                })
390
                .join('|')
391
            : 'all'; // Single group key when no GROUP BY columns
392

393
        if (!groups[groupKey]) {
70✔
394
          groups[groupKey] = [];
32✔
395
        }
396
        groups[groupKey].push(row);
70✔
397
      }
398

399
      // Calculate aggregates for each group
400
      const resultData = [];
14✔
401
      // Only include explicitly selected columns, not all GROUP BY columns
402
      const resultColumns = [...selectColumns];
14✔
403

404
      // Add aggregate columns to result columns
405
      for (const agg of aggregates) {
14✔
406
        resultColumns.push(
18✔
407
          `${agg.aggregate.function}(${agg.aggregate.column})`
408
        );
409
      }
410

411
      for (const [groupKey, groupRows] of Object.entries(groups)) {
14✔
412
        const resultRow: {[string]: any} = {};
32✔
413

414
        // Add explicitly selected column values (from SELECT clause)
415
        if (selectColumns.length > 0) {
32✔
416
          const groupKeyValues = groupKey.split('|');
21✔
417
          for (const selectCol of selectColumns) {
21✔
418
            // Find the index of this select column in the groupBy columns
419
            const groupByIndex = groupByColumns.findIndex(
21✔
420
              (groupCol) =>
421
                normalizeColumnName(groupCol) === normalizeColumnName(selectCol)
21✔
422
            );
423
            if (groupByIndex >= 0) {
21!
424
              resultRow[selectCol] = groupKeyValues[groupByIndex];
21✔
425
            }
426
          }
427
        }
428

429
        // Calculate aggregates
430
        for (const agg of aggregates) {
32✔
431
          const column = agg.aggregate.column;
44✔
432
          const func = agg.aggregate.function;
44✔
433

434
          let result;
435
          if (func === 'COUNT') {
44✔
436
            // COUNT doesn't need to resolve column, just count rows
437
            result = groupRows.length;
9✔
438
          } else {
439
            // Other aggregates need to resolve the column and get values
440
            const values = groupRows.map((row: {[string]: any}) => {
35✔
441
              const resolvedCol = resolveColumn(column, row);
75✔
442
              return parseFloat(row[resolvedCol]) || 0;
75!
443
            });
444

445
            switch (func) {
35!
446
              case 'MAX':
447
                result = Math.max(...values);
10✔
448
                break;
10✔
449
              case 'MIN':
450
                result = Math.min(...values);
13✔
451
                break;
13✔
452
              case 'AVG':
453
                result =
7✔
454
                  values.reduce((sum, val) => sum + val, 0) / values.length;
15✔
455
                break;
7✔
456
              case 'SUM':
457
                result = values.reduce((sum, val) => sum + val, 0);
15✔
458
                break;
5✔
459
              case 'STDEV':
460
                if (values.length <= 1) {
×
461
                  result = 0;
×
462
                } else {
463
                  const mean =
464
                    values.reduce((sum, val) => sum + val, 0) / values.length;
×
465
                  const variance =
466
                    values.reduce(
×
467
                      (sum, val) => sum + Math.pow(val - mean, 2),
×
468
                      0
469
                    ) /
470
                    (values.length - 1);
471
                  result = Math.sqrt(variance);
×
472
                }
473
                break;
×
474
              default:
475
                throw new Error('Unsupported aggregate function: ' + func);
×
476
            }
477
          }
478

479
          resultRow[`${func}(${column})`] = result;
44✔
480
        }
481

482
        resultData.push(resultRow);
32✔
483
      }
484

485
      return {
14✔
486
        name: groupData.name + ' (grouped)',
487
        columns: resultColumns,
488
        data: resultData,
489
      };
490

491
    case 'except':
492
    case 'intersect':
493
    case 'union':
494
      // Process each side of the operation
495
      const setLeft = applyExpr(expr[type].left, sourceData);
4✔
496
      const setRight = applyExpr(expr[type].right, sourceData);
4✔
497

498
      // Check for valid columns
499
      if (setLeft.columns.length !== setRight.columns.length) {
4!
500
        throw new Error(
×
501
          'Each side of ' + type + ' must have the same number of columns'
502
        );
503
      }
504

505
      const outColumns: Array<string> = setLeft.columns.slice();
4✔
506
      const setOutput: Output = {
4✔
507
        name: setLeft.name + ' ∪ ' + setRight.name,
508
        columns: outColumns,
509
        data: [],
510
      };
511

512
      for (const leftRow of setLeft.data) {
4✔
513
        // Add the row if it doesn't exist or we don't want distinct
514
        if (
8!
515
          !expr[type].distinct ||
14✔
516
          setOutput.data.find((row) => deepEqual(row, leftRow)) === undefined
3✔
517
        ) {
518
          setOutput.data.push(leftRow);
8✔
519
        }
520
      }
521

522
      // Generate new rows for the right side with the salem
523
      // column names as those on the left
524
      const newRight = setRight.data.map((rightRow) => {
4✔
525
        const newRow: {[string]: any} = {};
8✔
526
        for (const rightKey of Object.keys(rightRow)) {
8✔
527
          newRow[setLeft.columns[setRight.columns.indexOf(rightKey)]] =
8✔
528
            rightRow[rightKey];
529
        }
530

531
        return newRow;
8✔
532
      });
533

534
      if (type === 'intersect') {
4✔
535
        // Keep only rows from th left which have a match on the right
536
        setOutput.data = setOutput.data.filter((leftRow) => {
1✔
537
          for (const rightRow of newRight) {
2✔
538
            if (deepEqual(leftRow, rightRow)) {
3✔
539
              return true;
1✔
540
            }
541
          }
542
          return false;
1✔
543
        });
544
      } else {
545
        for (const rightRow of newRight) {
3✔
546
          if (type === 'except') {
6✔
547
            // Remove any matching rows
548
            setOutput.data = setOutput.data.filter(
2✔
549
              (row) => !deepEqual(row, rightRow)
3✔
550
            );
551
          } else if (type === 'union') {
4!
552
            // Add the row if it doesn't exist or we don't want distinct
553
            if (
4✔
554
              !expr[type].distinct ||
6✔
555
              setOutput.data.find((row) => deepEqual(row, rightRow)) ===
3✔
556
                undefined
557
            ) {
558
              setOutput.data.push(rightRow);
3✔
559
            }
560
          }
561
        }
562
      }
563

564
      return setOutput;
4✔
565

566
    case 'join':
567
      // Process each side of the join
568
      let joinLeft = applyExpr(expr.join.left, sourceData);
49✔
569
      let joinRight = applyExpr(expr.join.right, sourceData);
49✔
570
      const combinedJoinColumns = getCombinedColumns(joinLeft, joinRight);
49✔
571
      let joinType = expr.join.type;
49✔
572

573
      let joinSymbol = ' ⋈ ';
49✔
574
      if (joinType === 'left') {
49✔
575
        joinSymbol = ' ⟕ ';
1✔
576
      } else if (joinType === 'right') {
48✔
577
        joinSymbol = ' ⟖ ';
1✔
578
      }
579

580
      const joinOutput: Output = {
49✔
581
        name: joinLeft.name + joinSymbol + joinRight.name,
582
        columns: combinedJoinColumns,
583
        data: [],
584
      };
585

586
      if (joinType === 'right') {
49✔
587
        let temp = joinLeft;
1✔
588
        joinLeft = joinRight;
1✔
589
        joinRight = temp;
1✔
590
      }
591

592
      // Perform the join
593
      for (const leftRow of joinLeft.data) {
49✔
594
        let matchFound = false;
1,379✔
595
        for (const rightRow of joinRight.data) {
1,379✔
596
          const combinedJoinData = getCombinedData(
41,392✔
597
            joinLeft.name,
598
            leftRow,
599
            joinRight.name,
600
            rightRow,
601
            combinedJoinColumns
602
          );
603
          if (applyItem(expr.join.condition, combinedJoinData)) {
41,392✔
604
            joinOutput.data.push(combinedJoinData);
1,267✔
605
            matchFound = true;
1,267✔
606
          }
607
        }
608
        if (!matchFound && joinType !== 'inner') {
1,379✔
609
          const combinedJoinData = getCombinedData(
2✔
610
            joinLeft.name,
611
            leftRow,
612
            joinRight.name,
613
            joinRight.data[0],
614
            combinedJoinColumns,
615
            true
616
          );
617
          joinOutput.data.push(combinedJoinData);
2✔
618
        }
619
      }
620

621
      return joinOutput;
49✔
622

623
    case 'product':
624
      // Process each side of the product
625
      const left = applyExpr(expr.product.left, sourceData);
1✔
626
      const right = applyExpr(expr.product.right, sourceData);
1✔
627
      const combinedColumns = getCombinedColumns(left, right);
1✔
628

629
      const output: Output = {
1✔
630
        name: left.name + ' × ' + right.name,
631
        columns: combinedColumns,
632
        data: [],
633
      };
634

635
      // Perform the cross product
636
      for (const leftRow of left.data) {
1✔
637
        for (const rightRow of right.data) {
3✔
638
          output.data.push(
6✔
639
            getCombinedData(
640
              left.name,
641
              leftRow,
642
              right.name,
643
              rightRow,
644
              combinedColumns
645
            )
646
          );
647
        }
648
      }
649

650
      return output;
1✔
651

652
    default:
653
      // Fallback in case we get something invalid to show a nice error
654
      throw new Error('Invalid expression');
×
655
  }
656
}
657

658
const reducer: (State, Action) => State = produce<State, Action>(
11✔
659
  (draft: State, action: Action) => {
660
    // eslint-disable-next-line default-case
661
    switch (action.type) {
47!
662
      case RESET_EXPR:
663
        draft.expr = {};
×
664
        draft.current = undefined;
×
665
        draft.element = undefined;
×
666
        break;
×
667
      case CHANGE_EXPR:
668
        draft.expr = action.expr;
42✔
669
        draft.current =
42✔
670
          JSON.stringify(action.expr) === JSON.stringify({})
42!
671
            ? undefined
672
            : applyExpr(action.expr, draft.sourceData);
673
        break;
42✔
674
    }
675
  },
676
  initialState
677
);
678

679
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