pat310/quick-pivot

View on GitHub
src/logic.js

Summary

Maintainability
F
4 days
Test Coverage
/**
 * @file Contains logic to actually pivot data
*/

/**
 * @description Format data into an array of objects where the headers are the keys
 * @param {!Array|Object} data Array of arrays or an object
 * @param {Function} custom sort function or default sort if null. Function equaling () => {} will skip the sort stage
 * @returns {Array<Object>} Formatted object
*/
export function fixDataFormat(data, rows = [], sortFunc) {
  if (!Array.isArray(data) || !data.length) return [];

  let formattedData = [];

  if (typeof data[0] === 'object' && !Array.isArray(data[0])) {
    formattedData = data;
  } else {
    formattedData = data.reduce((dataCol, row, i, arr) => {
      if (i !== 0) {
        if (Array.isArray(row)) {
          dataCol.push(row.reduce((acc, curr, index) =>{
            acc[arr[0][index]] = curr;
            return acc;
          }, {}));
        } else {
          dataCol.push({[arr[0]]: row});
        }
      }
      return dataCol;
    }, []);
  }

  // sorting data initially to prevent changes in data ordering
  if (rows.length > 0) {

    // NoOp Sort function, return unsorted
    if (sortFunc === (() => {})) {
      return formattedData;
    }

    // Use default sort if none provided
    if (!sortFunc) {
      sortFunc = (row) => (a, b) => {
        if ((typeof a[row] === 'string') && (typeof b[row] === 'string')) {
          return a[row].localeCompare(b[row]);
        }
        if (a[row] > b[row]) {
          return 1;
        };
        if (a[row] < b[row]) {
          return -1;
        };
        return 0;
      };
    }

    // Sort Rows
    return rows.reduceRight((acc, row) => {
      return acc.sort(sortFunc(row));
    }, formattedData);
  }

  return formattedData.sort((a, b) => {
    return JSON.stringify(a).localeCompare(JSON.stringify(b));
  });
}

/**
 * @description Groups the data into an object by the provided category
 * @param {!Array<Object>} data Array of objects
 * @param {string} groupBy Category to group by
 * @returns {Object} Each key in the object is one the category groups
*/
export function groupByCategory(data, groupBy) {
  return data.reduce((acc, curr) =>{
    const category = curr[groupBy];

    if (!acc[category]) acc[category] = [];
    acc[category].push(curr);
    return acc;
  }, {});
}

/**
 * @description Performs groupByCategory recursively (nesting objects within objects)
 * @param {!Array<Object>} data Array of objects
 * @param {Array} groups Items to categorize by
 * @param {Object} acc The accumulated results
 * @returns {Object} Deeply nested object
 * where each key is one the category groups
*/
export function groupByCategories(data, groups = [], acc = {}) {
  /**
   * base case - if data is empty
   * or if there are no more groups to group by
   * return result
  */
  if (data.length === 0 || groups.length === 0) return data;

  const groupedData = groupByCategory(data, groups[0]);
  const groupedDataKeys = Object.keys(groupedData);
  const children = Object.values(groupedData);

  for (let i = 0; i < children.length; i++) {
    acc[groupedDataKeys[i]] = groupByCategories(
        children[i], groups.slice(1), acc[groupedDataKeys[i]]);
  }

  return acc;
}

/**
 * @description Builds the column headers and a map to each header
 * @param {!Array<Object>} data Array of objects
 * @param {Array} cols Columns to pivot on
 * @param {string} firstColumn A string to place in the first column header
 * @param {Function} columnSortFunc A sort function to sort the column headers
 * @returns {Object} columnHeaders (array of arrays) and mapToHeader (object)
*/
export function createColumnHeaders(
  data,
  cols = [],
  firstColumn = '',
  columnSortFunc = () => () => {}
) {
  if (cols.length === 0) {
    return {
      columnHeaders: [firstColumn],
      mapToHeader: null,
    };
  }

  const mapToHeader = groupByCategories(data, cols);
  const columnHeaders = [];
  let mapPos = 1;

  (function columnHeaderRecursion(data, pos = 0, headerMap) {
    /** base case - at actual data as opposed to another grouping */
    if (typeof data !== 'object' || Array.isArray(data)) return 1;

    const currKeys = Object.keys(data).sort(columnSortFunc(data, cols, pos));
    let sumLength = 0;

    for (let i = 0; i < currKeys.length; i++) {
      const currLength = columnHeaderRecursion(
          data[currKeys[i]], pos + 1, headerMap[currKeys[i]]);

      if (Array.isArray(data[currKeys[i]])) {
        headerMap[currKeys[i]] = mapPos;
        mapPos += 1;
      }

      columnHeaders[pos] = typeof columnHeaders[pos] === 'undefined' ?
        [firstColumn].concat(Array(currLength).fill(currKeys[i])) :
        columnHeaders[pos].concat(Array(currLength).fill(currKeys[i]));

      sumLength += currLength;
    }

    return sumLength;

  }(mapToHeader, 0, mapToHeader));

  return {
    columnHeaders,
    mapToHeader,
  };
}

