Gapminder/vizabi

View on GitHub
src/base/reader.js

Summary

Maintainability
B
6 hrs
Test Coverage
import * as utils from "base/utils";
import Class from "base/class";

/**
 * Initializes the reader.
 * @param {Object} reader_info Information about the reader
 */
const Reader = Class.extend({

  QUERY_FROM_CONCEPTS: "concepts",
  QUERY_FROM_DATAPOINTS: "datapoints",
  QUERY_FROM_ENTITIES: "entities",
  SCHEMA_QUERY_FROM_CONCEPTS: "concepts.schema",
  SCHEMA_QUERY_FROM_DATAPOINTS: "datapoints.schema",
  SCHEMA_QUERY_FROM_ENTITIES: "entities.schema",

  CONDITION_CALLBACKS: {
    $gt: (configValue, rowValue) => rowValue > configValue,
    $gte: (configValue, rowValue) => rowValue >= configValue,
    $lt: (configValue, rowValue) => rowValue < configValue,
    $lte: (configValue, rowValue) => rowValue <= configValue,
    $in: (configValue, rowValue) => configValue.includes(rowValue)
  },

  LOGICAL_TEST: {
    $and: "every",
    $or: "some"
  },

  ERRORS: {
    GENERIC_ERROR: "reader/error/generic"
  },

  _name: "reader",

  load() {
    return Promise.resolve({
      rows: [],
      columns: []
    });
  },

  read(query, parsers = {}) {
    const originalQuery = query;
    query = this._normalizeQuery(query, parsers);

    const {
      select,
      from
    } = query;

    return this.load(parsers)
      .then(result => {
        const { rows, columns } = result;
        this._checkTimeParser(columns, parsers);
        this.ensureDataIsCorrect(result, parsers);

        switch (true) {
          case from === this.SCHEMA_QUERY_FROM_CONCEPTS:
            return [{ key: ["concept"], value: "concept_type" }];

          case from === this.SCHEMA_QUERY_FROM_ENTITIES:
            return columns.slice(0, this.keySize).map(key => ({ key: [key], value: key }));

          case from === this.SCHEMA_QUERY_FROM_DATAPOINTS: {
            const key = columns.slice(0, this.keySize + 1);
            return columns.slice(this.keySize + 1).map(value => ({ key, value }));
          }
          case from === this.QUERY_FROM_CONCEPTS:
            return this._getConcepts(columns, this._mapRows(rows, query, parsers));

          case this._isDataQuery(from) && select.key.length > 0:
            return this._getData(rows, query, parsers);

          default:
            return [];
        }
      })
      .catch(error => {
        this._onLoadError(error);
        if (!utils.find(this.ERRORS, f => f === error.name)) error = this.error(this.ERRORS.GENERIC_ERROR, error);
        if (!error.endpoint) error.endpoint = this._basepath;
        if (!error.ddfql) error.ddfql = originalQuery;
        if (!error.details) error.details = this._name;
        throw error;
      });
  },

  ensureDataIsCorrect() {

  },

  _checkTimeParser(columns, parsers) {
    const timeKey = columns[this.keySize];
    if (!parsers[timeKey]) parsers[timeKey] = t => t;
  },

  _normalizeQuery(_query, parsers) {
    const query = Object.assign({}, _query);
    const { where = {}, join = {} } = query;

    if (where.$and) {
      where.$and = where.$and.reduce((whereResult, condition) => {
        Object.keys(condition).forEach(rowKey => {
          const conditionValue = condition[rowKey];

          if (typeof conditionValue === "string" && conditionValue.startsWith("$")) {
            const joinWhere = join[conditionValue].where;

            Object.keys(joinWhere)
              .forEach(joinRowKey => {
                const value = joinWhere[joinRowKey];
                const parser = parsers[joinRowKey];

                whereResult.push({ [joinRowKey]: parser ?
                  typeof value === "object" ?
                    Object.keys(value).reduce((callbackConditions, callbackKey) => {
                      callbackConditions[callbackKey] = parser(value[callbackKey]);
                      return callbackConditions;
                    }, {}) :
                    parser(value)
                  : value });
              });
          } else {
            const parser = parsers[rowKey];
            whereResult.push({ [rowKey]: parser ? parser(conditionValue) : conditionValue });
          }
        });

        return whereResult;
      }, []);
    }

    return query;
  },

  _getConcepts(columns, rows) {
    return columns.map((concept, index) => {
      const result = { concept };

      if (index < this.keySize) {
        result.concept_type = "entity_domain";
      } else if (index === this.keySize) {
        // the column after is expected to have time
        result.concept_type = "time";
      } else {
        result.concept_type = "measure";

        for (let i = rows.length - 1; i > -1; --i) {
          if (utils.isString(rows[i][concept]) && rows[i][concept] !== "") {
            result.concept_type = "string";
            [result.domain] = columns;
            break;
          }
        }
      }

      return result;
    });
  },

  _getData(rows, query, parsers) {
    const { order_by = [] } = query;
    const [orderBy] = order_by;

    return this._mapRows(rows, query, parsers)
      .reduce(this._applyQuery(query), [])
      .sort((prev, next) => prev[orderBy] - next[orderBy]);
  },

  _isDataQuery(from) {
    return [
      this.QUERY_FROM_DATAPOINTS,
      this.QUERY_FROM_ENTITIES
    ].includes(from);
  },

  _mapRows(rows, query, parsers) {
    return rows.map(this._getRowMapper(query, parsers));
  },

  _getRowMapper(query, parsers) {
    return row => {
      let correct = true;

      const result = Object.keys(row).reduce((result, key) => {
        if (correct) {
          const defaultValue = row[key];
          const defaultValueString = String(defaultValue).trim();

          const parser = parsers[key];
          const resultValue = !utils.isString(defaultValue) ?
            defaultValue :
            parser ?
              parser(defaultValueString) :
              this._parse(defaultValueString);

          if (!resultValue && resultValue !== 0) {
            if (query.select.key.includes(key)) {
              correct = false;
            }
          } else {
            result[key] = resultValue;
          }
        }

        return result;
      }, {});

      return correct && result;
    };
  },

  _parse(value) {
    return value;
  },

  _applyQuery(query) {
    const {
      select,
      from
    } = query;

    const [uniqueKey] = select.key;
    const uniqueValues = [];

    return (result, row) => {

      if (row) {
        const unique = row[uniqueKey];
        const isUnique = from !== this.QUERY_FROM_ENTITIES || !uniqueValues.includes(unique);
        const isSuitable = this._isSuitableRow(query, row);

        if (isSuitable && isUnique) {
          if (from === this.QUERY_FROM_ENTITIES) {
            uniqueValues.push(unique);
          }

          const rowFilteredByKeys = Object.keys(row)
            .reduce((resultRow, rowKey) => {
              if (select.key.includes(rowKey) || select.value.includes(rowKey)) {
                resultRow[rowKey] = row[rowKey];
              }

              return resultRow;
            }, {});

          result.push(rowFilteredByKeys);
        }
      }

      return result;
    };
  },

  _isSuitableRow(query, row) {
    const { where } = query;
    return !where || Object.keys(where).every(conditionKey => this._testCondition(where[conditionKey], conditionKey, row));
  },

  _testCondition(condition, conditionKey, row) {
    const logicalTest = this.LOGICAL_TEST[conditionKey];
    const rowValue = logicalTest ? conditionKey : row[conditionKey];

    // if the column is missing, then don't apply filter
    return typeof rowValue === "undefined" ||
      (condition instanceof Date ? !(+condition - +rowValue) :
        typeof condition !== "object" ?
          (rowValue === condition
            // resolve booleans via strings
            || condition === true && utils.isString(rowValue) && rowValue.toLowerCase().trim() === "true"
            || condition === false && utils.isString(rowValue) && rowValue.toLowerCase().trim() === "false"
          ) :
          logicalTest ? condition[logicalTest](newCondition => Object.keys(newCondition).every(newConditionKey => this._testCondition(newCondition[newConditionKey], newConditionKey, row))) :
            Object.keys(condition).every(callbackKey =>
              this.CONDITION_CALLBACKS[callbackKey](condition[callbackKey], rowValue, row)
            ));

  },

  _onLoadError(error) {

  },

  error(code, message, payload, query, file) {
    const error = new Error;
    error.name = code;
    error.message = message;
    error.details = payload;
    error.ddfql = query;
    error.endpoint = file;
    return error;
  }

});

export default Reader;