src/utils.cql.js

Summary

Maintainability
A
0 mins
Test Coverage
B
84%
import _ from 'lodash'
import makeDebug from 'debug'
import errors from '@feathersjs/errors'
import { parse as parseWtk } from 'wellknown'
import { convertValue, convertDateTime } from './utils.js'

const debug = makeDebug('kfs:utils:cql')
const { BadRequest } = errors
// Reserved properties at root level ?
const ReservedProperties = ['time', 'geometry']

export function convertSpatialTextCqlExpression (expression, operator) {
  const cqlJson = {}
  if (expression.startsWith(`${operator}(`)) {
    // Omit enclosing operator to manage operands
    expression = expression.replace(`${operator}(`, '')
    expression = expression.substring(0, expression.length - 1)
    const index = expression.indexOf(',')
    expression = [expression.substring(0, index), expression.substring(index + 1)]
    if (expression.length !== 2) throw new BadRequest(`Invalid ${operator} operator specification`)
    const geometry = parseWtk(expression[1])
    if (!geometry) throw new BadRequest(`Invalid WTK geometry specification ${expression[1]}`)
    cqlJson[_.lowerCase(operator)] = [{ property: expression[0] }, geometry]
  }
  return cqlJson
}

export function convertIsNullTextCqlExpression (expression, operator) {
  let cqlJson = {}
  if (expression.endsWith(operator)) {
    // Omit operator to manage operand
    const property = expression.replace(operator, '').trim()
    cqlJson = { isNull: { property } }
    if (operator.includes('NOT')) cqlJson = { not: cqlJson }
  }
  return cqlJson
}

export function convertTextToJsonCql (expression) {
  const cqlJson = {}
  let operators = ['INTERSECTS', 'WITHIN']
  operators.forEach(operator => {
    Object.assign(cqlJson, convertSpatialTextCqlExpression(expression, operator))
  })
  operators = ['IS NOT NULL', 'IS NULL']
  operators.forEach(operator => {
    Object.assign(cqlJson, convertIsNullTextCqlExpression(expression, operator))
  })
  return cqlJson
}

export function convertComparisonCqlOperator (expression, operator) {
  const query = {}
  if (_.has(expression, operator)) {
    let property = _.get(expression, `${operator}[0].property`)
    if (!ReservedProperties.includes(property)) property = `properties.${property}`
    const value = _.get(expression, `${operator}[1]`)
    if (!property) throw new BadRequest('Invalid comparison operator specification')
    query[property] = { [`$${operator}`]: convertValue(value) }
  }
  return query
}

export function convertComparisonCqlExpression (expression) {
  const query = {}
  const operators = ['eq', 'lt', 'gt', 'lte', 'gte']
  operators.forEach(operator => {
    Object.assign(query, convertComparisonCqlOperator(expression, operator))
  })
  if (expression.between) {
    let property = _.get(expression, 'between.value.property')
    if (!ReservedProperties.includes(property)) property = `properties.${property}`
    if (!property) throw new BadRequest('Invalid between operator specification')
    const lower = _.get(expression, 'between.lower')
    const upper = _.get(expression, 'between.upper')
    query[property] = { $gte: convertValue(lower), $lte: convertValue(upper) }
  }
  if (expression.in) {
    let property = _.get(expression, 'in.value.property')
    if (!ReservedProperties.includes(property)) property = `properties.${property}`
    if (!property) throw new BadRequest('Invalid in operator specification')
    const list = _.get(expression, 'in.list')
    query[property] = { $in: convertValue(list) }
  }
  return query
}