/**
 * @description Reduces an array of values to a result
 * @param {!Array} arr The array to reduce
 * @param {?requestCallback|string} accCat Callback, category to reduce, or null
 * @param {string} accType Reduce type (count, average, min, max, etc) or initial value
 * @param {*} accValue Initial value - string, number, array, or object
 * @returns {*} Reduced value
 * @todo Move accumulator to its own file since it will continue to grow
*/
export function accumulator(arr, accCat, accType, accValue) {
  if (typeof accCat === 'undefined') accType = 'count';
  else if (typeof accCat === 'function') accValue = accType || 0;

  return arr.reduce((acc, curr, index, array) => {
    if (typeof accCat === 'function') return accCat(acc, curr, index, array);

    switch (accType) {
      case ('average'): {
        acc += Number(curr[accCat]) / arr.length;

        return acc;
      }

      case ('count'): {
        acc += 1;

        return acc;
      }

      case ('min'): {
        if (index === 0) acc = Number(curr[accCat]);
        else if (curr[accCat] < acc) acc = Number(curr[accCat]);

        return acc;
      }

      case ('max'): {
        if (index === 0) acc = Number(curr[accCat]);
        else if (curr[accCat] > acc) acc = Number(curr[accCat]);

        return acc;
      }

      case ('sum'): {
        acc += Number(curr[accCat]);

        return acc;
      }

      default: {
        acc += 1;

        return acc;
      }
    }
  }, accValue || 0);
}

/**
 * @description Check that categories to pivot on actually exist
 * @param {!Object} actualCats Categories that actually exist in the data
 * @param {!Array<string>} selectedCats Categories to pivot selected by user
 * @throws Will throw an error if the category does not exist
*/
export function checkPivotCategories(actualCats, selectedCats) {
  const errMessage = selectedCats.filter((selectedCat) => {
    return !(selectedCat in actualCats);
  });

  if (errMessage.length > 0) {
    throw new Error('Check that these selected pivot categories exist: ' +
      errMessage.join(', '));
  }
}

