Source: soda/Query.js

/**
 * @module nyc/soda/Query
 */

import Papa from 'papaparse'
import $ from 'jquery'

/**
 * @desc A class for querying NYC OpenData using the SocrataJson SODA API
 * @public
 * @class
 * @see https://opendata.cityofnewyork.us/
 * @see https://dev.socrata.com/consumers/getting-started.html
 */
class Query {
  /**
   * @desc Create an instance of Query
   * @public
   * @constructor
   * @param {module:nyc/soda/Query~Query.Options} options Constructor options
   */
  constructor(options) {
    /**
     * @private
     * @member {string}
     */
    this.url = options.url
    /**
     * @private
     * @member {string}
     */
    this.appToken = options.appToken
    /**
     * @private
     * @member {module:nyc/soda/Query~Query.Query}
     */
    this.query = options.query
    /**
     * @private
     * @member {Object<string, Array<module:nyc/soda/Query~Query.Filter>>}
     */
    this.filters = {}
    this.setFilters(options.filters)

  }
  /**
   * @desc Set app token for SODA api request
   * @public
   * @method
   * @param {string} appToken Authentication token for SODA api
   */
  setAppToken(appToken) {
    this.appToken = appToken || this.appToken
  }

  /**
   * @desc Overwrite a filter for the query
   * @public
   * @method
   * @param {string} field The field to which the filter will be applied
   * @param {module:nyc/soda/Query~Query.Filter} filter The filter to apply to the field
   */
  setFilter(field, filter) {
    this.filters[field] = [filter]
  }

  /**
   * @desc Overwrite all filters for the query
   * @public
   * @method
   * @param {Object<string, Array<module:nyc/soda/Query~Query.Filter>>} filters Filter arrays mapped to field names
   */
  setFilters(filters) {
    this.filters = filters || {}
  }

  /**
   * @desc Add a filter to the query
   * @public
   * @method
   * @param {string} field The field to which the filter will be applied
   * @param {module:nyc/soda/Query~Query.Filter} filter The filter to apply to the field
   */
  addFilter(field, filter) {
    this.filters[field] = this.filters[field] || []
    this.filters[field].push(filter)
  }

  /**
   * @desc Append filter for each field to where clause
   * @private
   * @method
   * @param {string} where The current where clause
   * @param {string} field The field where filters will be applied
   * @param {module:nyc/soda/Query~Query.Filter} filter The filter to be applied to field
   * @return {string} Query
   */
  appendFilter(where, field, filter) {
    let value = filter.value || ''
    filter.op = filter.op.toUpperCase()
    if (typeof value === 'string') {
      if (value.toUpperCase() === 'NULL' || value === '') {
        value = 'NULL'
      } else {
        value = `'${value}'`
      }
    } else if (typeof value === 'number') {
      value = `${value}`
    } else {
      if (typeof value[0] === 'number') {
        value = `(${value.join(', ')})`
      } else {
        value = `('${value.join('\', \'')}')`
      }
    }
    return Query.and(where, `${field} ${filter.op} ${value}`)
  }

  /**
   * @desc Clear all filters on specified field
   * @public
   * @method
   * @param {string} field The field to which the filter will be cleared
   */
  clearFilters(field) {
    delete this.filters[field]
  }

  /**
   * @desc Clear all filters on all fields
   * @public
   * @method
   */
  clearAllFilters() {
    this.filters = {}
  }

  /**
   * @desc Set the base query to which filters can be added
   * @public
   * @method
   * @param {module:nyc/soda/Query~Query.Query} q the query
   */
  setQuery(q) {
    q = q || {}
    this.query.select = q.select || this.query.select || '*'
    this.query.where = q.where || this.query.where || ''
    this.query.group = q.group || this.query.group || ''
    this.query.order = q.order || this.query.order || ''
    this.query.limit = q.limit || this.query.limit || ''
  }

  /**
   * @desc Set the url for which queries can be added to
   * @private
   * @method
   * @param {string} url Url
   */
  setUrl(url) {
    this.url = url || this.url
  }