export function convertTemporalCqlExpression (expression) {
  const query = {}
  if (expression.before) {
    let property = _.get(expression, 'before[0].property')
    if (!ReservedProperties.includes(property)) property = `properties.${property}`
    const upper = _.get(expression, 'before[1]')
    if (!property || !upper) throw new BadRequest('Invalid before operator specification')
    query[property] = { $lt: convertDateTime(upper) }
  } else if (expression.after) {
    let property = _.get(expression, 'after[0].property')
    if (!ReservedProperties.includes(property)) property = `properties.${property}`
    const lower = _.get(expression, 'after[1]')
    if (!property || !lower) throw new BadRequest('Invalid after operator specification')
    query[property] = { $gt: convertDateTime(lower) }
  } else if (expression.during) {
    let property = _.get(expression, 'during[0].property')
    if (!ReservedProperties.includes(property)) property = `properties.${property}`
    const lower = _.get(expression, 'during[1][0]')
    const upper = _.get(expression, 'during[1][1]')
    if (!property || !lower || !upper) throw new BadRequest('Invalid during operator specification')
    query[property] = { $gte: convertDateTime(lower), $lte: convertDateTime(upper) }
  }
  return query
}

export function convertSpatialCqlExpression (expression) {
  const query = {}
  if (expression.intersects) {
    const property = _.get(expression, 'intersects[0].property', 'geometry')
    const geometry = _.get(expression, 'intersects[1]')
    if (!property || !geometry) throw new BadRequest('Invalid spatial operator specification')
    debug('Processed CQL intersects geometry:', geometry)
    query[property] = {
      $geoIntersects: {
        $geometry: geometry
      }
    }
  } else if (expression.within) {
    const property = _.get(expression, 'within[0].property', 'geometry')
    const geometry = _.get(expression, 'within[1]')
    if (!property || !geometry) throw new BadRequest('Invalid spatial operator specification')
    debug('Processed CQL within geometry:', geometry)
    query[property] = {
      $geoWithin: {
        $geometry: geometry
      }
    }
  }
  return query
}

export function convertLogicalCqlOperator (expression, operator) {
  const query = {}
  if (_.has(expression, operator)) {
    if (operator !== 'not') query[`$${operator}`] = []
    // NOT IS NULL predicate is managed by a dedicated function
    else if (_.has(expression, 'not.isNull')) return
    _.get(expression, operator).forEach(subexpression => {
      const subquery = convertCqlExpression(subexpression)
      if (operator === 'not') {
        // { not: { in: { value: { property: 'category' }, list: [] } } } should become
        // { category: { $not: { $in: [] } } } and subquery is like { category: { $in: [] } }
        const keys = Object.keys(subquery)
        if (keys.length !== 1) throw new BadRequest('Invalid not operator specification')
        query[keys[0]] = { $not: subquery[keys[0]] }
      } else {
        query[`$${operator}`].push(subquery)
      }
    })
  }
  return query
}

export function convertLogicalCqlExpression (expression) {
  const query = {}
  const operators = ['and', 'or', 'not']
  operators.forEach(operator => {
    Object.assign(query, convertLogicalCqlOperator(expression, operator))
  })
  return query
}

export function convertIsNullCqlExpression (expression) {
  const query = {}
  let property = _.get(expression, 'isNull.property')
  if (property) {
    if (!ReservedProperties.includes(property)) property = `properties.${property}`
    query[property] = { $eq: null }
  }
  property = _.get(expression, 'not.isNull.property')
  if (property) {
    if (!ReservedProperties.includes(property)) property = `properties.${property}`
    query[property] = { $ne: null }
  }
  return query
}

export function convertCqlExpression (expression) {
  const query = {}
  // Merge as different operators might target the same property
  Object.assign(query,
    convertIsNullCqlExpression(expression),
    convertLogicalCqlExpression(expression),
    convertComparisonCqlExpression(expression),
    convertTemporalCqlExpression(expression),
    convertSpatialCqlExpression(expression))
  return query
}

export function convertCqlQuery (query) {
  const encoding = _.get(query, 'filter-lang', 'cql-json')
  // TODO: we support a small subset of text encoding
  // We experimented various BNF parser without success (bnf and abnf nodejs modules, OpenLayers v2 CQL parser)
  // if (encoding !== 'cql-json') throw new BadRequest('Only JSON encoding of CQL is supported')
  let filter = _.get(query, 'filter')
  if (encoding === 'cql-text') {
    filter = convertTextToJsonCql(filter)
    debug('Converted CQL expression from text', filter)
  }
  return convertCqlExpression(filter)
}