Skip to content

Commit

Permalink
(dsl): Query DSL (#23)
Browse files Browse the repository at this point in the history
  • Loading branch information
markaya authored Dec 16, 2022
1 parent 610fdb0 commit 4742581
Show file tree
Hide file tree
Showing 6 changed files with 298 additions and 4 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package zio.elasticsearch

import zio.json.ast.Json
import zio.json.ast.Json.{Arr, Bool, Num, Obj, Str}

sealed trait ElasticQuery { self =>

def asJson: Json

final def asJsonBody: Json = Obj("query" -> self.asJson)

}

object ElasticQuery {

def matches(field: String, query: String): ElasticQuery =
Match(field, query)

def matches(field: String, query: Boolean): ElasticQuery =
Match(field, query)

def matches(field: String, query: Long): ElasticQuery =
Match(field, query)

def boolQuery(): BoolQuery = BoolQuery.empty

private[elasticsearch] final case class BoolQuery(must: List[ElasticQuery], should: List[ElasticQuery])
extends ElasticQuery { self =>

override def asJson: Json =
Obj("bool" -> Obj("must" -> Arr(must.map(_.asJson): _*), "should" -> Arr(should.map(_.asJson): _*)))

def must(queries: ElasticQuery*): BoolQuery =
self.copy(must = must ++ queries)

def should(queries: ElasticQuery*): BoolQuery =
self.copy(should = should ++ queries)
}

object BoolQuery {
def empty: BoolQuery = BoolQuery(Nil, Nil)
}

private[elasticsearch] final case class Match[A](field: String, query: A) extends ElasticQuery {
override def asJson: Json =
query match {
case str if str.isInstanceOf[String] =>
Obj("match" -> Obj(field -> Str(str.asInstanceOf[String])))
case bool if bool.isInstanceOf[Boolean] =>
Obj("match" -> Obj(field -> Bool(bool.asInstanceOf[Boolean])))
case num if num.isInstanceOf[Long] =>
Obj("match" -> Obj(field -> Num(num.asInstanceOf[Long])))
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package zio.elasticsearch

import zio.json.ast.Json
import zio.json.{DeriveJsonDecoder, JsonDecoder, jsonField}

private[elasticsearch] final case class ElasticQueryResponse(
took: Int,
@jsonField("timed_out")
timedOut: Boolean,
@jsonField("_shards")
shards: Shards,
hits: Hits
)

private[elasticsearch] object ElasticQueryResponse {
implicit val decoder: JsonDecoder[ElasticQueryResponse] = DeriveJsonDecoder.gen[ElasticQueryResponse]
}

private[elasticsearch] final case class Shards(
total: Int,
successful: Int,
skipped: Int,
failed: Int
)

private[elasticsearch] object Shards {
implicit val decoder: JsonDecoder[Shards] = DeriveJsonDecoder.gen[Shards]
}

private[elasticsearch] final case class Hits(
total: Total,
@jsonField("max_score")
maxScore: Double,
hits: List[Item]
)

private[elasticsearch] object Hits {
implicit val decoder: JsonDecoder[Hits] = DeriveJsonDecoder.gen[Hits]
}

private[elasticsearch] final case class Total(value: Long, relation: String)

private[elasticsearch] object Total {
implicit val decoder: JsonDecoder[Total] = DeriveJsonDecoder.gen[Total]
}

private[elasticsearch] final case class Item(
@jsonField("_index")
index: String,
@jsonField("_type")
`type`: String,
@jsonField("_id")
id: String,
@jsonField("_score")
score: Double,
@jsonField("_source")
source: Json
)

private[elasticsearch] object Item {
implicit val decoder: JsonDecoder[Item] = DeriveJsonDecoder.gen[Item]
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ object ElasticRequest {
case None => Left(DocumentNotFound)
}

def search(index: IndexName, query: ElasticQuery): ElasticRequest[Option[ElasticQueryResponse]] =
GetByQuery(index, query)

def createIndex(name: IndexName, definition: Option[String]): ElasticRequest[Unit] =
CreateIndex(name, definition)

Expand Down Expand Up @@ -92,6 +95,12 @@ object ElasticRequest {
routing: Option[Routing] = None
) extends ElasticRequest[Option[Document]]

private[elasticsearch] final case class GetByQuery(
index: IndexName,
query: ElasticQuery,
routing: Option[Routing] = None
) extends ElasticRequest[Option[ElasticQueryResponse]]

private[elasticsearch] final case class Map[A, B](request: ElasticRequest[A], mapper: A => B)
extends ElasticRequest[B]
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ private[elasticsearch] final class HttpElasticExecutor private (config: ElasticC
case r: DeleteIndex => executeDeleteIndex(r)
case r: Exists => executeExists(r)
case r: GetById => executeGetById(r)
case r: GetByQuery => executeQuery(r)
case map @ Map(_, _) => execute(map.request).map(map.mapper)
}

Expand Down Expand Up @@ -89,19 +90,28 @@ private[elasticsearch] final class HttpElasticExecutor private (config: ElasticC
req: RequestT[Identity, Either[ResponseException[String, String], A], Any]
): Task[Response[Either[ResponseException[String, String], A]]] =
for {
_ <- logDebug(s"[es-req]: ${req.show(includeBody = true, includeHeaders = true, sensitiveHeaders = Set())}")
_ <- logDebug(s"[es-req]: ${req.show(includeBody = true, includeHeaders = true, sensitiveHeaders = Set.empty)}")
resp <- req.send(client)
_ <- logDebug(s"[es-res]: ${resp.show(includeBody = true, includeHeaders = true, sensitiveHeaders = Set())}")
_ <- logDebug(s"[es-res]: ${resp.show(includeBody = true, includeHeaders = true, sensitiveHeaders = Set.empty)}")
} yield resp

private def sendRequest(
req: RequestT[Identity, Either[String, String], Any]
): Task[Response[Either[String, String]]] =
for {
_ <- logDebug(s"[es-req]: ${req.show(includeBody = true, includeHeaders = true, sensitiveHeaders = Set())}")
_ <- logDebug(s"[es-req]: ${req.show(includeBody = true, includeHeaders = true, sensitiveHeaders = Set.empty)}")
resp <- req.send(client)
_ <- logDebug(s"[es-res]: ${resp.show(includeBody = true, includeHeaders = true, sensitiveHeaders = Set())}")
_ <- logDebug(s"[es-res]: ${resp.show(includeBody = true, includeHeaders = true, sensitiveHeaders = Set.empty)}")
} yield resp

private def executeQuery(r: GetByQuery): Task[Option[ElasticQueryResponse]] =
sendRequestWithCustomResponse(
request
.post(uri"${config.uri}/${IndexName.unwrap(r.index)}/_search")
.response(asJson[ElasticQueryResponse])
.contentType(ApplicationJson)
.body(r.query.asJsonBody)
).map(_.body.toOption)
}

private[elasticsearch] object HttpElasticExecutor {
Expand Down
146 changes: 146 additions & 0 deletions modules/library/src/test/scala/zio/elasticsearch/QueryDSLSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package zio.elasticsearch

import zio.Scope
import zio.elasticsearch.ElasticQuery._
import zio.elasticsearch.utils.Utils._
import zio.test._

object QueryDSLSpec extends ZIOSpecDefault {
override def spec: Spec[Environment with TestEnvironment with Scope, Any] =
suite("Query DSL")(
suite("Creating Elastic Query Class")(
test("Successfully create Match Query using `matches` method") {
val queryString = matches("day_of_week", "Monday")
val queryBool = matches("day_of_week", true)
val queryLong = matches("day_of_week", 1L)

assert(queryString)(Assertion.equalTo(Match("day_of_week", "Monday")))
assert(queryBool)(Assertion.equalTo(Match("day_of_week", true)))
assert(queryLong)(Assertion.equalTo(Match("day_of_week", 1)))
},
test("Successfully create `Must` Query from two Match queries") {
val query = boolQuery().must(matches("day_of_week", "Monday"), matches("customer_gender", "MALE"))

assert(query)(
Assertion.equalTo(
BoolQuery(List(Match("day_of_week", "Monday"), Match("customer_gender", "MALE")), List.empty)
)
)
},
test("Successfully create `Should` Query from two Match queries") {
val query = boolQuery().should(matches("day_of_week", "Monday"), matches("customer_gender", "MALE"))

assert(query)(
Assertion.equalTo(
BoolQuery(List.empty, List(Match("day_of_week", "Monday"), Match("customer_gender", "MALE")))
)
)
},
test("Successfully create `Must/Should` mixed Query") {
val query = boolQuery()
.must(matches("day_of_week", "Monday"), matches("customer_gender", "MALE"))
.should(matches("customer_age", 23))

assert(query)(
Assertion.equalTo(
BoolQuery(
List(Match("day_of_week", "Monday"), Match("customer_gender", "MALE")),
List(Match("customer_age", 23))
)
)
)
},
test("Successfully create `Should/Must` mixed Query") {
val query = boolQuery()
.must(matches("customer_age", 23))
.should(matches("day_of_week", "Monday"), matches("customer_gender", "MALE"))

assert(query)(
Assertion.equalTo(
BoolQuery(
List(Match("customer_age", 23)),
List(Match("day_of_week", "Monday"), Match("customer_gender", "MALE"))
)
)
)
}
),
suite("Writing out Elastic Query as Json")(
test("Properly write JSON body for Match query") {
val queryBool = matches("day_of_week", true)

assert(queryBool.asJsonBody)(
Assertion.equalTo(
"""{
"query": {
"match": {
"day_of_week":true
}
}
}
}""".toJson
)
)
},
test("Properly write JSON body for must query") {
val queryBool = boolQuery().must(matches("day_of_week", "Monday"))

assert(queryBool.asJsonBody)(
Assertion.equalTo(
"""{
"query": {
"bool":{
"must":[
{"match": {"day_of_week":"Monday"}}
],
"should":[]
}
}
}""".toJson
)
)
},
test("Properly write JSON body for must query") {
val queryBool = boolQuery().should(matches("day_of_week", "Monday"))

assert(queryBool.asJsonBody)(
Assertion.equalTo(
"""{
"query": {
"bool":{
"must":[],
"should":[
{"match": {"day_of_week":"Monday"}}
]
}
}
}""".toJson
)
)
},
test("Properly write JSON body for mixed `AND/OR` query") {
val queryBool =
boolQuery()
.must(matches("customer_id", 1))
.should(matches("day_of_week", "Monday"))

assert(queryBool.asJsonBody)(
Assertion.equalTo(
"""{
"query": {
"bool":{
"must":[
{"match": {"customer_id":1}}
],
"should":[
{"match": {"day_of_week":"Monday"}}
]
}
}
}""".toJson
)
)
}
)
)
}
11 changes: 11 additions & 0 deletions modules/library/src/test/scala/zio/elasticsearch/utils/Utils.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package zio.elasticsearch.utils

import zio.json.DecoderOps
import zio.json.ast.Json

object Utils {

implicit class RichString(private val text: String) extends AnyVal {
def toJson: Json = text.fromJson[Json].toOption.get
}
}

0 comments on commit 4742581

Please sign in to comment.