weacast/weacast-core

View on GitHub
src/hooks/query.js

Summary

Maintainability
A
3 hrs
Test Coverage
A
90%
import moment from 'moment'
import fs from 'fs-extra'
import logger from 'winston'
import _ from 'lodash'
import path from 'path'
import makeDebug from 'debug'
import { getItems, replaceItems, discard } from 'feathers-hooks-common'
import { marshallTime } from './marshall'
import { Grid } from '../common/grid'

const debug = makeDebug('weacast:weacast-core')
const discardDataField = discard('data')
const discardFilepathField = discard('filePath')
const discardConvertedFilepathField = discard('convertedFilePath')

function marshallComparisonFieldsInQuery (queryObject) {
  _.forOwn(queryObject, (value, key) => {
    // Process current attributes or  recurse
    if (typeof value === 'object') {
      marshallComparisonFieldsInQuery(value)
    } else if ((key === '$lt') || (key === '$lte') || (key === '$gt') || (key === '$gte')) {
      let number = _.toNumber(value)
      // Update from query string to number if required
      if (!Number.isNaN(number)) {
        queryObject[key] = number
      } else {
        // try for dates as well
        let date = moment.utc(value)
        if (date.isValid()) {
          queryObject[key] = new Date(date.valueOf())
        }
      }
    }
  })
}

export function marshallComparisonQuery (hook) {
  let query = hook.params.query
  if (query) {
    // Complex queries might have nested objects so we call a recursive function to handle this
    marshallComparisonFieldsInQuery(query)
  }
}

export function marshallQuery (hook) {
  let query = hook.params.query
  let service = hook.service

  if (query) {
    // Need to convert from client/server side types : string or moment dates
    marshallTime(query, 'runTime')
    marshallTime(query, 'forecastTime')

    // In this case take care that we always internally require the file path, it will be removed for the client by another hook
    if (!_.isNil(query.$select) && !_.isNil(service.element) && service.isExternalDataStorage()) {
      query.$select.push('convertedFilePath')
    }
    // When listing available forecast we might want to disable pagination
    // However disabling or changing the default pagination is not available in the client in Feathers by default,
    // this is the reason of this specific hook
    if (query.$paginate === 'false' || query.$paginate === false) {
      hook.params.paginate = false
      delete query.$paginate
    }
  }
}

function coordinatesToNumbers (value) {
  if (typeof value === 'string') {
    return _.toNumber(value)
  } else if (Array.isArray(value)) {
    return value.map(item => coordinatesToNumbers(item))
  } else {
    return value
  }
}

function marshallGeometryQuery (query) {
  if (typeof query.geometry === 'object') {
    // Geospatial operators begin with $
    let geoOperator = _.keys(query.geometry).find(key => key.startsWith('$'))
    // Specific case of exist
    if (geoOperator === '$exists') {
      const value = query.geometry.$exists
      query.geometry.$exists = (typeof value === 'string' ? (value === 'true' || value === '1') : value)
      return
    }
    geoOperator = query.geometry[geoOperator]
    _.forOwn(geoOperator, (value, key) => {
      // Geospatial parameters begin with $
      if (key.startsWith('$')) {
        // Some target coordinates
        if (!_.isNil(value.coordinates)) {
          value.coordinates = coordinatesToNumbers(value.coordinates)
        } else {
          // Other simple values or array of values
          geoOperator[key] = coordinatesToNumbers(value)
        }
      }
    })
  }
}

export function marshallTileQuery (hook) {
  let params = hook.params
  // Ensure we have a query object to update
  if (!params.query) params.query = {}
  let query = params.query
  // This ensure that when no geometry is specified we only access raw data
  if (!query.geometry && !_.has(query, 'x') && !_.has(query, 'y')) {
    query.geometry = { $exists: false }
  }
  // If no geometry query tile x/y can be directly provided
  if (typeof query.x === 'string') query.x = _.toNumber(query.x)
  if (typeof query.y === 'string') query.y = _.toNumber(query.y)
}

export function marshallSpatialQuery (hook) {
  let query = hook.params.query
  if (query) {
    marshallGeometryQuery(query)
    // Resampling is used by hooks only, do not send it to DB
    if (!_.isNil(query.oLon) && !_.isNil(query.oLat) && !_.isNil(query.sLon) && !_.isNil(query.sLat) && !_.isNil(query.dLon) && !_.isNil(query.dLat)) {
      // Convert when required from query strings
      hook.params.oLat = _.toNumber(query.oLat)
      hook.params.oLon = _.toNumber(query.oLon)
      hook.params.sLat = _.toNumber(query.sLat)
      hook.params.sLon = _.toNumber(query.sLon)
      hook.params.dLat = _.toNumber(query.dLat)
      hook.params.dLon = _.toNumber(query.dLon)
      delete query.oLat
      delete query.oLon
      delete query.sLat
      delete query.sLon
      delete query.dLat
      delete query.dLon
    }
    // Shortcut for proximity query
    if ((!_.isNil(query.centerLon) || !_.isNil(query.longitude)) &&
        (!_.isNil(query.centerLat) || !_.isNil(query.latitude)) && !_.isNil(query.distance)) {
      const longitude = (_.isNil(query.centerLon) ? _.toNumber(query.longitude) : _.toNumber(query.centerLon))
      const latitude = (_.isNil(query.centerLat) ? _.toNumber(query.latitude) : _.toNumber(query.centerLat))
      const distance = _.toNumber(query.distance)
      // Transform to MongoDB spatial request
      delete query.centerLon
      delete query.longitude
      delete query.centerLat
      delete query.latitude
      delete query.distance
      /* We switched from $near to $geoWithin due to https://github.com/weacast/weacast-core/issues/36
      query.geometry = {
        $near: {
          $geometry: {
              type: 'Point',
              coordinates: [longitude, latitude]
            },
            $maxDistance: distance
        }
      }
      */
      query.geometry = {
        $geoWithin: {
          $centerSphere: [[longitude, latitude], distance / 6378137.0] // Earth radius as in radians
        }
      }
    }
    // Shortcut for bbox query
    if (!_.isNil(query.south) && !_.isNil(query.north) && !_.isNil(query.west) && !_.isNil(query.east)) {
      const south = _.toNumber(query.south)
      const north = _.toNumber(query.north)
      const west = _.toNumber(query.west)
      const east = _.toNumber(query.east)
      // Transform to MongoDB spatial request
      delete query.south
      delete query.north
      delete query.west
      delete query.east
      query.geometry = {
        $geoIntersects: {
          $geometry: {
            type: 'Polygon',
            coordinates: [ // BBox as a polygon
              [[west, south], [east, south], [east, north], [west, north], [west, south]] // Closing point
            ]
          }
        }
      }
    }
  }
}

