Home Reference Source Test

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';
  10. export const RESET_EXPR = 'RESET_EXPR';
  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};
  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 = {
  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> = [];
  73. for (const leftColumn of left.columns) {
  74. if (right.columns.includes(leftColumn)) {
  75. combinedColumns.push(left.name + '.' + leftColumn);
  76. } else {
  77. combinedColumns.push(leftColumn);
  78. }
  79. }
  80. for (const rightColumn of right.columns) {
  81. if (left.columns.includes(rightColumn)) {
  82. combinedColumns.push(right.name + '.' + rightColumn);
  83. } else {
  84. combinedColumns.push(rightColumn);
  85. }
  86. }
  87.  
  88. return combinedColumns;
  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} = {};
  101. for (const leftKey in leftRow) {
  102. combinedData[leftName + '.' + leftKey] = leftRow[leftKey];
  103. }
  104. for (const rightKey in rightRow) {
  105. if (outerJoin) {
  106. combinedData[rightName + '.' + rightKey] = null;
  107. } else {
  108. combinedData[rightName + '.' + rightKey] = rightRow[rightKey];
  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} = {};
  115. for (const column of combinedColumns) {
  116. outputData[column] = combinedData[resolveColumn(column, combinedData)];
  117. }
  118.  
  119. return outputData;
  120. }
  121.  
  122. function resolveColumn(path: string, row: {[string]: any}): string {
  123. // Avoid an error if we're projecting nothing
  124. if (!row) {
  125. return path;
  126. }
  127.  
  128. const pathParts = path.split('.');
  129. let [table, maybeColumn]: [?string, ?string] = [pathParts[0], pathParts[1]];
  130. const column: string = maybeColumn || pathParts[0];
  131. if (!maybeColumn) {
  132. table = undefined;
  133. }
  134.  
  135. if (table) {
  136. if (row.hasOwnProperty(path)) {
  137. // Use the dotted path
  138. return path;
  139. } else if (row.hasOwnProperty(column)) {
  140. // Use the column name without the table qualifier
  141. return column;
  142. }
  143. }
  144.  
  145. // Check for bare columns first
  146. const columns = [];
  147. for (const rowCol in row) {
  148. if (rowCol === column) {
  149. columns.push(rowCol);
  150. }
  151. }
  152.  
  153. // Check if we found the correct column
  154. if (columns.length === 1) {
  155. return columns[0];
  156. } else if (columns.length > 1) {
  157. throw new Error('Invalid column ' + path);
  158. }
  159.  
  160. // Then check for the column with a prefix
  161. columns.splice(0);
  162. for (const rowCol in row) {
  163. const rowColParts = rowCol.split('.').length;
  164. if (!table && rowColParts < 3 && rowCol.endsWith('.' + column)) {
  165. columns.push(rowCol);
  166. }
  167. }
  168.  
  169. // Check if we found the correct column
  170. if (columns.length === 1) {
  171. return columns[0];
  172. } else if (columns.length > 1) {
  173. throw new Error('Invalid column ' + path);
  174. }
  175.  
  176. // Finally check with a table and column prefix
  177. columns.splice(0);
  178. for (const rowCol in row) {
  179. if (table && rowCol.endsWith('.' + table + '.' + column)) {
  180. columns.push(rowCol);
  181. }
  182. }
  183.  
  184. // Check if we found the correct column
  185. if (columns.length === 1) {
  186. return columns[0];
  187. } else {
  188. throw new Error('Invalid column ' + path);
  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;
  195. try {
  196. value = row[resolveColumn(path, row)];
  197. } catch {}
  198. return value;
  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') {
  208. return columnName;
  209. }
  210. const parts = columnName.split('.');
  211. return parts[parts.length - 1]; // Return the last part (column name)
  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];
  221. switch (type) {
  222. case 'cmp':
  223. // Get the values to compare and the comparison operator
  224. const lhs = resolveValue(expr.cmp.lhs, item);
  225. const op = expr.cmp.op;
  226. let rhs = resolveValue(expr.cmp.rhs, item);
  227.  
  228. switch (op) {
  229. case '$gte':
  230. return lhs >= rhs;
  231. case '$gt':
  232. return lhs > rhs;
  233. case '$lt':
  234. return lhs < rhs;
  235. case '$lte':
  236. return lhs <= rhs;
  237. case '$ne':
  238. // eslint-disable-next-line eqeqeq
  239. return lhs != rhs;
  240. case '$eq':
  241. // eslint-disable-next-line eqeqeq
  242. return lhs == rhs;
  243. default:
  244. throw new Error('Invaid comparison operator');
  245. }
  246.  
  247. case 'and':
  248. let andResult = true;
  249.  
  250. // Loop over all expressions to be evaluated
  251. for (var i = 0; andResult && i < expr.and.clauses.length; i++) {
  252. andResult = andResult && applyItem(expr.and.clauses[i], item);
  253. }
  254. return andResult;
  255.  
  256. case 'or':
  257. let orResult = false;
  258.  
  259. // Loop over all expressions to be evaluated
  260. for (var i2 = 0; !orResult && i2 < expr.or.clauses.length; i2++) {
  261. orResult = orResult || applyItem(expr.or.clauses[i2], item);
  262. }
  263. return orResult;
  264.  
  265. case 'not':
  266. return !applyItem(expr.not.clause, item);
  267.  
  268. default:
  269. throw new Error('Invalid expression');
  270. }
  271. }
  272.  
  273. /**
  274. * @param expr - a relational algebra expression to evaluate
  275. * @param sourceData - source data from relations
  276. * @return result of evaluating the expression
  277. */
  278. export function applyExpr(
  279. expr: {[string]: any},
  280. sourceData: {[string]: any}
  281. ): {[string]: any} {
  282. const type = Object.keys(expr)[0];
  283. switch (type) {
  284. case 'projection':
  285. // Evaluate the single child of this expression
  286. let projData = applyExpr(expr.projection.children[0], sourceData);
  287.  
  288. // Get the columns which should be deleted
  289. const columns = projData.columns.map((col) =>
  290. resolveColumn(col, projData.data[0])
  291. );
  292. const keep = expr.projection.arguments.project.map((col) =>
  293. resolveColumn(col, projData.data[0])
  294. );
  295. const deleted = columns.filter((column) => keep.indexOf(column) === -1);
  296.  
  297. // Make a copy of the list of columns to project
  298. projData.columns = keep;
  299.  
  300. // Delete data values which should not be included
  301. for (let i = 0; i < deleted.length; i++) {
  302. for (let j = 0; j < projData.data.length; j++) {
  303. delete projData.data[j][deleted[i]];
  304. }
  305. }
  306. return projData;
  307.  
  308. case 'selection':
  309. // Evaluate the single child of this expression
  310. let selData = applyExpr(expr.selection.children[0], sourceData);
  311.  
  312. let select = expr.selection.arguments.select;
  313. selData.data = selData.data.filter((item) => applyItem(select, item));
  314.  
  315. return selData;
  316.  
  317. case 'rename':
  318. // Evaluate the single child of this expression
  319. let renData = applyExpr(expr.rename.children[0], sourceData);
  320.  
  321. // Loop over all pairs of things to rename
  322. Object.entries(expr.rename.arguments.rename.columns).forEach(
  323. ([from, to]) => {
  324. // Ensure target name is a string
  325. if (typeof to !== 'string') {
  326. throw new Error('Invalid target for rename');
  327. }
  328.  
  329. // Add a new column with the new name
  330. const fromColumn = resolveColumn(from, renData.data[0]);
  331. renData.columns[renData.columns.indexOf(fromColumn)] = to;
  332.  
  333. // Copy all column data and delete the original column
  334. for (let j = 0; j < renData.data.length; j++) {
  335. renData.data[j][to] = renData.data[j][fromColumn];
  336. delete renData.data[j][fromColumn];
  337. }
  338. }
  339. );
  340. return renData;
  341.  
  342. case 'relation':
  343. // Make a copy of the data from a source table and return it
  344. return {...sourceData[expr.relation]};
  345.  
  346. case 'order_by':
  347. let ordData = applyExpr(expr.order_by.children[0], sourceData);
  348.  
  349. ordData.data.sort((a, b) => {
  350. let sortOrder = 0;
  351. expr.order_by.arguments.order_by.every((c) => {
  352. // Continue as long as column values are equal
  353. if (sortOrder !== 0) {
  354. return false;
  355. }
  356.  
  357. if (a[c.column_name] < b[c.column_name]) {
  358. sortOrder = c.ascending ? -1 : 1;
  359. } else if (a[c.column_name] > b[c.column_name]) {
  360. sortOrder = c.ascending ? 1 : -1;
  361. }
  362.  
  363. return true;
  364. });
  365.  
  366. return sortOrder;
  367. });
  368.  
  369. return ordData;
  370.  
  371. case 'group_by':
  372. let groupData = applyExpr(expr.group_by.children[0], sourceData);
  373. const groupByColumns = expr.group_by.arguments.groupBy;
  374. const aggregates = expr.group_by.arguments.aggregates;
  375. const selectColumns = expr.group_by.arguments.selectColumns || [];
  376.  
  377. // Group the data by the specified columns
  378. const groups: {[string]: Array<{[string]: any}>} = {};
  379. for (const row of groupData.data) {
  380. // Create a group key from the group by columns
  381. // If no grouping columns, use a single group for all data
  382. const groupKey =
  383. groupByColumns.length > 0
  384. ? groupByColumns
  385. .map((col) => {
  386. const resolvedCol = resolveColumn(col, row);
  387. return row[resolvedCol];
  388. })
  389. .join('|')
  390. : 'all'; // Single group key when no GROUP BY columns
  391.  
  392. if (!groups[groupKey]) {
  393. groups[groupKey] = [];
  394. }
  395. groups[groupKey].push(row);
  396. }
  397.  
  398. // Calculate aggregates for each group
  399. const resultData = [];
  400. // Only include explicitly selected columns, not all GROUP BY columns
  401. const resultColumns = [...selectColumns];
  402.  
  403. // Add aggregate columns to result columns
  404. for (const agg of aggregates) {
  405. resultColumns.push(
  406. `${agg.aggregate.function}(${agg.aggregate.column})`
  407. );
  408. }
  409.  
  410. for (const [groupKey, groupRows] of Object.entries(groups)) {
  411. const resultRow: {[string]: any} = {};
  412.  
  413. // Add explicitly selected column values (from SELECT clause)
  414. if (selectColumns.length > 0) {
  415. const groupKeyValues = groupKey.split('|');
  416. for (const selectCol of selectColumns) {
  417. // Find the index of this select column in the groupBy columns
  418. const groupByIndex = groupByColumns.findIndex(
  419. (groupCol) =>
  420. normalizeColumnName(groupCol) === normalizeColumnName(selectCol)
  421. );
  422. if (groupByIndex >= 0) {
  423. resultRow[selectCol] = groupKeyValues[groupByIndex];
  424. }
  425. }
  426. }
  427.  
  428. // Calculate aggregates
  429. for (const agg of aggregates) {
  430. const column = agg.aggregate.column;
  431. const func = agg.aggregate.function;
  432.  
  433. let result;
  434. if (func === 'COUNT') {
  435. // COUNT doesn't need to resolve column, just count rows
  436. result = groupRows.length;
  437. } else {
  438. // Other aggregates need to resolve the column and get values
  439. const values = groupRows.map((row: {[string]: any}) => {
  440. const resolvedCol = resolveColumn(column, row);
  441. return parseFloat(row[resolvedCol]) || 0;
  442. });
  443.  
  444. switch (func) {
  445. case 'MAX':
  446. result = Math.max(...values);
  447. break;
  448. case 'MIN':
  449. result = Math.min(...values);
  450. break;
  451. case 'AVG':
  452. result =
  453. values.reduce((sum, val) => sum + val, 0) / values.length;
  454. break;
  455. case 'SUM':
  456. result = values.reduce((sum, val) => sum + val, 0);
  457. break;
  458. case 'STDEV':
  459. if (values.length <= 1) {
  460. result = 0;
  461. } else {
  462. const mean =
  463. values.reduce((sum, val) => sum + val, 0) / values.length;
  464. const variance =
  465. values.reduce(
  466. (sum, val) => sum + Math.pow(val - mean, 2),
  467. 0
  468. ) /
  469. (values.length - 1);
  470. result = Math.sqrt(variance);
  471. }
  472. break;
  473. default:
  474. throw new Error('Unsupported aggregate function: ' + func);
  475. }
  476. }
  477.  
  478. resultRow[`${func}(${column})`] = result;
  479. }
  480.  
  481. resultData.push(resultRow);
  482. }
  483.  
  484. return {
  485. name: groupData.name + ' (grouped)',
  486. columns: resultColumns,
  487. data: resultData,
  488. };
  489.  
  490. case 'except':
  491. case 'intersect':
  492. case 'union':
  493. // Process each side of the operation
  494. const setLeft = applyExpr(expr[type].left, sourceData);
  495. const setRight = applyExpr(expr[type].right, sourceData);
  496.  
  497. // Check for valid columns
  498. if (setLeft.columns.length !== setRight.columns.length) {
  499. throw new Error(
  500. 'Each side of ' + type + ' must have the same number of columns'
  501. );
  502. }
  503.  
  504. const outColumns: Array<string> = setLeft.columns.slice();
  505. const setOutput: Output = {
  506. name: setLeft.name + ' ∪ ' + setRight.name,
  507. columns: outColumns,
  508. data: [],
  509. };
  510.  
  511. for (const leftRow of setLeft.data) {
  512. // Add the row if it doesn't exist or we don't want distinct
  513. if (
  514. !expr[type].distinct ||
  515. setOutput.data.find((row) => deepEqual(row, leftRow)) === undefined
  516. ) {
  517. setOutput.data.push(leftRow);
  518. }
  519. }
  520.  
  521. // Generate new rows for the right side with the salem
  522. // column names as those on the left
  523. const newRight = setRight.data.map((rightRow) => {
  524. const newRow: {[string]: any} = {};
  525. for (const rightKey of Object.keys(rightRow)) {
  526. newRow[setLeft.columns[setRight.columns.indexOf(rightKey)]] =
  527. rightRow[rightKey];
  528. }
  529.  
  530. return newRow;
  531. });
  532.  
  533. if (type === 'intersect') {
  534. // Keep only rows from th left which have a match on the right
  535. setOutput.data = setOutput.data.filter((leftRow) => {
  536. for (const rightRow of newRight) {
  537. if (deepEqual(leftRow, rightRow)) {
  538. return true;
  539. }
  540. }
  541. return false;
  542. });
  543. } else {
  544. for (const rightRow of newRight) {
  545. if (type === 'except') {
  546. // Remove any matching rows
  547. setOutput.data = setOutput.data.filter(
  548. (row) => !deepEqual(row, rightRow)
  549. );
  550. } else if (type === 'union') {
  551. // Add the row if it doesn't exist or we don't want distinct
  552. if (
  553. !expr[type].distinct ||
  554. setOutput.data.find((row) => deepEqual(row, rightRow)) ===
  555. undefined
  556. ) {
  557. setOutput.data.push(rightRow);
  558. }
  559. }
  560. }
  561. }
  562.  
  563. return setOutput;
  564.  
  565. case 'join':
  566. // Process each side of the join
  567. let joinLeft = applyExpr(expr.join.left, sourceData);
  568. let joinRight = applyExpr(expr.join.right, sourceData);
  569. const combinedJoinColumns = getCombinedColumns(joinLeft, joinRight);
  570. let joinType = expr.join.type;
  571.  
  572. let joinSymbol = ' ⋈ ';
  573. if (joinType === 'left') {
  574. joinSymbol = ' ⟕ ';
  575. } else if (joinType === 'right') {
  576. joinSymbol = ' ⟖ ';
  577. }
  578.  
  579. const joinOutput: Output = {
  580. name: joinLeft.name + joinSymbol + joinRight.name,
  581. columns: combinedJoinColumns,
  582. data: [],
  583. };
  584.  
  585. if (joinType === 'right') {
  586. let temp = joinLeft;
  587. joinLeft = joinRight;
  588. joinRight = temp;
  589. }
  590.  
  591. // Perform the join
  592. for (const leftRow of joinLeft.data) {
  593. let matchFound = false;
  594. for (const rightRow of joinRight.data) {
  595. const combinedJoinData = getCombinedData(
  596. joinLeft.name,
  597. leftRow,
  598. joinRight.name,
  599. rightRow,
  600. combinedJoinColumns
  601. );
  602. if (applyItem(expr.join.condition, combinedJoinData)) {
  603. joinOutput.data.push(combinedJoinData);
  604. matchFound = true;
  605. }
  606. }
  607. if (!matchFound && joinType !== 'inner') {
  608. const combinedJoinData = getCombinedData(
  609. joinLeft.name,
  610. leftRow,
  611. joinRight.name,
  612. joinRight.data[0],
  613. combinedJoinColumns,
  614. true
  615. );
  616. joinOutput.data.push(combinedJoinData);
  617. }
  618. }
  619.  
  620. return joinOutput;
  621.  
  622. case 'product':
  623. // Process each side of the product
  624. const left = applyExpr(expr.product.left, sourceData);
  625. const right = applyExpr(expr.product.right, sourceData);
  626. const combinedColumns = getCombinedColumns(left, right);
  627.  
  628. const output: Output = {
  629. name: left.name + ' × ' + right.name,
  630. columns: combinedColumns,
  631. data: [],
  632. };
  633.  
  634. // Perform the cross product
  635. for (const leftRow of left.data) {
  636. for (const rightRow of right.data) {
  637. output.data.push(
  638. getCombinedData(
  639. left.name,
  640. leftRow,
  641. right.name,
  642. rightRow,
  643. combinedColumns
  644. )
  645. );
  646. }
  647. }
  648.  
  649. return output;
  650.  
  651. default:
  652. // Fallback in case we get something invalid to show a nice error
  653. throw new Error('Invalid expression');
  654. }
  655. }
  656.  
  657. const reducer = (state: State = initialState, action: Action): State => {
  658. return produce(state, (draft: State) => {
  659. // eslint-disable-next-line default-case
  660. switch (action.type) {
  661. case RESET_EXPR:
  662. draft.expr = {};
  663. draft.current = undefined;
  664. draft.element = undefined;
  665. break;
  666. case CHANGE_EXPR:
  667. draft.expr = action.expr;
  668. draft.element = action.element;
  669. draft.current =
  670. JSON.stringify(action.expr) === JSON.stringify({})
  671. ? undefined
  672. : applyExpr(action.expr, draft.sourceData);
  673. break;
  674. }
  675. });
  676. };
  677.  
  678. export default reducer;