aboutsummaryrefslogtreecommitdiff
path: root/src/main/scala/xyz/driver/restquery/rest/parsers/SearchFilterParser.scala
blob: 13f82a6047c6a6ff8313af519c6fbdf1766c2d61 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
package xyz.driver.restquery.rest.parsers

import java.util.UUID

import fastparse.all._
import fastparse.core.Parsed
import xyz.driver.restquery.query.{SearchFilterBinaryOperation, SearchFilterExpr, SearchFilterNAryOperation}
import xyz.driver.restquery.utils.Utils
import xyz.driver.restquery.utils.Utils._

import scala.util.Try

@SuppressWarnings(Array("org.wartremover.warts.Product", "org.wartremover.warts.Serializable"))
object SearchFilterParser {

  private object BinaryAtomFromTuple {
    def unapply(input: (SearchFilterExpr.Dimension, (String, Any))): Option[SearchFilterExpr.Atom.Binary] = {
      val (dimensionName, (strOperation, value)) = input
      val updatedValue                           = trimIfString(value)

      parseBinaryOperation(strOperation.toLowerCase).map { op =>
        SearchFilterExpr.Atom.Binary(dimensionName, op, updatedValue.asInstanceOf[AnyRef])
      }
    }
  }

  private object NAryAtomFromTuple {
    // Compiler warning: unchecked since it is eliminated by erasure, if we user Seq[String]
    def unapply(input: (SearchFilterExpr.Dimension, (String, Seq[_]))): Option[SearchFilterExpr.Atom.NAry] = {
      val (dimensionName, (strOperation, xs)) = input
      val updatedValues                       = xs.map(trimIfString)

      parseNAryOperation(strOperation.toLowerCase).map(
        op =>
          SearchFilterExpr.Atom
            .NAry(dimensionName, op, updatedValues.map(_.asInstanceOf[AnyRef])))
    }
  }

  private def trimIfString(value: Any) =
    value match {
      case s: String => Utils.safeTrim(s)
      case a         => a
    }

  private def parseBinaryOperation: String => Option[SearchFilterBinaryOperation] =
    SearchFilterBinaryOperation.binaryOperationsFromString.get

  private def parseNAryOperation: String => Option[SearchFilterNAryOperation] =
    SearchFilterNAryOperation.nAryOperationsFromString.get

  private val whitespaceParser = P(CharPred(Utils.isSafeWhitespace))

  val dimensionParser: Parser[SearchFilterExpr.Dimension] = {
    val identParser = P(
      CharPred(c => c.isLetterOrDigit)
        .rep(min = 1)).!.map(s => SearchFilterExpr.Dimension(None, toSnakeCase(s)))
    val pathParser = P(identParser.! ~ "." ~ identParser.!) map {
      case (left, right) =>
        SearchFilterExpr.Dimension(Some(toSnakeCase(left)), toSnakeCase(right))
    }
    P(pathParser | identParser)
  }

  private val commonOperatorParser: Parser[String] = {
    import xyz.driver.restquery.query.SearchFilterBinaryOperation.binaryOperationToName
    val eq    = binaryOperationToName(SearchFilterBinaryOperation.Eq)
    val like  = binaryOperationToName(SearchFilterBinaryOperation.Like)
    val noteq = binaryOperationToName(SearchFilterBinaryOperation.NotEq)
    P(IgnoreCase(eq) | IgnoreCase(like) | IgnoreCase(noteq)).!
  }

  private val numericOperatorParser: Parser[String] = {
    import xyz.driver.restquery.query.SearchFilterBinaryOperation.binaryOperationToName
    val eq    = binaryOperationToName(SearchFilterBinaryOperation.Eq)
    val noteq = binaryOperationToName(SearchFilterBinaryOperation.NotEq)
    val gt    = binaryOperationToName(SearchFilterBinaryOperation.Gt)
    val lt    = binaryOperationToName(SearchFilterBinaryOperation.Lt)
    P(IgnoreCase(eq) | IgnoreCase(noteq) | ((IgnoreCase(gt) | IgnoreCase(lt)) ~ IgnoreCase(eq).?)).!
  }

  private val naryOperatorParser: Parser[String] = {
    import xyz.driver.restquery.query.SearchFilterNAryOperation.nAryOperationToName
    val in    = nAryOperationToName(SearchFilterNAryOperation.In)
    val notin = nAryOperationToName(SearchFilterNAryOperation.NotIn)
    P(IgnoreCase(in) | IgnoreCase(notin)).!
  }

