From 893f66a6b1ed8663cdc57323c85903824904e2f3 Mon Sep 17 00:00:00 2001 From: Marko Krstic Date: Thu, 19 Jun 2025 13:39:41 +0200 Subject: [PATCH 1/4] Add geoBoundingBoxQuery --- .../zio/elasticsearch/ElasticQuery.scala | 42 ++++++++ .../zio/elasticsearch/query/Queries.scala | 97 +++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/modules/library/src/main/scala/zio/elasticsearch/ElasticQuery.scala b/modules/library/src/main/scala/zio/elasticsearch/ElasticQuery.scala index 7b1472988..e7b236a18 100644 --- a/modules/library/src/main/scala/zio/elasticsearch/ElasticQuery.scala +++ b/modules/library/src/main/scala/zio/elasticsearch/ElasticQuery.scala @@ -307,6 +307,48 @@ object ElasticQuery { final def fuzzy(field: String, value: String): FuzzyQuery[Any] = Fuzzy(field = field, value = value, fuzziness = None, maxExpansions = None, prefixLength = None) + /** + * Constructs a type-safe instance of [[zio.elasticsearch.query.GeoBoundingBoxQuery]] using the specified parameters. + * + * @param field + * the type-safe GeoPoint field for which the bounding box query is specified + * @param topLeft + * the geo-point representing the top-left corner of the bounding box + * @param bottomRight + * the geo-point representing the bottom-right corner of the bounding box + * @tparam S + * the type of document on which the query is defined + * @return + * an instance of [[zio.elasticsearch.query.GeoBoundingBoxQuery]] that represents the `geo_bounding_box` query to be + * performed. + */ + final def geoBoundingBoxQuery[S]( + field: Field[S, GeoPoint], + topLeft: GeoPoint, + bottomRight: GeoPoint + ): GeoBoundingBoxQuery[S] = + GeoBoundingBox(field.toString, topLeft, bottomRight) + + /** + * Constructs an instance of [[zio.elasticsearch.query.GeoBoundingBoxQuery]] using the specified parameters. + * + * @param field + * the name of the GeoPoint field for which the bounding box query is specified + * @param topLeft + * the geo-point representing the top-left corner of the bounding box + * @param bottomRight + * the geo-point representing the bottom-right corner of the bounding box + * @return + * an instance of [[zio.elasticsearch.query.GeoBoundingBoxQuery]] that represents the `geo_bounding_box` query to be + * performed. + */ + final def geoBoundingBoxQuery[S]( + field: String, + topLeft: GeoPoint, + bottomRight: GeoPoint + ): GeoBoundingBoxQuery[S] = + GeoBoundingBox(field, topLeft, bottomRight) + /** * Constructs a type-safe instance of [[zio.elasticsearch.query.GeoDistanceQuery]] using the specified parameters. * diff --git a/modules/library/src/main/scala/zio/elasticsearch/query/Queries.scala b/modules/library/src/main/scala/zio/elasticsearch/query/Queries.scala index ca62ebdb2..ef385c7c4 100644 --- a/modules/library/src/main/scala/zio/elasticsearch/query/Queries.scala +++ b/modules/library/src/main/scala/zio/elasticsearch/query/Queries.scala @@ -19,6 +19,7 @@ package zio.elasticsearch.query import zio.Chunk import zio.elasticsearch.ElasticPrimitive._ import zio.elasticsearch.Field +import zio.elasticsearch.data.GeoPoint import zio.elasticsearch.query.options._ import zio.elasticsearch.query.sort.options.HasFormat import zio.json.ast.Json @@ -497,6 +498,102 @@ private[elasticsearch] final case class Fuzzy[S]( } } +sealed trait GeoBoundingBoxQuery[S] extends ElasticQuery[S] { + + /** + * Sets the `boost` parameter for the [[zio.elasticsearch.query.GeoBoundingBoxQuery]]. Boosts the relevance score of + * the query. + * + * @param value + * the boost factor as a [[Double]]. Values greater than 1.0 increase relevance, values between 0 and 1.0 decrease + * it. + * @return + * an instance of [[zio.elasticsearch.query.GeoBoundingBoxQuery]] enriched with the `boost` parameter. + */ + def boost(value: Double): GeoBoundingBoxQuery[S] + + /** + * Sets the `ignoreUnmapped` parameter for the [[zio.elasticsearch.query.GeoBoundingBoxQuery]]. Determines how to + * handle unmapped fields. + * + * @param value + * - true: unmapped fields are ignored and the query returns no matches for them + * - false: query will throw an exception if the field is unmapped + * @return + * an instance of [[zio.elasticsearch.query.GeoBoundingBoxQuery]] enriched with the `ignoreUnmapped` parameter. + */ + def ignoreUnmapped(value: Boolean): GeoBoundingBoxQuery[S] + + /** + * Sets the `queryName` parameter for the [[zio.elasticsearch.query.GeoBoundingBoxQuery]]. Represents the optional + * name used to identify the query. + * + * @param value + * the [[String]] name used to tag and identify this query in responses + * @return + * an instance of [[zio.elasticsearch.query.GeoBoundingBoxQuery]] enriched with the `queryName` parameter. + */ + def name(value: String): GeoBoundingBoxQuery[S] + + /** + * Sets the `validationMethod` parameter for the [[zio.elasticsearch.query.GeoBoundingBoxQuery]]. Defines handling of + * incorrect coordinates. + * + * @param value + * defines how to handle invalid latitude and longitude: + * - [[zio.elasticsearch.query.ValidationMethod.Strict]]: Default method + * - [[zio.elasticsearch.query.ValidationMethod.IgnoreMalformed]]: Accepts geo points with invalid latitude or + * longitude + * - [[zio.elasticsearch.query.ValidationMethod.Coerce]]: Additionally try and infer correct coordinates + * @return + * an instance of [[zio.elasticsearch.query.GeoDistanceQuery]] enriched with the `validationMethod` parameter. + */ + def validationMethod(value: ValidationMethod): GeoBoundingBoxQuery[S] +} + +private[elasticsearch] final case class GeoBoundingBox[S]( + field: String, + topLeft: GeoPoint, + bottomRight: GeoPoint, + boost: Option[Double] = None, + ignoreUnmapped: Option[Boolean] = None, + queryName: Option[String] = None, + validationMethod: Option[ValidationMethod] = None +) extends GeoBoundingBoxQuery[S] { self => + + def boost(value: Double): GeoBoundingBoxQuery[S] = + self.copy(boost = Some(value)) + + def ignoreUnmapped(value: Boolean): GeoBoundingBoxQuery[S] = + self.copy(ignoreUnmapped = Some(value)) + + def name(value: String): GeoBoundingBoxQuery[S] = + self.copy(queryName = Some(value)) + + def validationMethod(value: ValidationMethod): GeoBoundingBoxQuery[S] = + self.copy(validationMethod = Some(value)) + + override def toJson(fieldPath: Option[String]): Json = + Obj( + "geo_bounding_box" -> Obj( + Chunk( + Some( + field -> Obj( + Chunk( + Some("top_left" -> topLeft.toString.toJson), + Some("bottom_right" -> bottomRight.toString.toJson) + ).flatten: _* + ) + ), + boost.map("boost" -> Json.Num(_)), + ignoreUnmapped.map("ignore_unmapped" -> Json.Bool(_)), + queryName.map("_name" -> _.toJson), + validationMethod.map("validation_method" -> _.toString.toJson) + ).flatten: _* + ) + ) +} + sealed trait GeoDistanceQuery[S] extends ElasticQuery[S] { /** From a4819e219e90f970d2a061574a84da95d110bf63 Mon Sep 17 00:00:00 2001 From: Marko Krstic Date: Thu, 19 Jun 2025 15:30:40 +0200 Subject: [PATCH 2/4] Add unit test and name params. --- .../zio/elasticsearch/ElasticQuery.scala | 24 +++- .../zio/elasticsearch/query/Queries.scala | 10 +- .../zio/elasticsearch/ElasticQuerySpec.scala | 109 ++++++++++++++++++ 3 files changed, 134 insertions(+), 9 deletions(-) diff --git a/modules/library/src/main/scala/zio/elasticsearch/ElasticQuery.scala b/modules/library/src/main/scala/zio/elasticsearch/ElasticQuery.scala index e7b236a18..1702e2391 100644 --- a/modules/library/src/main/scala/zio/elasticsearch/ElasticQuery.scala +++ b/modules/library/src/main/scala/zio/elasticsearch/ElasticQuery.scala @@ -327,7 +327,15 @@ object ElasticQuery { topLeft: GeoPoint, bottomRight: GeoPoint ): GeoBoundingBoxQuery[S] = - GeoBoundingBox(field.toString, topLeft, bottomRight) + GeoBoundingBox( + field = field.toString, + topLeft = topLeft, + bottomRight = bottomRight, + boost = None, + ignoreUnmapped = None, + queryName = None, + validationMethod = None + ) /** * Constructs an instance of [[zio.elasticsearch.query.GeoBoundingBoxQuery]] using the specified parameters. @@ -342,12 +350,20 @@ object ElasticQuery { * an instance of [[zio.elasticsearch.query.GeoBoundingBoxQuery]] that represents the `geo_bounding_box` query to be * performed. */ - final def geoBoundingBoxQuery[S]( + final def geoBoundingBoxQuery( field: String, topLeft: GeoPoint, bottomRight: GeoPoint - ): GeoBoundingBoxQuery[S] = - GeoBoundingBox(field, topLeft, bottomRight) + ): GeoBoundingBoxQuery[Any] = + GeoBoundingBox( + field = field, + topLeft = topLeft, + bottomRight = bottomRight, + boost = None, + ignoreUnmapped = None, + queryName = None, + validationMethod = None + ) /** * Constructs a type-safe instance of [[zio.elasticsearch.query.GeoDistanceQuery]] using the specified parameters. diff --git a/modules/library/src/main/scala/zio/elasticsearch/query/Queries.scala b/modules/library/src/main/scala/zio/elasticsearch/query/Queries.scala index ef385c7c4..fcaa184aa 100644 --- a/modules/library/src/main/scala/zio/elasticsearch/query/Queries.scala +++ b/modules/library/src/main/scala/zio/elasticsearch/query/Queries.scala @@ -522,7 +522,7 @@ sealed trait GeoBoundingBoxQuery[S] extends ElasticQuery[S] { * @return * an instance of [[zio.elasticsearch.query.GeoBoundingBoxQuery]] enriched with the `ignoreUnmapped` parameter. */ - def ignoreUnmapped(value: Boolean): GeoBoundingBoxQuery[S] + def ignoreUnmapped(value: Boolean = false): GeoBoundingBoxQuery[S] /** * Sets the `queryName` parameter for the [[zio.elasticsearch.query.GeoBoundingBoxQuery]]. Represents the optional @@ -564,7 +564,7 @@ private[elasticsearch] final case class GeoBoundingBox[S]( def boost(value: Double): GeoBoundingBoxQuery[S] = self.copy(boost = Some(value)) - def ignoreUnmapped(value: Boolean): GeoBoundingBoxQuery[S] = + def ignoreUnmapped(value: Boolean = false): GeoBoundingBoxQuery[S] = self.copy(ignoreUnmapped = Some(value)) def name(value: String): GeoBoundingBoxQuery[S] = @@ -573,7 +573,7 @@ private[elasticsearch] final case class GeoBoundingBox[S]( def validationMethod(value: ValidationMethod): GeoBoundingBoxQuery[S] = self.copy(validationMethod = Some(value)) - override def toJson(fieldPath: Option[String]): Json = + private[elasticsearch] override def toJson(fieldPath: Option[String]): Json = Obj( "geo_bounding_box" -> Obj( Chunk( @@ -585,8 +585,8 @@ private[elasticsearch] final case class GeoBoundingBox[S]( ).flatten: _* ) ), - boost.map("boost" -> Json.Num(_)), - ignoreUnmapped.map("ignore_unmapped" -> Json.Bool(_)), + boost.map("boost" -> _.toJson), + ignoreUnmapped.map("ignore_unmapped" -> _.toJson), queryName.map("_name" -> _.toJson), validationMethod.map("validation_method" -> _.toString.toJson) ).flatten: _* diff --git a/modules/library/src/test/scala/zio/elasticsearch/ElasticQuerySpec.scala b/modules/library/src/test/scala/zio/elasticsearch/ElasticQuerySpec.scala index da509a1e2..6b76db50b 100644 --- a/modules/library/src/test/scala/zio/elasticsearch/ElasticQuerySpec.scala +++ b/modules/library/src/test/scala/zio/elasticsearch/ElasticQuerySpec.scala @@ -725,6 +725,115 @@ object ElasticQuerySpec extends ZIOSpecDefault { ) ) }, + test("geoBoundingBox") { + val queryBasic = + geoBoundingBoxQuery(TestDocument.geoPointField, GeoPoint(40.73, -74.1), GeoPoint(40.01, -71.12)) + + val queryWithBoost = + geoBoundingBoxQuery(TestDocument.geoPointField, GeoPoint(40.73, -74.1), GeoPoint(40.01, -71.12)) + .boost(1.5) + + val queryWithIgnoreUnmapped = + geoBoundingBoxQuery( + TestDocument.geoPointField, + GeoPoint(40.73, -74.1), + GeoPoint(40.01, -71.12) + ).ignoreUnmapped() + + val queryWithName = + geoBoundingBoxQuery(TestDocument.geoPointField, GeoPoint(40.73, -74.1), GeoPoint(40.01, -71.12)) + .name("name") + + val queryWithValidationMethod = + geoBoundingBoxQuery(TestDocument.geoPointField, GeoPoint(40.73, -74.1), GeoPoint(40.01, -71.12)) + .validationMethod(IgnoreMalformed) + + val queryWithAllParams = + geoBoundingBoxQuery(TestDocument.geoPointField, GeoPoint(40.73, -74.1), GeoPoint(40.01, -71.12)) + .boost(1.5) + .ignoreUnmapped(true) + .name("name") + .validationMethod(IgnoreMalformed) + + assert(queryBasic)( + equalTo( + GeoBoundingBox[TestDocument]( + field = "geoPointField", + topLeft = GeoPoint(40.73, -74.1), + bottomRight = GeoPoint(40.01, -71.12), + boost = None, + ignoreUnmapped = None, + queryName = None, + validationMethod = None + ) + ) + ) && + assert(queryWithBoost)( + equalTo( + GeoBoundingBox[TestDocument]( + field = "geoPointField", + topLeft = GeoPoint(40.73, -74.1), + bottomRight = GeoPoint(40.01, -71.12), + boost = Some(1.5), + ignoreUnmapped = None, + queryName = None, + validationMethod = None + ) + ) + ) && + assert(queryWithIgnoreUnmapped)( + equalTo( + GeoBoundingBox[TestDocument]( + field = "geoPointField", + topLeft = GeoPoint(40.73, -74.1), + bottomRight = GeoPoint(40.01, -71.12), + boost = None, + ignoreUnmapped = Some(false), + queryName = None, + validationMethod = None + ) + ) + ) && + assert(queryWithName)( + equalTo( + GeoBoundingBox[TestDocument]( + field = "geoPointField", + topLeft = GeoPoint(40.73, -74.1), + bottomRight = GeoPoint(40.01, -71.12), + boost = None, + ignoreUnmapped = None, + queryName = Some("name"), + validationMethod = None + ) + ) + ) && + assert(queryWithValidationMethod)( + equalTo( + GeoBoundingBox[TestDocument]( + field = "geoPointField", + topLeft = GeoPoint(40.73, -74.1), + bottomRight = GeoPoint(40.01, -71.12), + boost = None, + ignoreUnmapped = None, + queryName = None, + validationMethod = Some(IgnoreMalformed) + ) + ) + ) && + assert(queryWithAllParams)( + equalTo( + GeoBoundingBox[TestDocument]( + field = "geoPointField", + topLeft = GeoPoint(40.73, -74.1), + bottomRight = GeoPoint(40.01, -71.12), + boost = Some(1.5), + ignoreUnmapped = Some(true), + queryName = Some("name"), + validationMethod = Some(IgnoreMalformed) + ) + ) + ) + }, test("geoDistance") { val queryWithHash = geoDistance(TestDocument.geoPointField, GeoHash("drm3btev3e86"), Distance(200, Kilometers)) From 3b6da2bcafe7bef2f658941d63837ec78a09c073 Mon Sep 17 00:00:00 2001 From: Marko Krstic Date: Thu, 19 Jun 2025 16:20:15 +0200 Subject: [PATCH 3/4] Add docs and test in HttpExecutorSpec. --- .gitattributes | 1 + .../queries/elastic_query_geo_bounding_box.md | 65 +++++++++++++++++++ .../zio/elasticsearch/HttpExecutorSpec.scala | 40 ++++++++++++ .../zio/elasticsearch/query/Queries.scala | 18 ++++- 4 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 docs/overview/queries/elastic_query_geo_bounding_box.md diff --git a/.gitattributes b/.gitattributes index 476390ebf..a45fa8234 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ +* text=auto eol=lf sbt linguist-vendored diff --git a/docs/overview/queries/elastic_query_geo_bounding_box.md b/docs/overview/queries/elastic_query_geo_bounding_box.md new file mode 100644 index 000000000..72c74953b --- /dev/null +++ b/docs/overview/queries/elastic_query_geo_bounding_box.md @@ -0,0 +1,65 @@ +--- +id: elastic_query_geo_bounding_box +title: "Geo-bounding-box Query" +--- + +The `GeoBoundingBox` query matches documents containing geo points that fall within a defined rectangular bounding box, specified by the `top-left` and `bottom-right` corners. + +To use the `GeoBoundingBox` query, import the following: +```scala +import zio.elasticsearch.query.GeoBoundingBoxQuery +import zio.elasticsearch.ElasticQuery._ +``` + +You can create a [type-safe](https://lambdaworks.github.io/zio-elasticsearch/overview/overview_zio_prelude_schema) `GeoBoundingBoxQuery` query using typed document fields like this: +```scala +val query: GeoBoundingBoxQuery[Document] = +geoBoundingBoxQuery( +field = Document.location, +topLeft = GeoPoint(40.73, -74.1), +bottomRight = GeoPoint(40.01, -71.12) +) +``` + +You can create a `GeoBoundingBox` query using the `geoBoundingBoxQuery` method with GeoPoints for the `top-left` and `bottom-right` corners: +```scala +val query: GeoBoundingBoxQuery[Any] = +geoBoundingBoxQuery( +field = "location", +topLeft = GeoPoint(40.73, -74.1), +bottomRight = GeoPoint(40.01, -71.12) +) +``` + +If you want to `boost` the relevance score of the query, you can use the `boost` method: +```scala +val queryWithBoost = +geoBoundingBoxQuery("location", GeoPoint(40.73, -74.1), GeoPoint(40.01, -71.12)) +.boost(1.5) +``` + +To ignore unmapped fields (fields that do not exist in the mapping), use the ignoreUnmapped method: +```scala + +val queryWithIgnoreUnmapped = +geoBoundingBoxQuery("location", GeoPoint(40.73, -74.1), GeoPoint(40.01, -71.12)) +.ignoreUnmapped(true) + +``` +To give the query a name for identification in the response, use the name method: +```scala +val queryWithName = +geoBoundingBoxQuery("location", GeoPoint(40.73, -74.1), GeoPoint(40.01, -71.12)) +.name("myGeoBoxQuery") +``` + +To specify how invalid geo coordinates are handled, use the validationMethod method: +```scala +import zio.elasticsearch.query.ValidationMethod + +val queryWithValidationMethod = +geoBoundingBoxQuery("location", GeoPoint(40.73, -74.1), GeoPoint(40.01, -71.12)) +.validationMethod(ValidationMethod.IgnoreMalformed) +``` + +You can find more information about `Geo-bounding-box` query [here](https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-geo-bounding-box-query).** diff --git a/modules/integration/src/test/scala/zio/elasticsearch/HttpExecutorSpec.scala b/modules/integration/src/test/scala/zio/elasticsearch/HttpExecutorSpec.scala index 24efb9e44..e34be98c9 100644 --- a/modules/integration/src/test/scala/zio/elasticsearch/HttpExecutorSpec.scala +++ b/modules/integration/src/test/scala/zio/elasticsearch/HttpExecutorSpec.scala @@ -2696,6 +2696,46 @@ object HttpExecutorSpec extends IntegrationSpec { } } ), + suite("geo-bounding-box query")( + test("using geo-bounding-box query") { + checkOnce(genTestDocument) { document => + val indexDefinition = + """ + |{ + | "mappings": { + | "properties": { + | "geoPointField": { + | "type": "geo_point" + | } + | } + | } + |} + |""".stripMargin + + for { + _ <- Executor.execute(ElasticRequest.createIndex(firstSearchIndex, indexDefinition)) + _ <- Executor.execute(ElasticRequest.deleteByQuery(firstSearchIndex, matchAll)) + _ <- Executor.execute( + ElasticRequest.create[TestDocument](firstSearchIndex, document).refreshTrue + ) + result <- Executor + .execute( + ElasticRequest.search( + firstSearchIndex, + ElasticQuery.geoBoundingBoxQuery( + "geoPointField", + topLeft = + GeoPoint(document.geoPointField.lat + 0.1, document.geoPointField.lon - 0.1), + bottomRight = + GeoPoint(document.geoPointField.lat - 0.1, document.geoPointField.lon + 0.1) + ) + ) + ) + .documentAs[TestDocument] + } yield assert(result)(equalTo(Chunk(document))) + } + } @@ after(Executor.execute(ElasticRequest.deleteIndex(firstSearchIndex)).orDie) + ), suite("geo-distance query")( test("using geo-distance query") { checkOnce(genTestDocument) { document => diff --git a/modules/library/src/main/scala/zio/elasticsearch/query/Queries.scala b/modules/library/src/main/scala/zio/elasticsearch/query/Queries.scala index fcaa184aa..13a54856c 100644 --- a/modules/library/src/main/scala/zio/elasticsearch/query/Queries.scala +++ b/modules/library/src/main/scala/zio/elasticsearch/query/Queries.scala @@ -580,8 +580,22 @@ private[elasticsearch] final case class GeoBoundingBox[S]( Some( field -> Obj( Chunk( - Some("top_left" -> topLeft.toString.toJson), - Some("bottom_right" -> bottomRight.toString.toJson) + Some( + "top_left" -> Obj( + Chunk( + Some("lat" -> topLeft.lat.toJson), + Some("lon" -> topLeft.lon.toJson) + ).flatten: _* + ) + ), + Some( + "bottom_right" -> Obj( + Chunk( + Some("lat" -> bottomRight.lat.toJson), + Some("lon" -> bottomRight.lon.toJson) + ).flatten: _* + ) + ) ).flatten: _* ) ), From c172022803200c3b92cb5d9d1bdd98cbfb02bb3d Mon Sep 17 00:00:00 2001 From: Marko Krstic Date: Thu, 19 Jun 2025 16:49:25 +0200 Subject: [PATCH 4/4] Add to sidebars.js --- website/sidebars.js | 1 + 1 file changed, 1 insertion(+) diff --git a/website/sidebars.js b/website/sidebars.js index 8c11fe381..068a43eb1 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -21,6 +21,7 @@ module.exports = { 'overview/queries/elastic_query_exists', 'overview/queries/elastic_query_function_score', 'overview/queries/elastic_query_fuzzy', + 'overview/queries/elastic_query_geo_bounding_box', 'overview/queries/elastic_query_geo_distance', 'overview/queries/elastic_query_geo_polygon', 'overview/queries/elastic_query_has_child',