  /**
   * @desc Get the SODA API call URL that was last requested or will be requested with the current query and filter settings
   * @public
   * @method
   * @return {string} The SODA API call URL
   */
  getUrlAndQuery() {
    return `${this.url}?${this.qstr()}`
  }

  /**
   * @desc Execute the query
   * @public
   * @method
   * @param {module:nyc/soda/Query~Query.Options} options The execution options
   * @return {Promise} Promise
   */
  execute(options) {
    options = options || {}
    this.setFilters(options.filters)
    this.setUrl(options.url)
    this.setQuery(options.query)
    this.setAppToken(options.appToken)

    const url = `${this.url}?${this.qstr()}`

    return new Promise((resolve, reject) => {
      fetch(url).then(response => {
        return response.text()
      })
        .then(text => {
          this.parseResponse(text, resolve)
        })
        .catch(err => {
          reject(err)
        })
    })
  }

  /**
   * @desc Parses response from query depending on file type, and resolves promise if successful
   * @private
   * @method
   * @param {string} text The response text
   * @param {function(Array<Object<string, Object>>)} resolve Resolve callback function from the promise
   */
  parseResponse(text, resolve) {
    let data
    if (this.csv()) {
      data = Papa.parse(text, {header: true}).data
    } else {
      data = JSON.parse(text).rows
    }
    resolve(data)
  }

  /**
   * @desc Checks if file is .csv type
   * @private
   * @method
   * @return {boolean} Are we dealing with a CSV?
   */
  csv() {
    const idxCsv = this.url.indexOf('.csv')
    const idxQstr = this.url.indexOf('?')
    const len = this.url.length
    const csvPos = len - idxCsv
    const qstrPos = len - idxQstr
    return idxCsv > -1 && (csvPos === 4 || (qstrPos === csvPos - 4))
  }


  /**
   * @desc Generates query string to be appended to url
   * @private
   * @method
   * @return {string} Query string
   */
  qstr() {
    const qry = {}
    Object.keys(this.query).forEach(clause => {
      qry[`$${clause}`] = this.query[clause]
    })
    Object.keys(this.filters).forEach(field => {
      this.filters[field].forEach(filter => {
        qry.$where = this.appendFilter(qry.$where, field, filter)
      })
    })
    if (this.appToken) {
      qry.$$app_token = this.appToken
    }
    return $.param(qry)
  }
}

/**
 * @desc Append to a where clause with AND
 * @public
 * @static
 * @function
 * @param {string} where The current where clause
 * @param {string} clause The new clause to append to the current where clause
 * @return {string} The modified where clause
 */
Query.and = (where, clause) => {
  if (where) {
    if (clause) {
      return `${where} AND ${clause}`
    }
    return where
  }
  return clause
}


/**
 * @desc Filter object for {@link module:nyc/soda/Query~Query}
 * @public
 * @typedef {Object}
 * @property {string} op Filter operator (=, !=, >, <, >=, <=, IN, NOT IN, BETWEEN, NOT BETWEEN)
 * @property {string|number|Array<string>|Array<number>} value Filter value
 */
Query.Filter

/**
 * @desc Query object for {@link module:nyc/soda/Query~Query}
 * @public
 * @typedef {Object}
 * @property {string=} select SODA $select parameter
 * @property {string=} where SODA $where parameter
 * @property {string=} group SODA $group parameter
 * @property {string=} order SODA $order parameter
 * @property {number=} limit SODA $limit parameter
 */
Query.Query

/**
 * @desc Constructor options for {@link module:nyc/soda/Query~Query}
 * @public
 * @typedef {Object}
 * @property {string=} url SODA URL
 * @property {string=} appToken SODA $$app_token parameter
 * @property {module:nyc/soda/Query~Query.Query=} query Query options
 * @property {Object<string, Array<module:nyc/soda/Query~Query.Filter>>=} filters Filter arrays mapped to field names
 */
Query.Options


export default Query