CartoDB/cartodb20

View on GitHub
lib/assets/javascripts/builder/data/query-rows-collection.js

Summary

Maintainability
C
1 day
Test Coverage
var Backbone = require('backbone');
var QueryRowModel = require('./query-row-model');
var syncAbort = require('./backbone/sync-abort');
var _ = require('underscore');
var STATUS = require('./query-base-status');

var MAX_GET_LENGTH = 1024;
var WRAP_SQL_TEMPLATE = 'select <%= selectedColumns %> from (<%= sql %>) __wrapped';
var MAX_REPEATED_ERRORS = 3;

module.exports = Backbone.Collection.extend({
  defaults: {
    empty: true
  },

  DEFAULT_FETCH_OPTIONS: {
    rows_per_page: 40,
    sort_order: 'asc',
    page: 0
  },

  // Due to a problem how Backbone checks if there is a duplicated model
  // or not, we can't create the model with a function + its necessary options
  model: QueryRowModel,

  sync: syncAbort,

  url: function () {
    return this._configModel.getSqlApiUrl();
  },

  initialize: function (models, opts) {
    opts = opts || {};

    this._repeatedErrors = 0;
    this._tableName = opts.tableName;
    this._querySchemaModel = opts.querySchemaModel;
    this._configModel = opts.configModel;

    this.statusModel = new Backbone.Model({
      status: STATUS.initial
    });

    this._initBinds();
  },

  _initBinds: function () {
    this.listenTo(this._querySchemaModel, 'change:query', this._onQuerySchemaQueryChange);

    if (this._querySchemaModel && this._querySchemaModel.columnsCollection) {
      this.listenTo(this._querySchemaModel.columnsCollection, 'reset', this._onColumnsCollectionReset);
    }

    this.listenTo(this.statusModel, 'change:status', this._onStatusChanged);
  },

  _onColumnsCollectionReset: function () {
    this._onQuerySchemaQueryChange();

    if (this.shouldFetch()) {
      this.fetch();
    }
  },

  getStatusValue: function () {
    return this.statusModel.get('status');
  },

  isInInitialStatus: function () {
    return this.getStatusValue() === STATUS.initial;
  },

  isFetched: function () {
    return this.getStatusValue() === STATUS.fetched;
  },

  isErrored: function () {
    return this.getStatusValue() === STATUS.errored;
  },

  isFetching: function () {
    return this.getStatusValue() === STATUS.fetching;
  },

  isDone: function () {
    return this.isFetched() || this.isErrored();
  },

  isInFinalStatus: function () {
    var finalStatuses = [STATUS.unavailable, STATUS.fetched, STATUS.errored];
    return _.contains(finalStatuses, this.getStatusValue());
  },

  isUnavailable: function () {
    return this.getStatusValue() === STATUS.unavailable;
  },

  canFetch: function () {
    var hasQuery = !!this._querySchemaModel.get('query');
    var isReady = this._querySchemaModel.get('ready');
    var isFetched = this._querySchemaModel.isFetched();
    var isErrored = this._querySchemaModel.isErrored();

    return hasQuery && isReady && isFetched && !isErrored;
  },

  shouldFetch: function () {
    return !this.isFetched() && !this.isFetching() && this.canFetch() && !this.isErrored();
  },

  resetFetch: function () {
    this.statusModel.set('status', STATUS.unfetched);
  },

  isEmpty: function () {
    throw new Error('QueryRowsCollection.isEmpty() is an async operation. Use `.isEmptyAsync` instead.');
  },

  isEmptyAsync: function () {
    if (this.isInFinalStatus()) {
      return Promise.resolve(this.size() === 0);
    } else {
      return new Promise(function (resolve) {
        this.listenToOnce(this, 'inFinalStatus', function () {
          resolve(this.size() === 0);
        });
      }.bind(this));
    }
  },

  _onStatusChanged: function () {
    if (this.isInFinalStatus()) {
      this.trigger('inFinalStatus');
    }
  },

  _onQuerySchemaQueryChange: function () {
    this.statusModel.set('status', 'unfetched');
    this.reset([], { silent: true });
  },

  _geometryColumnSQL: function (column) {
    /* eslint-disable */
    return [
      "CASE",
      "WHEN GeometryType(" + column + ") = 'POINT' THEN",
        "ST_AsGeoJSON(" + column + ",8)",
      "WHEN (" + column + " IS NULL) THEN",
        "NULL",
      "ELSE",
        "GeometryType(" + column + ")",
      "END " + column
    ].join(' ');
    /* eslint-enable */
  },

  // return wrapped SQL removing the_geom and the_geom_webmercator
  // to avoid fetching those columns.
  // So for a sql like
  // select * from table the returned value is
  // select column1, column2, column3... from table
  _getWrappedSQL: function (excludeColumns) {
    var self = this;
    var schema = this._querySchemaModel.columnsCollection.toJSON();

    var selectedColumns = _
      .chain(schema)
      .omit(function (item) {
        return _.contains(excludeColumns, item.name);
      })
      .map(function (item, index) {
        if (item.type === 'geometry') {
          return self._geometryColumnSQL(item.name);
        }
        return '"' + item.name + '"';
      })
      .value()
      .join(',');

    return _.template(WRAP_SQL_TEMPLATE)({
      selectedColumns: selectedColumns,
      sql: this._querySchemaModel.get('query')
    });
  },

  _httpMethod: function (query) {
    return query.length > MAX_GET_LENGTH ? 'POST' : 'GET';
  },

  _incrementRepeatedError: function () {
    this._repeatedErrors++;
  },

  _resetRepeatedError: function () {
    this._repeatedErrors = 0;
  },

  hasRepeatedErrors: function () {
    return this._repeatedErrors >= MAX_REPEATED_ERRORS;
  },

  fetch: function (opts) {
    if (this.isFetching()) return;

    opts = opts || {};

    var previousStatus = this.getStatusValue();
    this.statusModel.set('status', STATUS.fetching);

    var excludeColumns = (opts.data && opts.data.exclude) || [];
    var query = this._getWrappedSQL(excludeColumns);

    opts = opts || {};
    opts.data = _.extend(
      {},
      this.DEFAULT_FETCH_OPTIONS,
      opts.data && _.omit(opts.data, 'exclude') || {},
      {
        api_key: this._configModel.get('api_key'),
        q: query
      }
    );

    opts.method = this._httpMethod(query);
    var errorCallback = opts.error;
    opts.error = function (coll, resp) {
      if (previousStatus === STATUS.unavailable) {
        this._incrementRepeatedError();
      }

      if (resp && resp.error) {
        this._querySchemaModel.setError(resp.error);
      }

      this.trigger('fail', coll, resp);
      this.statusModel.set('status', this.hasRepeatedErrors() ? STATUS.errored : STATUS.unavailable);
      errorCallback && errorCallback(coll, resp);
    }.bind(this);

    var successCallback = opts.success;
    opts.success = function (coll, resp, options) {
      if (resp && resp.error) {
        return opts.error.apply(this, arguments);
      }

      this._resetRepeatedError();
      this.statusModel.set('status', STATUS.fetched);
      successCallback && successCallback(coll, resp);
    }.bind(this);

    // Needed to reset the whole collection when a fetch is done
    opts.reset = true;

    return Backbone.Collection.prototype.fetch.call(this, opts);
  },

  parse: function (r) {
    return this._parseWithID(r.rows);
  },

  reset: function (result, opts) {
    var items = [];

    if (result && result.rows) {
      // If reset comes from a fetch, we need to parse the rows
      items = result.rows;
    } else {
      // If it comes directly from a simple reset function
      items = result;
    }

    Backbone.Collection.prototype.reset.apply(this, [this._parseWithID(items)]);
  },

  _parseWithID: function (array) {
    return _.map(array, function (attrs) {
      attrs.__id = _.uniqueId();
      return attrs;
    });
  },

  addRow: function (opts) {
    opts = opts || {};
    this.create(
      {
        __id: _.uniqueId()
      },
      _.extend(
        opts,
        {
          wait: true,
          parse: true
        }
      )
    );
  },

  setTableName: function (name) {
    if (!name) return;

    if (this._tableName) {
      this._tableName = name;

      this.each(function (rowModel) {
        rowModel._tableName = name;
      });
    }
  }
});