export function tableCreator(data, rows = [], cols = [], accCatOrCB,
  accTypeOrInitVal, rowHeader, columnSortFunc) {

  /** if data is empty, return empty array */
  if (data.length === 0) {
    return {
      rawData: [],
      table: [],
    };
  };

  /** if rows/cols are not arrays, return throw an error */
  if (!Array.isArray(rows) || !Array.isArray(cols)) {
    throw new Error('rowsToPivot and colsToPivot must be of type array');
  }

  checkPivotCategories(data[0], rows);
  checkPivotCategories(data[0], cols);

  if (typeof rowHeader === 'undefined') {
    rowHeader = typeof accCatOrCB !== 'function' ?
      `${accTypeOrInitVal} ${accCatOrCB}` :
      'Custom Agg';
  }

  const columnData = createColumnHeaders(data, cols, rowHeader, columnSortFunc);
  const columnHeaders = Array.isArray(columnData.columnHeaders[0]) ?
    columnData.columnHeaders :
    [columnData.columnHeaders.concat(rowHeader)];
  const mapToHeader = columnData.mapToHeader;
  const headerLength = columnHeaders[0].length;
  const formattedColumnHeaders = columnHeaders.map((value, depth) => {
    return {
      value,
      depth,
      type: 'colHeader',
      row: depth,
    };
  });

  let dataRows = [];
  let rawData = [];
  let prevKey = null;

  function rowRecurse(rowGroups, depth, rowHeaders = []) {
    for (const key in rowGroups) {
      if (Array.isArray(rowGroups[key])) {
        const recursedData = groupByCategories(rowGroups[key], cols);

        prevKey = null;

        (function recurseThroughMap(dataPos, map) {
          if (Array.isArray(dataPos)) {
            if (key === prevKey) {
              const datum = dataRows[dataRows.length - 1].value;

              datum[map] = accumulator(dataPos, accCatOrCB, accTypeOrInitVal);
              dataRows[dataRows.length - 1].value = datum;

              const rawDataDatum = rawData[rawData.length - 1].value;

              rawDataDatum[map] = dataPos;
              rawData[rawData.length - 1].value = rawDataDatum;
            } else {
              prevKey = key;
              const datum = [key].concat(
                Array(map - 1).fill(''),
                accumulator(dataPos, accCatOrCB, accTypeOrInitVal),
                Array(headerLength - (map + 1)).fill('')
              );
              const rawDataDatum = [key].concat(
                Array(map - 1).fill(''),
                [dataPos],
                Array(headerLength - (map + 1)).fill('')
              );

              rawData.push({
                value: rawDataDatum,
                type: 'data',
                depth,
              });
              dataRows.push({
                value: datum,
                type: 'data',
                depth,
                row: dataRows.length + formattedColumnHeaders.length,
              });
            }
          } else {
            for (const innerKey in dataPos) {
              recurseThroughMap(dataPos[innerKey], map[innerKey]);
            }
          }
        })(recursedData, mapToHeader || 1);

      } else {
        const rowHeaderValue = rowHeaders.shift();
        const value = rowHeaderValue ?
            rowHeaderValue.value :
            [key].concat(Array(headerLength - 1).fill(''));

        dataRows.push({
          value,
          depth,
          type: 'rowHeader',
          row: dataRows.length + formattedColumnHeaders.length,
        });
        rawData.push({
          value,
          depth,
          type: 'rowHeader',
        });

        rowRecurse(rowGroups[key], depth + 1, rowHeaders);
      }
    }
  }

  let dataGroups = [];

  if (rows.length > 0) {
    for (let i = 0; i < rows.length; i++) {
      // possible memoization opportunity
      rowRecurse(groupByCategories(data, rows.slice(0, i + 1)), 0, dataGroups);
      dataGroups = Object.assign([], dataRows);
      if (i + 1 < rows.length) {
        dataRows = [];
        rawData = [];
        prevKey = null;
      }
    }
  } else if (cols.length > 0) {
    for (let i = 0; i < cols.length; i++) {
      rowRecurse(groupByCategories(data, cols.slice(0, i + 1)), 0, dataGroups);
      dataGroups = Object.assign([], dataRows);
      if (i + 1 < cols.length) {
        dataRows = [];
        rawData = [];
        prevKey = null;
      }
    }
  } else {
    dataRows.push({
      value: [rowHeader, accumulator(data, accCatOrCB, accTypeOrInitVal)],
      type: 'data',
      row: 1,
      depth: 0,
    });
    rawData = data.map((value) => {
      return {
        value,
        type: 0,
        row: 1,
        depth: 0,
      };
    });
  }

  function tableRowAggregator(rows) {
    const filteredRows = rows.reduce((acc, { type, value }) => {
      if (acc.length === 0) {
        acc = Array(value.length).fill([]);
      }

      if (type === 'data') {
        value.forEach((valueElem, i) => {
          if (Array.isArray(valueElem)) {
            acc[i] = acc[i].concat(valueElem);
          }
        });
      }

      return acc;
    }, []);

    return filteredRows.map((accumulatedRawData) => {
      if (accumulatedRawData.length > 0) {
        return accumulator(accumulatedRawData, accCatOrCB, accTypeOrInitVal);
      }

      return 'Totals';
    });
  }

  function tableColumnAggregator(rows) {
    const filteredRows = rows.reduce((acc, { type, value }) => {
      if (type === 'data') {
        const i = acc.length;

        acc[i] = [];
        value.forEach((valueElem) => {
          if (Array.isArray(valueElem)) {
            acc[i] = acc[i].concat(valueElem);
          }
        });
      } else if (type === 0) {
        // TODO: Investigate why type is coming up as 0
        // happens when aggregating [] for rows and [] for cols
        acc.push(value);
      }

      return acc;
    }, []);

    return filteredRows.map((accumulatedRawData) => {
      if (accumulatedRawData.length > 0) {
        return accumulator(accumulatedRawData, accCatOrCB, accTypeOrInitVal);
      }

      return '';
    });
  }

  const columnAggregations = tableColumnAggregator(rawData);
  const accumulatedRows = {
    value: tableRowAggregator(rawData),
    type: 'aggregated',
  };

  const colHeadersValueCopies = formattedColumnHeaders.map(({ value }) => {
    return Object.assign([], value);
  });
  const colHeadersCopy = formattedColumnHeaders.map((colHeaderObj, i) => {
    const copy = Object.assign({}, colHeaderObj);

    copy.value = colHeadersValueCopies[i];
    return copy;
  });

  let counter = 0;
  const table = colHeadersCopy.concat(dataRows, accumulatedRows)
    .map((tableRow, i) => {
      if (tableRow.type === 'data') {
        tableRow.value = tableRow.value.concat(columnAggregations[counter]);
        counter += 1;
      } else {
        tableRow.value = tableRow.value.concat(i === 0 ? 'Totals' : '');
      }

      return tableRow;
    });

  return {
    table,
    rawData: formattedColumnHeaders.concat(rawData),
  };
}