src/modules/data.js
// @flow
import {deepEqual} from 'fast-equals';
import {produce} from 'immer';
import department from '../resources/Department.json';
import doctor from '../resources/Doctor.json';
import patient from '../resources/Patient.json';
export const CHANGE_EXPR = 'CHANGE_EXPR';
export const RESET_EXPR = 'RESET_EXPR';
type ChangeAction = {
type: 'CHANGE_EXPR',
expr: {[string]: any},
element: ?HTMLElement,
};
type ResetAction = {
type: 'RESET_EXPR',
};
type Action = ChangeAction | ResetAction;
type Output = {
name: string,
columns: Array<string>,
data: Array<{[string]: any}>,
};
/**
* @param expr - a relational algebra expression object
* @param element
* @return a new CHANGE_EXPR action
*/
export function changeExpr(
expr: {[string]: any},
element: ?HTMLElement
): ChangeAction {
return {type: CHANGE_EXPR, expr, element};
}
export function resetAction(): ResetAction {
return {type: RESET_EXPR};
}
export type Data = {
name: string,
columns: Array<string>,
data: Array<{[string]: any}>,
};
export type State = {
current?: Data,
sourceData: {[string]: Data},
element: ?HTMLElement,
expr: {[string]: any},
};
// Source data which can be used in SQL queries
export const initialState = {
sourceData: {
Department: department,
Doctor: doctor,
Patient: patient,
},
element: undefined,
expr: {},
};
function getCombinedColumns(left: {[string]: any}, right: {[string]: any}) {
// Combine columns adding relation name where needed
const combinedColumns: Array<string> = [];
for (const leftColumn of left.columns) {
if (right.columns.includes(leftColumn)) {
combinedColumns.push(left.name + '.' + leftColumn);
} else {
combinedColumns.push(leftColumn);
}
}
for (const rightColumn of right.columns) {
if (left.columns.includes(rightColumn)) {
combinedColumns.push(right.name + '.' + rightColumn);
} else {
combinedColumns.push(rightColumn);
}
}
return combinedColumns;
}
function getCombinedData(
leftName: string,
leftRow: {[string]: any},
rightName: string,
rightRow: {[string]: any},
combinedColumns: Array<string>,
outerJoin: ?boolean
): {[string]: any} {
// Combine data from the two objects including the relation name
const combinedData: {[string]: any} = {};
for (const leftKey in leftRow) {
combinedData[leftName + '.' + leftKey] = leftRow[leftKey];
}
for (const rightKey in rightRow) {
if (outerJoin) {
combinedData[rightName + '.' + rightKey] = null;
} else {
combinedData[rightName + '.' + rightKey] = rightRow[rightKey];
}
}
// Resolve the output data according to the combined data
// This may remove relation names where they are not needed
const outputData: {[string]: any} = {};
for (const column of combinedColumns) {
outputData[column] = combinedData[resolveColumn(column, combinedData)];
}
return outputData;
}
function resolveColumn(path: string, row: {[string]: any}): string {
// Avoid an error if we're projecting nothing
if (!row) {
return path;
}
const pathParts = path.split('.');
let [table, maybeColumn]: [?string, ?string] = [pathParts[0], pathParts[1]];
const column: string = maybeColumn || pathParts[0];
if (!maybeColumn) {
table = undefined;
}
if (table) {
if (row.hasOwnProperty(path)) {
// Use the dotted path
return path;
} else if (row.hasOwnProperty(column)) {
// Use the column name without the table qualifier
return column;
}
}
// Check for bare columns first
const columns = [];
for (const rowCol in row) {
if (rowCol === column) {
columns.push(rowCol);
}
}
// Check if we found the correct column
if (columns.length === 1) {
return columns[0];
} else if (columns.length > 1) {
throw new Error('Invalid column ' + path);
}
// Then check for the column with a prefix
columns.splice(0);
for (const rowCol in row) {
const rowColParts = rowCol.split('.').length;
if (!table && rowColParts < 3 && rowCol.endsWith('.' + column)) {
columns.push(rowCol);
}
}
// Check if we found the correct column
if (columns.length === 1) {
return columns[0];
} else if (columns.length > 1) {
throw new Error('Invalid column ' + path);
}
// Finally check with a table and column prefix
columns.splice(0);
for (const rowCol in row) {
if (table && rowCol.endsWith('.' + table + '.' + column)) {
columns.push(rowCol);
}
}
// Check if we found the correct column
if (columns.length === 1) {
return columns[0];
} else {
throw new Error('Invalid column ' + path);
}
}
// Try to resolve a column, otherwise treat it as a literal
function resolveValue(path: string, row: {[string]: any}): string {
let value = path;
try {
value = row[resolveColumn(path, row)];
} catch {}
return value;
}
/**
* Normalizes column names for comparison by extracting the base column name
* @param columnName - column name (could be qualified like "Doctor.departmentId" or unqualified like "departmentId")
* @return the base column name without table qualification
*/
function normalizeColumnName(columnName: string): string {
if (typeof columnName !== 'string') {
return columnName;
}
const parts = columnName.split('.');
return parts[parts.length - 1]; // Return the last part (column name)
}
/**
* @param expr - a relational algebra expression to evaluate
* @param item - an item to evaluate against
* @return result of evaluating the expression
*/
function applyItem(expr: {[string]: any}, item: {[string]: any}): any {
const type = Object.keys(expr)[0];
switch (type) {
case 'cmp':
// Get the values to compare and the comparison operator
const lhs = resolveValue(expr.cmp.lhs, item);
const op = expr.cmp.op;
let rhs = resolveValue(expr.cmp.rhs, item);
switch (op) {
case '$gte':
return lhs >= rhs;
case '$gt':
return lhs > rhs;
case '$lt':
return lhs < rhs;
case '$lte':
return lhs <= rhs;
case '$ne':
// eslint-disable-next-line eqeqeq
return lhs != rhs;
case '$eq':
// eslint-disable-next-line eqeqeq
return lhs == rhs;
default:
throw new Error('Invaid comparison operator');
}
case 'and':
let andResult = true;
// Loop over all expressions to be evaluated
for (var i = 0; andResult && i < expr.and.clauses.length; i++) {
andResult = andResult && applyItem(expr.and.clauses[i], item);
}
return andResult;
case 'or':
let orResult = false;
// Loop over all expressions to be evaluated
for (var i2 = 0; !orResult && i2 < expr.or.clauses.length; i2++) {
orResult = orResult || applyItem(expr.or.clauses[i2], item);
}
return orResult;
case 'not':
return !applyItem(expr.not.clause, item);
default:
throw new Error('Invalid expression');
}
}
/**
* @param expr - a relational algebra expression to evaluate
* @param sourceData - source data from relations
* @return result of evaluating the expression
*/
export function applyExpr(
expr: {[string]: any},
sourceData: {[string]: any}
): {[string]: any} {
const type = Object.keys(expr)[0];
switch (type) {
case 'projection':
// Evaluate the single child of this expression
let projData = applyExpr(expr.projection.children[0], sourceData);
// Get the columns which should be deleted
const columns = projData.columns.map((col) =>
resolveColumn(col, projData.data[0])
);
const keep = expr.projection.arguments.project.map((col) =>
resolveColumn(col, projData.data[0])
);
const deleted = columns.filter((column) => keep.indexOf(column) === -1);
// Make a copy of the list of columns to project
projData.columns = keep;
// Delete data values which should not be included
for (let i = 0; i < deleted.length; i++) {
for (let j = 0; j < projData.data.length; j++) {
delete projData.data[j][deleted[i]];
}
}
return projData;
case 'selection':
// Evaluate the single child of this expression
let selData = applyExpr(expr.selection.children[0], sourceData);
let select = expr.selection.arguments.select;
selData.data = selData.data.filter((item) => applyItem(select, item));
return selData;
case 'rename':
// Evaluate the single child of this expression
let renData = applyExpr(expr.rename.children[0], sourceData);
// Loop over all pairs of things to rename
Object.entries(expr.rename.arguments.rename.columns).forEach(
([from, to]) => {
// Ensure target name is a string
if (typeof to !== 'string') {
throw new Error('Invalid target for rename');
}
// Add a new column with the new name
const fromColumn = resolveColumn(from, renData.data[0]);
renData.columns[renData.columns.indexOf(fromColumn)] = to;
// Copy all column data and delete the original column
for (let j = 0; j < renData.data.length; j++) {
renData.data[j][to] = renData.data[j][fromColumn];
delete renData.data[j][fromColumn];
}
}
);
return renData;
case 'relation':
// Make a copy of the data from a source table and return it
return {...sourceData[expr.relation]};
case 'order_by':
let ordData = applyExpr(expr.order_by.children[0], sourceData);
ordData.data.sort((a, b) => {
let sortOrder = 0;
expr.order_by.arguments.order_by.every((c) => {
// Continue as long as column values are equal
if (sortOrder !== 0) {
return false;
}
if (a[c.column_name] < b[c.column_name]) {
sortOrder = c.ascending ? -1 : 1;
} else if (a[c.column_name] > b[c.column_name]) {
sortOrder = c.ascending ? 1 : -1;
}
return true;
});
return sortOrder;
});
return ordData;
case 'group_by':
let groupData = applyExpr(expr.group_by.children[0], sourceData);
const groupByColumns = expr.group_by.arguments.groupBy;
const aggregates = expr.group_by.arguments.aggregates;
const selectColumns = expr.group_by.arguments.selectColumns || [];
// Group the data by the specified columns
const groups: {[string]: Array<{[string]: any}>} = {};
for (const row of groupData.data) {
// Create a group key from the group by columns
// If no grouping columns, use a single group for all data
const groupKey =
groupByColumns.length > 0
? groupByColumns
.map((col) => {
const resolvedCol = resolveColumn(col, row);
return row[resolvedCol];
})
.join('|')
: 'all'; // Single group key when no GROUP BY columns
if (!groups[groupKey]) {
groups[groupKey] = [];
}
groups[groupKey].push(row);
}
// Calculate aggregates for each group
const resultData = [];
// Only include explicitly selected columns, not all GROUP BY columns
const resultColumns = [...selectColumns];
// Add aggregate columns to result columns
for (const agg of aggregates) {
resultColumns.push(
`${agg.aggregate.function}(${agg.aggregate.column})`
);
}
for (const [groupKey, groupRows] of Object.entries(groups)) {
const resultRow: {[string]: any} = {};
// Add explicitly selected column values (from SELECT clause)
if (selectColumns.length > 0) {
const groupKeyValues = groupKey.split('|');
for (const selectCol of selectColumns) {
// Find the index of this select column in the groupBy columns
const groupByIndex = groupByColumns.findIndex(
(groupCol) =>
normalizeColumnName(groupCol) === normalizeColumnName(selectCol)
);
if (groupByIndex >= 0) {
resultRow[selectCol] = groupKeyValues[groupByIndex];
}
}
}
// Calculate aggregates
for (const agg of aggregates) {
const column = agg.aggregate.column;
const func = agg.aggregate.function;
let result;
if (func === 'COUNT') {
// COUNT doesn't need to resolve column, just count rows
result = groupRows.length;
} else {
// Other aggregates need to resolve the column and get values
const values = groupRows.map((row: {[string]: any}) => {
const resolvedCol = resolveColumn(column, row);
return parseFloat(row[resolvedCol]) || 0;
});
switch (func) {
case 'MAX':
result = Math.max(...values);
break;
case 'MIN':
result = Math.min(...values);
break;
case 'AVG':
result =
values.reduce((sum, val) => sum + val, 0) / values.length;
break;
case 'SUM':
result = values.reduce((sum, val) => sum + val, 0);
break;
case 'STDEV':
if (values.length <= 1) {
result = 0;
} else {
const mean =
values.reduce((sum, val) => sum + val, 0) / values.length;
const variance =
values.reduce(
(sum, val) => sum + Math.pow(val - mean, 2),
0
) /
(values.length - 1);
result = Math.sqrt(variance);
}
break;
default:
throw new Error('Unsupported aggregate function: ' + func);
}
}
resultRow[`${func}(${column})`] = result;
}
resultData.push(resultRow);
}
return {
name: groupData.name + ' (grouped)',
columns: resultColumns,
data: resultData,
};
case 'except':
case 'intersect':
case 'union':
// Process each side of the operation
const setLeft = applyExpr(expr[type].left, sourceData);
const setRight = applyExpr(expr[type].right, sourceData);
// Check for valid columns
if (setLeft.columns.length !== setRight.columns.length) {
throw new Error(
'Each side of ' + type + ' must have the same number of columns'
);
}
const outColumns: Array<string> = setLeft.columns.slice();
const setOutput: Output = {
name: setLeft.name + ' ∪ ' + setRight.name,
columns: outColumns,
data: [],
};
for (const leftRow of setLeft.data) {
// Add the row if it doesn't exist or we don't want distinct
if (
!expr[type].distinct ||
setOutput.data.find((row) => deepEqual(row, leftRow)) === undefined
) {
setOutput.data.push(leftRow);
}
}
// Generate new rows for the right side with the salem
// column names as those on the left
const newRight = setRight.data.map((rightRow) => {
const newRow: {[string]: any} = {};
for (const rightKey of Object.keys(rightRow)) {
newRow[setLeft.columns[setRight.columns.indexOf(rightKey)]] =
rightRow[rightKey];
}
return newRow;
});
if (type === 'intersect') {
// Keep only rows from th left which have a match on the right
setOutput.data = setOutput.data.filter((leftRow) => {
for (const rightRow of newRight) {
if (deepEqual(leftRow, rightRow)) {
return true;
}
}
return false;
});
} else {
for (const rightRow of newRight) {
if (type === 'except') {
// Remove any matching rows
setOutput.data = setOutput.data.filter(
(row) => !deepEqual(row, rightRow)
);
} else if (type === 'union') {
// Add the row if it doesn't exist or we don't want distinct
if (
!expr[type].distinct ||
setOutput.data.find((row) => deepEqual(row, rightRow)) ===
undefined
) {
setOutput.data.push(rightRow);
}
}
}
}
return setOutput;
case 'join':
// Process each side of the join
let joinLeft = applyExpr(expr.join.left, sourceData);
let joinRight = applyExpr(expr.join.right, sourceData);
const combinedJoinColumns = getCombinedColumns(joinLeft, joinRight);
let joinType = expr.join.type;
let joinSymbol = ' ⋈ ';
if (joinType === 'left') {
joinSymbol = ' ⟕ ';
} else if (joinType === 'right') {
joinSymbol = ' ⟖ ';
}
const joinOutput: Output = {
name: joinLeft.name + joinSymbol + joinRight.name,
columns: combinedJoinColumns,
data: [],
};
if (joinType === 'right') {
let temp = joinLeft;
joinLeft = joinRight;
joinRight = temp;
}
// Perform the join
for (const leftRow of joinLeft.data) {
let matchFound = false;
for (const rightRow of joinRight.data) {
const combinedJoinData = getCombinedData(
joinLeft.name,
leftRow,
joinRight.name,
rightRow,
combinedJoinColumns
);
if (applyItem(expr.join.condition, combinedJoinData)) {
joinOutput.data.push(combinedJoinData);
matchFound = true;
}
}
if (!matchFound && joinType !== 'inner') {
const combinedJoinData = getCombinedData(
joinLeft.name,
leftRow,
joinRight.name,
joinRight.data[0],
combinedJoinColumns,
true
);
joinOutput.data.push(combinedJoinData);
}
}
return joinOutput;
case 'product':
// Process each side of the product
const left = applyExpr(expr.product.left, sourceData);
const right = applyExpr(expr.product.right, sourceData);
const combinedColumns = getCombinedColumns(left, right);
const output: Output = {
name: left.name + ' × ' + right.name,
columns: combinedColumns,
data: [],
};
// Perform the cross product
for (const leftRow of left.data) {
for (const rightRow of right.data) {
output.data.push(
getCombinedData(
left.name,
leftRow,
right.name,
rightRow,
combinedColumns
)
);
}
}
return output;
default:
// Fallback in case we get something invalid to show a nice error
throw new Error('Invalid expression');
}
}
const reducer = (state: State = initialState, action: Action): State => {
return produce(state, (draft: State) => {
// eslint-disable-next-line default-case
switch (action.type) {
case RESET_EXPR:
draft.expr = {};
draft.current = undefined;
draft.element = undefined;
break;
case CHANGE_EXPR:
draft.expr = action.expr;
draft.element = action.element;
draft.current =
JSON.stringify(action.expr) === JSON.stringify({})
? undefined
: applyExpr(action.expr, draft.sourceData);
break;
}
});
};
export default reducer;