export function processForecastTime (hook) {
  let query = hook.params.query
  let service = hook.service

  if (query && !_.isNil(query.time)) {
    // Find nearest forecast time corresponding to request time
    let time = (typeof query.time === 'string' ? moment.utc(query.time) : query.time)
    let forecastTime = service.getNearestForecastTime(time)
    delete query.time
    query.forecastTime = new Date(forecastTime.valueOf())
  }
  if (query && !_.isNil(query.from) && !_.isNil(query.to)) {
    // Find nearest forecast time corresponding to request time range
    let from = (typeof query.from === 'string' ? moment.utc(query.from) : query.from)
    let to = (typeof query.to === 'string' ? moment.utc(query.to) : query.to)
    let fromForecastTime = service.getNearestForecastTime(from)
    let toForecastTime = service.getNearestForecastTime(to)
    delete query.from
    delete query.to
    query.forecastTime = {
      $gte: new Date(fromForecastTime.valueOf()),
      $lte: new Date(toForecastTime.valueOf())
    }
  }
}

function readFile (service, item) {
  const inputPath = (path.isAbsolute(item.convertedFilePath)
    ? item.convertedFilePath
    : path.join(service.app.get('forecastPath'), item.convertedFilePath))

  return new Promise((resolve, reject) => {
    fs.readJson(inputPath, 'utf8')
    .then(grid => {
      item.data = grid
      resolve(item)
    })
    .catch(error => {
      let errorMessage = 'Cannot read converted ' + service.forecast.name + '/' + service.element.name + ' forecast'
      if (item.forecastTime) errorMessage += ' at ' + item.forecastTime.format()
      if (item.runTime) errorMessage += ' for run ' + item.runTime.format()
      logger.error(errorMessage)
      debug('Input JSON file was : ' + item.convertedFilePath)
      reject(error)
    })
  })
}

export async function processData (hook) {
  let params = hook.params
  let query = params.query
  let service = hook.service
  let items = getItems(hook)
  const isArray = Array.isArray(items)
  items = (isArray ? items : [items])

  // If we use a file based storage we have to load data on demand
  if (service.isExternalDataStorage()) {
    // Process data files when required
    if (query && !_.isNil(query.$select) && query.$select.includes('data')) {
      let dataPromises = []
      items.forEach(item => {
        // Except if we target tiles which have internal data
        if (item.convertedFilePath) {
          // In this case we need to extract from GridFS first
          if (service.element.dataStore === 'gridfs') {
            dataPromises.push(
              service.readFromGridFS(item.convertedFilePath)
              .then(_ => readFile(service, item))
            )
          } else {
            dataPromises.push(readFile(service, item))
          }
        }
      })
      // Something to be done ?
      if (dataPromises.length > 0) {
        await Promise.all(dataPromises)
        // In this case we need to remove extracted files as they are temporary
        if (service.element.dataStore === 'gridfs') {
          await Promise.all(items.map(item => fs.remove(item.convertedFilePath)))
        }
        replaceItems(hook, isArray ? items : items[0])
      }
    }
    // Remove any sensitive information about file path on the client side
    discardFilepathField(hook)
    discardConvertedFilepathField(hook)
  }
  // Only discard if not explicitely asked by $select
  if (_.isNil(query) || _.isNil(query.$select) || !query.$select.includes('data')) {
    discardDataField(hook)
  } else {
    // Check for resampling on returned data
    if (!_.isNil(params.oLon) && !_.isNil(params.oLat) && !_.isNil(params.sLon) && !_.isNil(params.sLat) && !_.isNil(params.dLon) && !_.isNil(params.dLat)) {
      items.forEach(item => {
        let grid = new Grid({
          bounds: service.forecast.bounds,
          origin: service.forecast.origin,
          size: service.forecast.size,
          resolution: service.forecast.resolution,
          data: item.data
        })
        item.data = grid.resample([params.oLon, params.oLat], [params.dLon, params.dLat], [params.sLon, params.sLat])
        // Update statistics
        Object.assign(item, Grid.getMinMax(item.data))
      })
      replaceItems(hook, isArray ? items : items[0])
    }
  }
}