  private val isPositiveParser: Parser[Boolean] = P(CharIn("-+").!.?).map {
    case Some("-") => false
    case _         => true
  }

  private val digitsParser: Parser[String] = P(CharIn('0' to '9').rep(min = 1).!) // Exclude Unicode "digits"

  private val numberParser: Parser[String] =
    P(isPositiveParser ~ digitsParser.! ~ ("." ~ digitsParser).!.?).map {
      case (false, intPart, Some(fracPart)) => s"-$intPart.${fracPart.tail}"
      case (false, intPart, None)           => s"-$intPart"
      case (_, intPart, Some(fracPart))     => s"$intPart.${fracPart.tail}"
      case (_, intPart, None)               => s"$intPart"
    }

  private val nAryValueParser: Parser[String] = P(CharPred(_ != ',').rep(min = 1).!)

  private val longParser: Parser[Long] = P(CharIn('0' to '9').rep(min = 1).!.map(_.toLong))

  private val booleanParser: Parser[Boolean] =
    P((IgnoreCase("true") | IgnoreCase("false")).!.map(_.toBoolean))

  private val hexDigit: Parser[String] = P((CharIn('a' to 'f') | CharIn('A' to 'F') | CharIn('0' to '9')).!)

  private val uuidParser: Parser[UUID] =
    P(
      hexDigit.rep(8).! ~ "-" ~ hexDigit.rep(4).! ~ "-" ~ hexDigit
        .rep(4)
        .! ~ "-" ~ hexDigit.rep(4).! ~ "-" ~ hexDigit
        .rep(12)
        .!).map {
      case (group1, group2, group3, group4, group5) =>
        UUID.fromString(s"$group1-$group2-$group3-$group4-$group5")
    }

  private val binaryAtomParser: Parser[SearchFilterExpr.Atom.Binary] = P(
    dimensionParser ~ whitespaceParser ~
      ((numericOperatorParser.! ~ whitespaceParser ~ (longParser | numberParser.!) ~ End) |
        (commonOperatorParser.! ~ whitespaceParser ~ (uuidParser | booleanParser | AnyChar
          .rep(min = 1)
          .!) ~ End))
  ).map {
    case BinaryAtomFromTuple(atom) => atom
  }

  private val nAryAtomParser: Parser[SearchFilterExpr.Atom.NAry] = P(
    dimensionParser ~ whitespaceParser ~ (
      naryOperatorParser ~ whitespaceParser ~
        ((longParser.rep(min = 1, sep = ",") ~ End) | (booleanParser
          .rep(min = 1, sep = ",") ~ End) |
          (nAryValueParser.!.rep(min = 1, sep = ",") ~ End))
    )
  ).map {
    case NAryAtomFromTuple(atom) => atom
  }

  private val atomParser: Parser[SearchFilterExpr.Atom] = P(binaryAtomParser | nAryAtomParser)

  @deprecated("play-akka transition", "0")
  def parse(query: Map[String, Seq[String]]): Try[SearchFilterExpr] =
    parse(query.toSeq.flatMap {
      case (key, values) =>
        values.map(value => key -> value)
    })

  def parse(query: Seq[(String, String)]): Try[SearchFilterExpr] = Try {
    query.toList.collect { case ("filters", value) => value } match {
      case Nil => SearchFilterExpr.Empty

      case head :: Nil =>
        atomParser.parse(head) match {
          case Parsed.Success(x, _) => x
          case e: Parsed.Failure[_, _] =>
            throw new ParseQueryArgException("filters" -> formatFailure(1, e))
        }

      case xs =>
        val parsed = xs.map(x => atomParser.parse(x))
        val failures: Seq[String] = parsed.zipWithIndex.collect {
          case (e: Parsed.Failure[_, _], index) => formatFailure(index, e)
        }

        if (failures.isEmpty) {
          val filters = parsed.collect {
            case Parsed.Success(x, _) => x
          }

          SearchFilterExpr.Intersection.create(filters: _*)
        } else {
          throw new ParseQueryArgException("filters" -> failures.mkString("; "))
        }
    }
  }

  private def formatFailure(sectionIndex: Int, e: Parsed.Failure[_, _]): String = {
    s"section $sectionIndex: ${fastparse.core.ParseError
      .msg(e.extra.input, e.extra.traced.expected, e.index)}"
  }

}