From f7b46a0cdab1c96e3bfeb02bf3f25bfe748869fc Mon Sep 17 00:00:00 2001 From: Daniel Slapman Date: Fri, 1 Jul 2022 18:15:02 +0300 Subject: [PATCH 01/10] oolong-json module with JsonNode --- build.sbt | 9 ++++++ .../scala/ru/tinkoff/oolong/JsonNode.scala | 30 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 oolong-json/src/main/scala/ru/tinkoff/oolong/JsonNode.scala diff --git a/build.sbt b/build.sbt index 45df6f4..0823be3 100644 --- a/build.sbt +++ b/build.sbt @@ -14,6 +14,15 @@ val `oolong-bson` = (project in file("oolong-bson")) Test / fork := true, ) +val `oolong-json` = (project in file("oolong-json")) + .settings(Settings.common) + .settings( + libraryDependencies ++= Seq( + "org.apache.commons" % "commons-text" % "1.9" + ), + Test / fork := true + ) + val `oolong-core` = (project in file("oolong-core")) .settings(Settings.common) .dependsOn(`oolong-bson`) diff --git a/oolong-json/src/main/scala/ru/tinkoff/oolong/JsonNode.scala b/oolong-json/src/main/scala/ru/tinkoff/oolong/JsonNode.scala new file mode 100644 index 0000000..27dd500 --- /dev/null +++ b/oolong-json/src/main/scala/ru/tinkoff/oolong/JsonNode.scala @@ -0,0 +1,30 @@ +package ru.tinkoff.oolong + +import scala.compiletime.ops.boolean + +import org.apache.commons.text.StringEscapeUtils + +sealed private[oolong] trait JsonNode { + def render: String +} + +private[oolong] object JsonNode { + case object Null extends JsonNode { + override def render: String = "null" + } + case class Bool(value: Boolean) extends JsonNode { + override def render: String = value.toString + } + case class Num(value: BigDecimal) extends JsonNode { + override def render: String = value.toString + } + case class Str(value: String) extends JsonNode { + override def render: String = s"\"${StringEscapeUtils.escapeJson(value)}\"" + } + case class Arr(value: Seq[JsonNode]) extends JsonNode { + override def render: String = value.map(_.render).mkString("[", ",", "]") + } + case class Obj(value: Map[String, JsonNode]) extends JsonNode { + override def render: String = value.map((k, v) => s"\"$k\":${v.render}").mkString("{", ",", "}") + } +} From fdbfe869f6807aeb7223ab66efafc79f3191d192 Mon Sep 17 00:00:00 2001 From: Daniel Slapman Date: Sat, 2 Jul 2022 00:51:02 +0300 Subject: [PATCH 02/10] PoC ElasticQueryCompiler --- build.sbt | 12 +++ .../elasticsearch/ElasticQueryCompiler.scala | 78 +++++++++++++++++++ .../elasticsearch/ElasticQueryNode.scala | 13 ++++ .../scala/ru/tinkoff/oolong/JsonNode.scala | 3 + 4 files changed, 106 insertions(+) create mode 100644 oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala create mode 100644 oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryNode.scala diff --git a/build.sbt b/build.sbt index 0823be3..55ff9c6 100644 --- a/build.sbt +++ b/build.sbt @@ -49,6 +49,18 @@ val `oolong-mongo` = (project in file("oolong-mongo")) Test / fork := true ) +val `oolong-elasticsearch` = (project in file("oolong-elasticsearch")) + .settings(Settings.common) + .dependsOn(`oolong-core`, `oolong-json`) + .settings( + libraryDependencies ++= Seq( + "org.scalatest" %% "scalatest" % "3.2.11" % Test, + "org.slf4j" % "slf4j-api" % "1.7.36" % Test, + "org.slf4j" % "slf4j-simple" % "1.7.36" % Test, + ), + Test / fork := true + ) + val root = (project in file(".")) .settings(Settings.common) .aggregate(`oolong-bson`, `oolong-core`, `oolong-mongo`) diff --git a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala new file mode 100644 index 0000000..5213719 --- /dev/null +++ b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala @@ -0,0 +1,78 @@ +package ru.tinkoff.oolong.elasticsearch + +import scala.quoted.Expr +import scala.quoted.Quotes + +import org.bson.json.JsonMode + +import ru.tinkoff.oolong.* +import ru.tinkoff.oolong.elasticsearch.ElasticQueryNode as EQN + +object ElasticQueryCompiler extends Backend[QExpr, ElasticQueryNode, JsonNode] { + override def opt(ast: QExpr)(using quotes: Quotes): ElasticQueryNode = { + import quotes.reflect.* + + ast match { + case QExpr.Prop(path) => EQN.Field(path) + case QExpr.Eq(x, QExpr.Constant(s)) => EQN.Term(getField(x), EQN.Constant(s)) + case QExpr.And(exprs) => EQN.And(exprs map opt) + case QExpr.Or(exprs) => EQN.Or(exprs map opt) + case unhandled => report.errorAndAbort("Unprocessable") + } + } + + def getField(f: QExpr)(using quotes: Quotes): EQN.Field = + import quotes.reflect.* + f match + case QExpr.Prop(path) => EQN.Field(path) + case _ => report.errorAndAbort("Field is of wrong type") + + override def render(node: ElasticQueryNode)(using quotes: Quotes): String = "No-op" + + override def target(optRepr: ElasticQueryNode)(using quotes: Quotes): Expr[JsonNode] = { + import quotes.reflect.* + + optRepr match { + case and: EQN.And => + '{ + JsonNode.obj( + "must" -> + JsonNode.Arr(${ Expr.ofSeq(and.exprs.map(target)) }) + ) + } + case or: EQN.Or => + '{ + JsonNode.obj( + "should" -> + JsonNode.Arr(${ Expr.ofSeq(or.exprs.map(target)) }) + ) + } + case EQN.Term(EQN.Field(path), x) => + '{ JsonNode.obj("term" -> JsonNode.obj(${ Expr(path.mkString(".")) } -> ${ handleValues(x) })) } + case _ => report.errorAndAbort("given node can't be in that position") + } + } + + def handleValues(expr: ElasticQueryNode)(using q: Quotes): Expr[JsonNode] = + import q.reflect.* + + expr match { + case EQN.Constant(i: Long) => + '{ JsonNode.Num.apply(BigDecimal.apply(${ Expr(i: Long) })) } + case EQN.Constant(i: Int) => + '{ JsonNode.Num.apply(BigDecimal.apply(${ Expr(i: Int) })) } + case EQN.Constant(i: Short) => + '{ JsonNode.Num.apply(BigDecimal.apply(${ Expr(i: Short) })) } + case EQN.Constant(i: Byte) => + '{ JsonNode.Num.apply(BigDecimal.apply(${ Expr(i: Byte) })) } + case EQN.Constant(i: Double) => + '{ JsonNode.Num.apply(BigDecimal.apply(${ Expr(i: Double) })) } + case EQN.Constant(i: Float) => + '{ JsonNode.Num.apply(BigDecimal.apply(${ Expr(i: Float) })) } + case EQN.Constant(i: String) => + '{ JsonNode.Str.apply(${ Expr(i: String) }) } + case EQN.Constant(i: Boolean) => + '{ JsonNode.Bool.apply(${ Expr(i: Boolean) }) } + case _ => report.errorAndAbort(s"Given type is not literal constant") + } +} diff --git a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryNode.scala b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryNode.scala new file mode 100644 index 0000000..62e6251 --- /dev/null +++ b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryNode.scala @@ -0,0 +1,13 @@ +package ru.tinkoff.oolong.elasticsearch + +sealed trait ElasticQueryNode + +object ElasticQueryNode { + case class Field(path: List[String]) extends ElasticQueryNode + + case class Term(field: Field, expr: ElasticQueryNode) extends ElasticQueryNode + + case class And(exprs: List[ElasticQueryNode]) extends ElasticQueryNode + case class Or(exprs: List[ElasticQueryNode]) extends ElasticQueryNode + case class Constant[T](s: T) extends ElasticQueryNode +} diff --git a/oolong-json/src/main/scala/ru/tinkoff/oolong/JsonNode.scala b/oolong-json/src/main/scala/ru/tinkoff/oolong/JsonNode.scala index 27dd500..a8af17d 100644 --- a/oolong-json/src/main/scala/ru/tinkoff/oolong/JsonNode.scala +++ b/oolong-json/src/main/scala/ru/tinkoff/oolong/JsonNode.scala @@ -27,4 +27,7 @@ private[oolong] object JsonNode { case class Obj(value: Map[String, JsonNode]) extends JsonNode { override def render: String = value.map((k, v) => s"\"$k\":${v.render}").mkString("{", ",", "}") } + + def obj(head: (String, JsonNode), tail: (String, JsonNode)*): Obj = + Obj((head +: tail).to(Map)) } From 1e2a82c7ceacea917c861dc00b0be7cd382f8688 Mon Sep 17 00:00:00 2001 From: Daniel Slapman Date: Mon, 4 Jul 2022 10:55:49 +0300 Subject: [PATCH 03/10] `render` implementation --- .../elasticsearch/ElasticQueryCompiler.scala | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala index 5213719..bd5e6eb 100644 --- a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala +++ b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala @@ -27,7 +27,20 @@ object ElasticQueryCompiler extends Backend[QExpr, ElasticQueryNode, JsonNode] { case QExpr.Prop(path) => EQN.Field(path) case _ => report.errorAndAbort("Field is of wrong type") - override def render(node: ElasticQueryNode)(using quotes: Quotes): String = "No-op" + override def render(node: ElasticQueryNode)(using quotes: Quotes): String = { + import quotes.reflect.* + + node match { + case EQN.Term(EQN.Field(path), x) => "{ " + "\"" + path.mkString(".") + "\"" + ": " + render(x) + " }" + case EQN.And(exprs) => "{ $and: [ " + exprs.map(render).mkString(", ") + " ] }" + case EQN.Or(exprs) => "{ $or: [ " + exprs.map(render).mkString(", ") + " ] }" + case EQN.Constant(s: String) => "\"" + s + "\"" + case EQN.Constant(s: Any) => s.toString + case EQN.Field(field) => + // TODO: adjust error message + report.errorAndAbort(s"There is no filter condition on field ${field.mkString(".")}") + } + } override def target(optRepr: ElasticQueryNode)(using quotes: Quotes): Expr[JsonNode] = { import quotes.reflect.* From 4d04c08b1637a72e93d4ee52bcd757ef42e26ddc Mon Sep 17 00:00:00 2001 From: Daniel Slapman Date: Mon, 4 Jul 2022 11:58:20 +0300 Subject: [PATCH 04/10] Term query test --- .../elasticsearch/ElasticQueryCompiler.scala | 13 ++++---- .../oolong/elasticsearch/QueryCompiler.scala | 30 +++++++++++++++++++ .../oolong/elasticsearch/QuerySpec.scala | 14 +++++++++ .../oolong/elasticsearch/TestDomain.scala | 12 ++++++++ 4 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/QueryCompiler.scala create mode 100644 oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala create mode 100644 oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/TestDomain.scala diff --git a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala index bd5e6eb..1257206 100644 --- a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala +++ b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala @@ -31,12 +31,13 @@ object ElasticQueryCompiler extends Backend[QExpr, ElasticQueryNode, JsonNode] { import quotes.reflect.* node match { - case EQN.Term(EQN.Field(path), x) => "{ " + "\"" + path.mkString(".") + "\"" + ": " + render(x) + " }" - case EQN.And(exprs) => "{ $and: [ " + exprs.map(render).mkString(", ") + " ] }" - case EQN.Or(exprs) => "{ $or: [ " + exprs.map(render).mkString(", ") + " ] }" - case EQN.Constant(s: String) => "\"" + s + "\"" - case EQN.Constant(s: Any) => s.toString - case EQN.Field(field) => + case EQN.Term(EQN.Field(path), x) => + "{ \"term\": {" + "\"" + path.mkString(".") + "\"" + ": " + render(x) + " } }" + case EQN.And(exprs) => "{ \"must\": [ " + exprs.map(render).mkString(", ") + " ] }" + case EQN.Or(exprs) => "{ \"should\": [ " + exprs.map(render).mkString(", ") + " ] }" + case EQN.Constant(s: String) => "\"" + s + "\"" + case EQN.Constant(s: Any) => s.toString + case EQN.Field(field) => // TODO: adjust error message report.errorAndAbort(s"There is no filter condition on field ${field.mkString(".")}") } diff --git a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/QueryCompiler.scala b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/QueryCompiler.scala new file mode 100644 index 0000000..2602879 --- /dev/null +++ b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/QueryCompiler.scala @@ -0,0 +1,30 @@ +package ru.tinkoff.oolong.elasticsearch + +import scala.quoted.* + +import ru.tinkoff.oolong.* +import ru.tinkoff.oolong.dsl.* + +/** + * Compile a ES query. + * @param input + * Scala code describing the query. + */ +inline def query[Doc](inline input: Doc => Boolean): JsonNode = ${ queryImpl('input) } + +private[oolong] def queryImpl[Doc: Type](input: Expr[Doc => Boolean])(using quotes: Quotes): Expr[JsonNode] = { + import quotes.reflect.* + import ElasticQueryCompiler.* + + val parser = new DefaultAstParser + + val ast = parser.parseQExpr(input) + val optimizedAst = LogicalOptimizer.optimize(ast) + + val optRepr = opt(optimizedAst) + val optimized = optimize(optRepr) + + report.info("Optimized AST:\n" + pprint(optimizedAst) + "\nGenerated query:\n" + render(optimized)) + + '{ JsonNode.obj("query" -> ${ target(optimized) }) } +} diff --git a/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala b/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala new file mode 100644 index 0000000..1000b10 --- /dev/null +++ b/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala @@ -0,0 +1,14 @@ +package ru.tinkoff.oolong.elasticsearch + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers.shouldBe + +import ru.tinkoff.oolong.dsl.* + +class QuerySpec extends AnyFunSuite { + test("Term query") { + val q = query[TestClass](_.field2 == 2) + + q.render shouldBe """{"query":{"term":{"field2":2}}}""" + } +} diff --git a/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/TestDomain.scala b/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/TestDomain.scala new file mode 100644 index 0000000..bc5a477 --- /dev/null +++ b/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/TestDomain.scala @@ -0,0 +1,12 @@ +package ru.tinkoff.oolong.elasticsearch + +case class TestClass( + field1: String, + field2: Int, + field3: InnerClass, + field4: List[Int], +) + +case class InnerClass( + innerField: String +) From 696fa7d711d6e3580540ea7f6bb191b0f50d6b27 Mon Sep 17 00:00:00 2001 From: Daniel Slapman Date: Mon, 4 Jul 2022 12:06:28 +0300 Subject: [PATCH 05/10] Test boolean queries --- .../elasticsearch/ElasticQueryCompiler.scala | 14 ++++++++++---- .../tinkoff/oolong/elasticsearch/QuerySpec.scala | 12 ++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala index 1257206..194116f 100644 --- a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala +++ b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala @@ -50,15 +50,21 @@ object ElasticQueryCompiler extends Backend[QExpr, ElasticQueryNode, JsonNode] { case and: EQN.And => '{ JsonNode.obj( - "must" -> - JsonNode.Arr(${ Expr.ofSeq(and.exprs.map(target)) }) + "bool" -> + JsonNode.obj( + "must" -> + JsonNode.Arr(${ Expr.ofSeq(and.exprs.map(target)) }) + ) ) } case or: EQN.Or => '{ JsonNode.obj( - "should" -> - JsonNode.Arr(${ Expr.ofSeq(or.exprs.map(target)) }) + "bool" -> + JsonNode.obj( + "should" -> + JsonNode.Arr(${ Expr.ofSeq(or.exprs.map(target)) }) + ) ) } case EQN.Term(EQN.Field(path), x) => diff --git a/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala b/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala index 1000b10..a192a28 100644 --- a/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala +++ b/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala @@ -11,4 +11,16 @@ class QuerySpec extends AnyFunSuite { q.render shouldBe """{"query":{"term":{"field2":2}}}""" } + + test("$$ query") { + val q = query[TestClass](c => c.field1 == "check" && c.field2 == 42) + + q.render shouldBe """{"query":{"bool":{"must":[{"term":{"field1":"check"}},{"term":{"field2":42}}]}}}""" + } + + test("|| query") { + val q = query[TestClass](c => c.field1 == "check" || c.field2 == 42) + + q.render shouldBe """{"query":{"bool":{"should":[{"term":{"field1":"check"}},{"term":{"field2":42}}]}}}""" + } } From db024c2eafc5a34147b02b6959dae23df8af92a2 Mon Sep 17 00:00:00 2001 From: Daniel Slapman Date: Mon, 4 Jul 2022 15:29:35 +0300 Subject: [PATCH 06/10] Introduce ElasticQueryNode.Bool --- .../elasticsearch/ElasticQueryCompiler.scala | 32 +++++++------------ .../elasticsearch/ElasticQueryNode.scala | 7 ++-- .../oolong/elasticsearch/QuerySpec.scala | 4 +-- 3 files changed, 19 insertions(+), 24 deletions(-) diff --git a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala index 194116f..21dd1f0 100644 --- a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala +++ b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala @@ -15,8 +15,8 @@ object ElasticQueryCompiler extends Backend[QExpr, ElasticQueryNode, JsonNode] { ast match { case QExpr.Prop(path) => EQN.Field(path) case QExpr.Eq(x, QExpr.Constant(s)) => EQN.Term(getField(x), EQN.Constant(s)) - case QExpr.And(exprs) => EQN.And(exprs map opt) - case QExpr.Or(exprs) => EQN.Or(exprs map opt) + case QExpr.And(exprs) => EQN.Bool(must = exprs map opt) + case QExpr.Or(exprs) => EQN.Bool(should = exprs map opt) case unhandled => report.errorAndAbort("Unprocessable") } } @@ -33,8 +33,10 @@ object ElasticQueryCompiler extends Backend[QExpr, ElasticQueryNode, JsonNode] { node match { case EQN.Term(EQN.Field(path), x) => "{ \"term\": {" + "\"" + path.mkString(".") + "\"" + ": " + render(x) + " } }" - case EQN.And(exprs) => "{ \"must\": [ " + exprs.map(render).mkString(", ") + " ] }" - case EQN.Or(exprs) => "{ \"should\": [ " + exprs.map(render).mkString(", ") + " ] }" + case EQN.Bool(must, should, mustNot) => + s"""{"must": [${must.map(render).mkString(", ")}], "should": [${should + .map(render) + .mkString(", ")}], "must_not": [${mustNot.map(render).mkString(", ")}]}""" case EQN.Constant(s: String) => "\"" + s + "\"" case EQN.Constant(s: Any) => s.toString case EQN.Field(field) => @@ -47,24 +49,14 @@ object ElasticQueryCompiler extends Backend[QExpr, ElasticQueryNode, JsonNode] { import quotes.reflect.* optRepr match { - case and: EQN.And => + case bool: EQN.Bool => '{ JsonNode.obj( - "bool" -> - JsonNode.obj( - "must" -> - JsonNode.Arr(${ Expr.ofSeq(and.exprs.map(target)) }) - ) - ) - } - case or: EQN.Or => - '{ - JsonNode.obj( - "bool" -> - JsonNode.obj( - "should" -> - JsonNode.Arr(${ Expr.ofSeq(or.exprs.map(target)) }) - ) + "bool" -> JsonNode.obj( + "must" -> JsonNode.Arr(${ Expr.ofSeq(bool.must.map(target)) }), + "should" -> JsonNode.Arr(${ Expr.ofSeq(bool.should.map(target)) }), + "must_not" -> JsonNode.Arr(${ Expr.ofSeq(bool.mustNot.map(target)) }), + ) ) } case EQN.Term(EQN.Field(path), x) => diff --git a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryNode.scala b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryNode.scala index 62e6251..4a29a41 100644 --- a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryNode.scala +++ b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryNode.scala @@ -7,7 +7,10 @@ object ElasticQueryNode { case class Term(field: Field, expr: ElasticQueryNode) extends ElasticQueryNode - case class And(exprs: List[ElasticQueryNode]) extends ElasticQueryNode - case class Or(exprs: List[ElasticQueryNode]) extends ElasticQueryNode + case class Bool( + must: List[ElasticQueryNode] = Nil, + should: List[ElasticQueryNode] = Nil, + mustNot: List[ElasticQueryNode] = Nil + ) extends ElasticQueryNode case class Constant[T](s: T) extends ElasticQueryNode } diff --git a/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala b/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala index a192a28..b6812ce 100644 --- a/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala +++ b/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala @@ -15,12 +15,12 @@ class QuerySpec extends AnyFunSuite { test("$$ query") { val q = query[TestClass](c => c.field1 == "check" && c.field2 == 42) - q.render shouldBe """{"query":{"bool":{"must":[{"term":{"field1":"check"}},{"term":{"field2":42}}]}}}""" + q.render shouldBe """{"query":{"bool":{"must":[{"term":{"field1":"check"}},{"term":{"field2":42}}],"should":[],"must_not":[]}}}""" } test("|| query") { val q = query[TestClass](c => c.field1 == "check" || c.field2 == 42) - q.render shouldBe """{"query":{"bool":{"should":[{"term":{"field1":"check"}},{"term":{"field2":42}}]}}}""" + q.render shouldBe """{"query":{"bool":{"must":[],"should":[{"term":{"field1":"check"}},{"term":{"field2":42}}],"must_not":[]}}}""" } } From b2163647080d91b97b20ff9fb45f6b5d5d6ed659 Mon Sep 17 00:00:00 2001 From: Daniel Slapman Date: Mon, 4 Jul 2022 16:34:10 +0300 Subject: [PATCH 07/10] Boolean query optimization --- .../main/scala/ru/tinkoff/oolong/Utils.scala | 4 +++ .../elasticsearch/ElasticQueryCompiler.scala | 35 +++++++++++++++++++ .../elasticsearch/ElasticQueryNode.scala | 25 ++++++++++++- .../oolong/elasticsearch/QuerySpec.scala | 12 +++++++ 4 files changed, 75 insertions(+), 1 deletion(-) diff --git a/oolong-core/src/main/scala/ru/tinkoff/oolong/Utils.scala b/oolong-core/src/main/scala/ru/tinkoff/oolong/Utils.scala index 4fc90e8..9894b9b 100644 --- a/oolong-core/src/main/scala/ru/tinkoff/oolong/Utils.scala +++ b/oolong-core/src/main/scala/ru/tinkoff/oolong/Utils.scala @@ -179,3 +179,7 @@ private[oolong] object Utils: def unapply(expr: Expr[Pattern])(using q: Quotes): Option[Pattern] = import q.reflect.* AsRegexPattern.unapply(expr) + + extension[A] (sq: Seq[A]) { + def pforall(pf: PartialFunction[A, Boolean]): Boolean = sq.forall(pf.applyOrElse(_, _ => false)) + } \ No newline at end of file diff --git a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala index 21dd1f0..e47fab2 100644 --- a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala +++ b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala @@ -1,11 +1,13 @@ package ru.tinkoff.oolong.elasticsearch +import scala.annotation.nowarn import scala.quoted.Expr import scala.quoted.Quotes import org.bson.json.JsonMode import ru.tinkoff.oolong.* +import ru.tinkoff.oolong.Utils.* import ru.tinkoff.oolong.elasticsearch.ElasticQueryNode as EQN object ElasticQueryCompiler extends Backend[QExpr, ElasticQueryNode, JsonNode] { @@ -15,8 +17,10 @@ object ElasticQueryCompiler extends Backend[QExpr, ElasticQueryNode, JsonNode] { ast match { case QExpr.Prop(path) => EQN.Field(path) case QExpr.Eq(x, QExpr.Constant(s)) => EQN.Term(getField(x), EQN.Constant(s)) + case QExpr.Ne(x, QExpr.Constant(s)) => EQN.Bool(mustNot = EQN.Term(getField(x), EQN.Constant(s)) :: Nil) case QExpr.And(exprs) => EQN.Bool(must = exprs map opt) case QExpr.Or(exprs) => EQN.Bool(should = exprs map opt) + case QExpr.Not(expr) => EQN.Bool(mustNot = opt(expr) :: Nil) case unhandled => report.errorAndAbort("Unprocessable") } } @@ -87,4 +91,35 @@ object ElasticQueryCompiler extends Backend[QExpr, ElasticQueryNode, JsonNode] { '{ JsonNode.Bool.apply(${ Expr(i: Boolean) }) } case _ => report.errorAndAbort(s"Given type is not literal constant") } + + override def optimize(query: ElasticQueryNode): ElasticQueryNode = query match { + case EQN.Bool(must, should, mustNot) => + val mustBuilder = List.newBuilder[ElasticQueryNode] + val shouldBuilder = List.newBuilder[ElasticQueryNode].addAll(should.map(optimize)) + val mustNotBuilder = List.newBuilder[ElasticQueryNode].addAll(mustNot.map(optimize)) + + lazy val orCount = must.count { + case EQN.Bool.Or(_) => true + case _ => false + } + + for (mp <- must.map(optimize)) mp match { + case EQN.Bool.And(must2) => + must2.foreach(mustBuilder += _) + case EQN.Bool.Or(should2) if should.isEmpty && orCount == 1 => + should2.foreach(shouldBuilder += _) + // !(a || b || c) => !a && !b && !c + case EQN.Bool.Or(should2) if should2.pforall { case EQN.Bool.Not(_) => true } => + should2.foreach { case EQN.Bool.Not(p) => + mustNotBuilder += p + }: @nowarn + case EQN.Bool.Not(not) => + mustNotBuilder += not + case other => mustBuilder += other + } + + EQN.Bool(mustBuilder.result, shouldBuilder.result, mustNotBuilder.result) + + case other => other + } } diff --git a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryNode.scala b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryNode.scala index 4a29a41..3b92ccf 100644 --- a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryNode.scala +++ b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryNode.scala @@ -6,11 +6,34 @@ object ElasticQueryNode { case class Field(path: List[String]) extends ElasticQueryNode case class Term(field: Field, expr: ElasticQueryNode) extends ElasticQueryNode + case class Constant[T](s: T) extends ElasticQueryNode case class Bool( must: List[ElasticQueryNode] = Nil, should: List[ElasticQueryNode] = Nil, mustNot: List[ElasticQueryNode] = Nil ) extends ElasticQueryNode - case class Constant[T](s: T) extends ElasticQueryNode + + object Bool { + object And { + def unapply(bool: Bool): Option[List[ElasticQueryNode]] = bool match { + case Bool(and, Nil, Nil) => Some(and) + case _ => None + } + } + + object Or { + def unapply(bool: Bool): Option[List[ElasticQueryNode]] = bool match { + case Bool(Nil, or, Nil) => Some(or) + case _ => None + } + } + + object Not { + def unapply(bool: Bool): Option[ElasticQueryNode] = bool match { + case Bool(Nil, Nil, List(not)) => Some(not) + case _ => None + } + } + } } diff --git a/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala b/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala index b6812ce..c79cad0 100644 --- a/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala +++ b/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala @@ -23,4 +23,16 @@ class QuerySpec extends AnyFunSuite { q.render shouldBe """{"query":{"bool":{"must":[],"should":[{"term":{"field1":"check"}},{"term":{"field2":42}}],"must_not":[]}}}""" } + + test("!= query") { + val q = query[TestClass](_.field2 != 2) + + q.render shouldBe """{"query":{"bool":{"must":[],"should":[],"must_not":[{"term":{"field2":2}}]}}}""" + } + + test("Composite boolean query") { + val q = query[TestClass](c => c.field1 == "check" && (c.field2 == 42 || c.field3.innerField == "inner")) + + q.render shouldBe """{"query":{"bool":{"must":[{"term":{"field1":"check"}}],"should":[{"term":{"field2":42}},{"term":{"field3.innerField":"inner"}}],"must_not":[]}}}""" + } } From f3d27521e4c716853c239e9a964fab050b233d44 Mon Sep 17 00:00:00 2001 From: Daniel Slapman Date: Wed, 6 Jul 2022 18:44:37 +0300 Subject: [PATCH 08/10] Exists query --- .../elasticsearch/ElasticQueryCompiler.scala | 27 ++++++++++++------- .../elasticsearch/ElasticQueryNode.scala | 2 ++ .../oolong/elasticsearch/QuerySpec.scala | 12 +++++++++ .../oolong/elasticsearch/TestDomain.scala | 5 ++-- 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala index e47fab2..1b0b74d 100644 --- a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala +++ b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala @@ -15,13 +15,17 @@ object ElasticQueryCompiler extends Backend[QExpr, ElasticQueryNode, JsonNode] { import quotes.reflect.* ast match { - case QExpr.Prop(path) => EQN.Field(path) - case QExpr.Eq(x, QExpr.Constant(s)) => EQN.Term(getField(x), EQN.Constant(s)) - case QExpr.Ne(x, QExpr.Constant(s)) => EQN.Bool(mustNot = EQN.Term(getField(x), EQN.Constant(s)) :: Nil) - case QExpr.And(exprs) => EQN.Bool(must = exprs map opt) - case QExpr.Or(exprs) => EQN.Bool(should = exprs map opt) - case QExpr.Not(expr) => EQN.Bool(mustNot = opt(expr) :: Nil) - case unhandled => report.errorAndAbort("Unprocessable") + case QExpr.Prop(path) => EQN.Field(path) + case QExpr.Eq(QExpr.Prop(path), QExpr.Constant(s)) => EQN.Term(EQN.Field(path), EQN.Constant(s)) + case QExpr.Ne(QExpr.Prop(path), QExpr.Constant(s)) => + EQN.Bool(mustNot = EQN.Term(EQN.Field(path), EQN.Constant(s)) :: Nil) + case QExpr.And(exprs) => EQN.Bool(must = exprs map opt) + case QExpr.Or(exprs) => EQN.Bool(should = exprs map opt) + case QExpr.Not(expr) => EQN.Bool(mustNot = opt(expr) :: Nil) + case QExpr.Exists(QExpr.Prop(path), QExpr.Constant(true)) => EQN.Exists(EQN.Field(path)) + case QExpr.Exists(QExpr.Prop(path), QExpr.Constant(false)) => + EQN.Bool(mustNot = EQN.Exists(EQN.Field(path)) :: Nil) + case unhandled => report.errorAndAbort("Unprocessable") } } @@ -36,16 +40,19 @@ object ElasticQueryCompiler extends Backend[QExpr, ElasticQueryNode, JsonNode] { node match { case EQN.Term(EQN.Field(path), x) => - "{ \"term\": {" + "\"" + path.mkString(".") + "\"" + ": " + render(x) + " } }" + s"""{ "term": {"${path.mkString(".")}": ${render(x)} } }""" case EQN.Bool(must, should, mustNot) => s"""{"must": [${must.map(render).mkString(", ")}], "should": [${should .map(render) .mkString(", ")}], "must_not": [${mustNot.map(render).mkString(", ")}]}""" case EQN.Constant(s: String) => "\"" + s + "\"" case EQN.Constant(s: Any) => s.toString - case EQN.Field(field) => + case EQN.Exists(EQN.Field(path)) => + s"""{ "exists": { "field": "${path.mkString(".")}" }}""" + case EQN.Field(field) => // TODO: adjust error message report.errorAndAbort(s"There is no filter condition on field ${field.mkString(".")}") + case _ => "AST can't be rendered" } } @@ -65,6 +72,8 @@ object ElasticQueryCompiler extends Backend[QExpr, ElasticQueryNode, JsonNode] { } case EQN.Term(EQN.Field(path), x) => '{ JsonNode.obj("term" -> JsonNode.obj(${ Expr(path.mkString(".")) } -> ${ handleValues(x) })) } + case EQN.Exists(EQN.Field(path)) => + '{ JsonNode.obj("exists" -> JsonNode.obj("field" -> JsonNode.Str(${ Expr(path.mkString(".")) }))) } case _ => report.errorAndAbort("given node can't be in that position") } } diff --git a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryNode.scala b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryNode.scala index 3b92ccf..0cd35d6 100644 --- a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryNode.scala +++ b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryNode.scala @@ -8,6 +8,8 @@ object ElasticQueryNode { case class Term(field: Field, expr: ElasticQueryNode) extends ElasticQueryNode case class Constant[T](s: T) extends ElasticQueryNode + case class Exists(x: ElasticQueryNode) extends ElasticQueryNode + case class Bool( must: List[ElasticQueryNode] = Nil, should: List[ElasticQueryNode] = Nil, diff --git a/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala b/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala index c79cad0..05dc8c9 100644 --- a/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala +++ b/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala @@ -35,4 +35,16 @@ class QuerySpec extends AnyFunSuite { q.render shouldBe """{"query":{"bool":{"must":[{"term":{"field1":"check"}}],"should":[{"term":{"field2":42}},{"term":{"field3.innerField":"inner"}}],"must_not":[]}}}""" } + + test(".isDefined query") { + val q = query[TestClass](_.field3.optionalInnerField.isDefined) + + q.render shouldBe """{"query":{"exists":{"field":"field3.optionalInnerField"}}}""" + } + + test(".isEmpty query") { + val q = query[TestClass](_.field3.optionalInnerField.isEmpty) + + q.render shouldBe """{"query":{"bool":{"must":[],"should":[],"must_not":[{"exists":{"field":"field3.optionalInnerField"}}]}}}""" + } } diff --git a/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/TestDomain.scala b/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/TestDomain.scala index bc5a477..72552b4 100644 --- a/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/TestDomain.scala +++ b/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/TestDomain.scala @@ -4,9 +4,10 @@ case class TestClass( field1: String, field2: Int, field3: InnerClass, - field4: List[Int], + field4: List[Int] ) case class InnerClass( - innerField: String + innerField: String, + optionalInnerField: Option[Int] ) From 2c6f24417447f658e9a93c141d5679a2c76b03e2 Mon Sep 17 00:00:00 2001 From: Daniel Slapman Date: Wed, 6 Jul 2022 20:48:09 +0300 Subject: [PATCH 09/10] Minimal range query support --- .../elasticsearch/ElasticQueryCompiler.scala | 34 +++++++++++++++++-- .../elasticsearch/ElasticQueryNode.scala | 8 +++++ .../oolong/elasticsearch/QuerySpec.scala | 24 +++++++++++++ .../scala/ru/tinkoff/oolong/JsonNode.scala | 2 ++ 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala index 1b0b74d..5fb0fd5 100644 --- a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala +++ b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryCompiler.scala @@ -19,9 +19,13 @@ object ElasticQueryCompiler extends Backend[QExpr, ElasticQueryNode, JsonNode] { case QExpr.Eq(QExpr.Prop(path), QExpr.Constant(s)) => EQN.Term(EQN.Field(path), EQN.Constant(s)) case QExpr.Ne(QExpr.Prop(path), QExpr.Constant(s)) => EQN.Bool(mustNot = EQN.Term(EQN.Field(path), EQN.Constant(s)) :: Nil) - case QExpr.And(exprs) => EQN.Bool(must = exprs map opt) - case QExpr.Or(exprs) => EQN.Bool(should = exprs map opt) - case QExpr.Not(expr) => EQN.Bool(mustNot = opt(expr) :: Nil) + case QExpr.Gte(QExpr.Prop(path), QExpr.Constant(s)) => EQN.Range(EQN.Field(path), gte = Some(EQN.Constant(s))) + case QExpr.Lte(QExpr.Prop(path), QExpr.Constant(s)) => EQN.Range(EQN.Field(path), lte = Some(EQN.Constant(s))) + case QExpr.Gt(QExpr.Prop(path), QExpr.Constant(s)) => EQN.Range(EQN.Field(path), gt = Some(EQN.Constant(s))) + case QExpr.Lt(QExpr.Prop(path), QExpr.Constant(s)) => EQN.Range(EQN.Field(path), lt = Some(EQN.Constant(s))) + case QExpr.And(exprs) => EQN.Bool(must = exprs map opt) + case QExpr.Or(exprs) => EQN.Bool(should = exprs map opt) + case QExpr.Not(expr) => EQN.Bool(mustNot = opt(expr) :: Nil) case QExpr.Exists(QExpr.Prop(path), QExpr.Constant(true)) => EQN.Exists(EQN.Field(path)) case QExpr.Exists(QExpr.Prop(path), QExpr.Constant(false)) => EQN.Bool(mustNot = EQN.Exists(EQN.Field(path)) :: Nil) @@ -49,6 +53,14 @@ object ElasticQueryCompiler extends Backend[QExpr, ElasticQueryNode, JsonNode] { case EQN.Constant(s: Any) => s.toString case EQN.Exists(EQN.Field(path)) => s"""{ "exists": { "field": "${path.mkString(".")}" }}""" + case EQN.Range(EQN.Field(path), gt, gte, lt, lte) => + val bounds = Seq( + renderKeyMap(""""gt"""", gt), + renderKeyMap(""""gte"""", gte), + renderKeyMap(""""lt"""", lt), + renderKeyMap(""""lte"""", lte) + ).flatten + s"""{"range": {"${path.mkString(".")}": {${bounds.mkString(",")}}}}""" case EQN.Field(field) => // TODO: adjust error message report.errorAndAbort(s"There is no filter condition on field ${field.mkString(".")}") @@ -56,6 +68,9 @@ object ElasticQueryCompiler extends Backend[QExpr, ElasticQueryNode, JsonNode] { } } + private def renderKeyMap(key: String, node: Option[ElasticQueryNode])(using quotes: Quotes): Option[String] = + node.map(render(_)).map(v => s"""$key:$v""") + override def target(optRepr: ElasticQueryNode)(using quotes: Quotes): Expr[JsonNode] = { import quotes.reflect.* @@ -74,6 +89,19 @@ object ElasticQueryCompiler extends Backend[QExpr, ElasticQueryNode, JsonNode] { '{ JsonNode.obj("term" -> JsonNode.obj(${ Expr(path.mkString(".")) } -> ${ handleValues(x) })) } case EQN.Exists(EQN.Field(path)) => '{ JsonNode.obj("exists" -> JsonNode.obj("field" -> JsonNode.Str(${ Expr(path.mkString(".")) }))) } + case EQN.Range(EQN.Field(path), gt, gte, lt, lte) => + '{ + JsonNode.obj( + "range" -> JsonNode.obj( + ${ Expr(path.mkString(".")) } -> JsonNode.obj( + "gt" -> ${ gt.map(handleValues(_)).getOrElse('{ JsonNode.`null` }) }, + "gte" -> ${ gte.map(handleValues(_)).getOrElse('{ JsonNode.`null` }) }, + "lt" -> ${ lt.map(handleValues(_)).getOrElse('{ JsonNode.`null` }) }, + "lte" -> ${ lte.map(handleValues(_)).getOrElse('{ JsonNode.`null` }) } + ) + ) + ) + } case _ => report.errorAndAbort("given node can't be in that position") } } diff --git a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryNode.scala b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryNode.scala index 0cd35d6..b825ab5 100644 --- a/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryNode.scala +++ b/oolong-elasticsearch/src/main/scala/ru/tinkoff/oolong/elasticsearch/ElasticQueryNode.scala @@ -38,4 +38,12 @@ object ElasticQueryNode { } } } + + case class Range( + field: Field, + gt: Option[ElasticQueryNode] = None, + gte: Option[ElasticQueryNode] = None, + lt: Option[ElasticQueryNode] = None, + lte: Option[ElasticQueryNode] = None + ) extends ElasticQueryNode } diff --git a/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala b/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala index 05dc8c9..58c7971 100644 --- a/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala +++ b/oolong-elasticsearch/src/test/scala/ru/tinkoff/oolong/elasticsearch/QuerySpec.scala @@ -47,4 +47,28 @@ class QuerySpec extends AnyFunSuite { q.render shouldBe """{"query":{"bool":{"must":[],"should":[],"must_not":[{"exists":{"field":"field3.optionalInnerField"}}]}}}""" } + + test("> query") { + val q = query[TestClass](_.field2 > 4) + + q.render shouldBe """{"query":{"range":{"field2":{"gt":4,"gte":null,"lt":null,"lte":null}}}}""" + } + + test(">= query") { + val q = query[TestClass](_.field2 >= 4) + + q.render shouldBe """{"query":{"range":{"field2":{"gt":null,"gte":4,"lt":null,"lte":null}}}}""" + } + + test("< query") { + val q = query[TestClass](_.field2 < 4) + + q.render shouldBe """{"query":{"range":{"field2":{"gt":null,"gte":null,"lt":4,"lte":null}}}}""" + } + + test("<= query") { + val q = query[TestClass](_.field2 <= 4) + + q.render shouldBe """{"query":{"range":{"field2":{"gt":null,"gte":null,"lt":null,"lte":4}}}}""" + } } diff --git a/oolong-json/src/main/scala/ru/tinkoff/oolong/JsonNode.scala b/oolong-json/src/main/scala/ru/tinkoff/oolong/JsonNode.scala index a8af17d..d9d45ed 100644 --- a/oolong-json/src/main/scala/ru/tinkoff/oolong/JsonNode.scala +++ b/oolong-json/src/main/scala/ru/tinkoff/oolong/JsonNode.scala @@ -28,6 +28,8 @@ private[oolong] object JsonNode { override def render: String = value.map((k, v) => s"\"$k\":${v.render}").mkString("{", ",", "}") } + val `null`: JsonNode = Null + def obj(head: (String, JsonNode), tail: (String, JsonNode)*): Obj = Obj((head +: tail).to(Map)) } From 88255932f4a483ddda08f8e6df408bf57c365769 Mon Sep 17 00:00:00 2001 From: Daniel Slapman Date: Wed, 21 Sep 2022 17:57:24 +0300 Subject: [PATCH 10/10] Fix formatting --- oolong-core/src/main/scala/ru/tinkoff/oolong/Utils.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/oolong-core/src/main/scala/ru/tinkoff/oolong/Utils.scala b/oolong-core/src/main/scala/ru/tinkoff/oolong/Utils.scala index 9894b9b..d86aedf 100644 --- a/oolong-core/src/main/scala/ru/tinkoff/oolong/Utils.scala +++ b/oolong-core/src/main/scala/ru/tinkoff/oolong/Utils.scala @@ -180,6 +180,6 @@ private[oolong] object Utils: import q.reflect.* AsRegexPattern.unapply(expr) - extension[A] (sq: Seq[A]) { + extension [A](sq: Seq[A]) { def pforall(pf: PartialFunction[A, Boolean]): Boolean = sq.forall(pf.applyOrElse(_, _ => false)) - } \ No newline at end of file + }