NGO-DB/ndb-core

View on GitHub
src/app/core/export/data-transformation-service/data-transformation.service.ts

Summary

Maintainability
A
25 mins
Test Coverage
import { Injectable } from "@angular/core";
import {
  getReadableValue,
  transformToReadableFormat,
} from "../../common-components/entities-table/value-accessor/value-accessor";
import { ExportColumnConfig } from "./export-column-config";
import { QueryService } from "../query.service";
import { groupBy } from "../../../utils/utils";

/**
 * Prepare data for export or analysis
 */
@Injectable({
  providedIn: "root",
})
export class DataTransformationService {
  constructor(private queryService: QueryService) {}

  async queryAndTransformData(
    config: ExportColumnConfig[],
    from?: Date,
    to?: Date,
  ): Promise<ExportRow[]> {
    const combinedResults: ExportRow[] = [];
    const totalRow: ExportRow = {};
    for (const c of config) {
      await this.queryService.cacheRequiredData(c.query, from, to);
      const baseData = this.queryService.queryData(c.query, from, to);
      const result: ExportRow[] = [];
      if (c.subQueries) {
        result.push(
          ...(await this.transformData(
            baseData,
            c.subQueries,
            from,
            to,
            c.groupBy,
          )),
        );
      } else {
        totalRow[c.label] = baseData;
      }

      combinedResults.push(...result);
    }
    return Object.keys(totalRow).length > 0
      ? [totalRow, ...combinedResults]
      : combinedResults;
  }

  private concatQueries(config: ExportColumnConfig) {
    return (config.subQueries ?? []).reduce(
      (query, c) => query + this.concatQueries(c),
      config.query,
    );
  }

  /**
   * Creates a dataset with the provided values that can be used for a simple table or export.
   * @param data an array of elements. If not provided, the first query in `config` will be used to get the data.
   * @param config (Optional) config specifying how export should look
   * @param from (Optional) limits the data which is fetched from the database and is also available inside the query. If not provided, all data is fetched.
   * @param to (Optional) same as from.If not provided, today is used.
   * @param groupByProperty (optional) groups the data using the value at the given property and adds a column to the final table.
   * @returns array with the result of the queries and sub queries
   */
  async transformData(
    data: any[],
    config: ExportColumnConfig[],
    from?: Date,
    to?: Date,
    groupByProperty?: { label: string; property: string },
  ): Promise<ExportRow[]> {
    const fullQuery = config.map((c) => this.concatQueries(c)).join("");
    await this.queryService.cacheRequiredData(fullQuery, from, to);
    return this.generateRows(data, config, from, to, groupByProperty).map(
      transformToReadableFormat,
    );
  }

  private generateRows(
    data: any[],
    config: ExportColumnConfig[],
    from: Date,
    to: Date,
    groupByProperty?: { label: string; property: string },
  ) {
    const result: ExportRow[] = [];
    if (groupByProperty) {
      const groups = groupBy(data, groupByProperty.property);
      for (const [group, values] of groups) {
        const groupColumn: ExportColumnConfig = {
          label: groupByProperty.label,
          query: `:setString(${getReadableValue(group)})`,
        };
        const rows = this.generateColumnsForRow(
          values,
          [groupColumn].concat(...config),
          from,
          to,
        );
        result.push(...rows);
      }
    } else {
      for (const dataRow of data) {
        const rows = this.generateColumnsForRow(dataRow, config, from, to);
        result.push(...rows);
      }
    }
    return result;
  }

  /**
   * Generate one or more export row objects from the given data data and config.
   * @param data A data to be exported as one or more export row objects
   * @param config
   * @param from
   * @param to
   * @returns array of one or more export row objects (as simple {key: value})
   * @private
   */
  private generateColumnsForRow(
    data: any | any[],
    config: ExportColumnConfig[],
    from: Date,
    to: Date,
  ): ExportRow[] {
    let exportRows: ExportRow[] = [{}];
    for (const exportColumnConfig of config) {
      const partialExportObjects = this.buildValueRecursively(
        data,
        exportColumnConfig,
        from,
        to,
      );

      exportRows = this.mergePartialExportRows(
        exportRows,
        partialExportObjects,
      );
    }
    return exportRows;
  }

  /**
   * Generate one or more (partial) export row objects from a single property of the data
   * @param data one single data item
   * @param exportColumnConfig
   * @param from
   * @param to
   * @private
   */
  private buildValueRecursively(
    data: any | any[],
    exportColumnConfig: ExportColumnConfig,
    from: Date,
    to: Date,
  ): ExportRow[] {
    const label =
      exportColumnConfig.label ?? exportColumnConfig.query.replace(".", "");
    const value = this.getValueForQuery(exportColumnConfig, data, from, to);

    if (!exportColumnConfig.subQueries) {
      return [{ [label]: value }];
    } else if (value.length === 0) {
      return this.generateColumnsForRow(
        {},
        exportColumnConfig.subQueries,
        from,
        to,
      );
    } else {
      return this.generateRows(
        value,
        exportColumnConfig.subQueries,
        from,
        to,
        exportColumnConfig.groupBy,
      );
    }
  }

  private getValueForQuery(
    exportColumnConfig: ExportColumnConfig,
    data: any | any[],
    from: Date,
    to: Date,
  ): any {
    const value = this.queryService.queryData(
      exportColumnConfig.query,
      from,
      to,
      Array.isArray(data) ? data : [data],
    );

    if (!Array.isArray(value)) {
      return value;
    } else if (!exportColumnConfig.subQueries && value.length === 1) {
      return value[0];
    } else {
      return value.filter((val) => val !== undefined);
    }
  }

  /**
   * Combine two arrays of export row objects.
   * Every additional row is merged with every row of the first array (combining properties),
   * resulting in n*m export rows.
   *
   * @param exportRows
   * @param additionalExportRows
   * @private
   */
  private mergePartialExportRows(
    exportRows: ExportRow[],
    additionalExportRows: ExportRow[],
  ): ExportRow[] {
    const rowsOfRows: ExportRow[][] = additionalExportRows.map((addRow) =>
      exportRows.map((row) => Object.assign({}, row, addRow)),
    );
    // return flattened array
    return rowsOfRows.reduce((acc, rowOfRows) => acc.concat(rowOfRows), []);
  }
}

interface ExportRow {
  [key: string]: any;
}