From a79a3e56d7358e7c2bbe16512ecc3b5bc2eaa4d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 30 May 2026 11:18:14 +0200 Subject: [PATCH 1/6] feat(bson): Add Geo.CoordinateReferenceSystem --- bson/src/commonMain/kotlin/types/Geo.kt | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/bson/src/commonMain/kotlin/types/Geo.kt b/bson/src/commonMain/kotlin/types/Geo.kt index fc420326..df9275db 100644 --- a/bson/src/commonMain/kotlin/types/Geo.kt +++ b/bson/src/commonMain/kotlin/types/Geo.kt @@ -27,6 +27,7 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import opensavvy.ktmongo.bson.BsonDocument import opensavvy.ktmongo.bson.decode +import opensavvy.ktmongo.bson.types.Geo.CoordinateReferenceSystem.Companion.MongoDB import opensavvy.ktmongo.dsl.LowLevelApi import kotlin.jvm.JvmInline @@ -125,6 +126,32 @@ sealed class Geo { override fun toString() = "Latitude($degrees°)" } + /** + * A coordinate reference system (CRS) represents the way points are projected. + * + * Some operators allow overriding the CRS. + * + * ### External resources + * + * - [$geoIntersects operator](https://www.mongodb.com/docs/manual/reference/operator/query/geoIntersects/#std-label-geointersects-big-poly) + * - [$geometry operator](https://www.mongodb.com/docs/manual/reference/operator/query/geometry/) + * + * @see MongoDB The `urn:x-mongodb:crs:strictwinding:EPSG:4326` CRS. + */ + @ExperimentalGeoBsonApi + data class CoordinateReferenceSystem( + val name: String, + ) { + + companion object { + /** + * Specify this CRS when querying with a polygon larger than a single hemisphere. + * The polygon should be single-ringed and in counter-clockwise winding order. + */ + val MongoDB = CoordinateReferenceSystem("urn:x-mongodb:crs:strictwinding:EPSG:4326") + } + } + /** * A GeoJSON point. * -- 2.51.2 From 0c63ce6733d2e0e4ae636b78d3d0f298b8b9ed68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 30 May 2026 12:31:27 +0200 Subject: [PATCH 2/6] feat(bson): Geo types implement BsonFieldWriteable --- .../src/commonMain/kotlin/geo/GeoTest.kt | 119 ++++++++++++++++++ bson/src/commonMain/kotlin/types/Geo.kt | 99 ++++++++++++++- 2 files changed, 217 insertions(+), 1 deletion(-) diff --git a/bson-tests/src/commonMain/kotlin/geo/GeoTest.kt b/bson-tests/src/commonMain/kotlin/geo/GeoTest.kt index 3d5b3fa6..b3ee8749 100644 --- a/bson-tests/src/commonMain/kotlin/geo/GeoTest.kt +++ b/bson-tests/src/commonMain/kotlin/geo/GeoTest.kt @@ -57,6 +57,10 @@ private fun SuiteDsl.geoPoint(factory: Prepared) = suite("Point") { writeDouble(3.5) } }, + document { + Geo.Point(Geo.Longitude(2.0), Geo.Latitude(3.5)) + .writeTo(this) + }, json("""{"type": "Point", "coordinates": [2.0, 3.5]}"""), verify("The longitude is correct") { check(decode().x == Geo.Longitude(2.0)) @@ -91,6 +95,10 @@ private fun SuiteDsl.geoLineString(factory: Prepared) = suite("Line } } }, + document { + Geo.LineString(Geo.Point(Geo.Longitude(40.0), Geo.Latitude(5.0)), Geo.Point(Geo.Longitude(41.0), Geo.Latitude(6.0))) + .writeTo(this) + }, json("""{"type": "LineString", "coordinates": [[40.0, 5.0], [41.0, 6.0]]}"""), verify("The coordinates are correct") { check(decode().points[0] == Geo.Point(Geo.Longitude(40.0), Geo.Latitude(5.0))) @@ -124,6 +132,14 @@ private fun SuiteDsl.geoLineString(factory: Prepared) = suite("Line Geo.Point(Geo.Longitude(40.0), Geo.Latitude(5.0)), ) as Geo ), + document { + Geo.LineString( + Geo.Point(Geo.Longitude(40.0), Geo.Latitude(5.0)), + Geo.Point(Geo.Longitude(41.0), Geo.Latitude(6.0)), + Geo.Point(Geo.Longitude(41.5), Geo.Latitude(6.0)), + Geo.Point(Geo.Longitude(40.0), Geo.Latitude(5.0)), + ).writeTo(this) + }, document { writeString("type", "LineString") writeArray("coordinates") { @@ -189,6 +205,14 @@ private fun SuiteDsl.geoPolygon(factory: Prepared) = suite("Polygon Geo.Point(Geo.Longitude(0.0), Geo.Latitude(0.0)), ) as Geo ), + document { + Geo.Polygon( + Geo.Point(Geo.Longitude(0.0), Geo.Latitude(0.0)), + Geo.Point(Geo.Longitude(3.0), Geo.Latitude(6.0)), + Geo.Point(Geo.Longitude(6.0), Geo.Latitude(1.0)), + Geo.Point(Geo.Longitude(0.0), Geo.Latitude(0.0)), + ).writeTo(this) + }, document { writeString("type", "Polygon") writeArray("coordinates") { @@ -258,6 +282,24 @@ private fun SuiteDsl.geoPolygon(factory: Prepared) = suite("Polygon ), ) ), + document { + Geo.Polygon( + Geo.LineString( + Geo.Point(Geo.Longitude(0.0), Geo.Latitude(0.0)), + Geo.Point(Geo.Longitude(10.0), Geo.Latitude(0.0)), + Geo.Point(Geo.Longitude(10.0), Geo.Latitude(10.0)), + Geo.Point(Geo.Longitude(0.0), Geo.Latitude(10.0)), + Geo.Point(Geo.Longitude(0.0), Geo.Latitude(0.0)), + ), + Geo.LineString( + Geo.Point(Geo.Longitude(2.0), Geo.Latitude(2.0)), + Geo.Point(Geo.Longitude(8.0), Geo.Latitude(2.0)), + Geo.Point(Geo.Longitude(8.0), Geo.Latitude(8.0)), + Geo.Point(Geo.Longitude(2.0), Geo.Latitude(8.0)), + Geo.Point(Geo.Longitude(2.0), Geo.Latitude(2.0)), + ), + ).writeTo(this) + }, document { writeString("type", "Polygon") writeArray("coordinates") { @@ -356,6 +398,14 @@ private fun SuiteDsl.geoMultiPoint(factory: Prepared) = suite("Mult Geo.Point(Geo.Longitude(-73.9814), Geo.Latitude(40.7681)), ) as Geo ), + document { + Geo.MultiPoint( + Geo.Point(Geo.Longitude(-73.9580), Geo.Latitude(40.8003)), + Geo.Point(Geo.Longitude(-73.9498), Geo.Latitude(40.7968)), + Geo.Point(Geo.Longitude(-73.9737), Geo.Latitude(40.7648)), + Geo.Point(Geo.Longitude(-73.9814), Geo.Latitude(40.7681)), + ).writeTo(this) + }, document { writeString("type", "MultiPoint") writeArray("coordinates") { @@ -445,6 +495,26 @@ private fun SuiteDsl.geoMultiLineString(factory: Prepared) = suite( ), ) as Geo ), + document { + Geo.MultiLineString( + Geo.LineString( + Geo.Point(Geo.Longitude(-73.96943), Geo.Latitude(40.78519)), + Geo.Point(Geo.Longitude(-73.96082), Geo.Latitude(40.78095)), + ), + Geo.LineString( + Geo.Point(Geo.Longitude(-73.96415), Geo.Latitude(40.79229)), + Geo.Point(Geo.Longitude(-73.95544), Geo.Latitude(40.78854)), + ), + Geo.LineString( + Geo.Point(Geo.Longitude(-73.97162), Geo.Latitude(40.78205)), + Geo.Point(Geo.Longitude(-73.96374), Geo.Latitude(40.77715)), + ), + Geo.LineString( + Geo.Point(Geo.Longitude(-73.97880), Geo.Latitude(40.77247)), + Geo.Point(Geo.Longitude(-73.97036), Geo.Latitude(40.76811)), + ), + ).writeTo(this) + }, document { writeString("type", "MultiLineString") writeArray("coordinates") { @@ -576,6 +646,27 @@ private fun SuiteDsl.geoMultiPolygon(factory: Prepared) = suite("Mu ), ) as Geo ), + document { + Geo.MultiPolygon( + Geo.Polygon( + Geo.LineString( + Geo.Point(Geo.Longitude(-73.958), Geo.Latitude(40.8003)), + Geo.Point(Geo.Longitude(-73.9498), Geo.Latitude(40.7968)), + Geo.Point(Geo.Longitude(-73.9737), Geo.Latitude(40.7648)), + Geo.Point(Geo.Longitude(-73.9814), Geo.Latitude(40.7681)), + Geo.Point(Geo.Longitude(-73.958), Geo.Latitude(40.8003)), + ), + ), + Geo.Polygon( + Geo.LineString( + Geo.Point(Geo.Longitude(-73.958), Geo.Latitude(40.8003)), + Geo.Point(Geo.Longitude(-73.9498), Geo.Latitude(40.7968)), + Geo.Point(Geo.Longitude(-73.9737), Geo.Latitude(40.7648)), + Geo.Point(Geo.Longitude(-73.958), Geo.Latitude(40.8003)), + ), + ), + ).writeTo(this) + }, document { writeString("type", "MultiPolygon") writeArray("coordinates") { @@ -699,6 +790,34 @@ private fun SuiteDsl.geoGeometryCollection(factory: Prepared) = sui ) ) ), + document { + Geo.GeometryCollection( + Geo.MultiPoint( + Geo.Point(Geo.Longitude(-73.9580), Geo.Latitude(40.8003)), + Geo.Point(Geo.Longitude(-73.9498), Geo.Latitude(40.7968)), + Geo.Point(Geo.Longitude(-73.9737), Geo.Latitude(40.7648)), + Geo.Point(Geo.Longitude(-73.9814), Geo.Latitude(40.7681)), + ), + Geo.MultiLineString( + Geo.LineString( + Geo.Point(Geo.Longitude(-73.96943), Geo.Latitude(40.78519)), + Geo.Point(Geo.Longitude(-73.96082), Geo.Latitude(40.78095)), + ), + Geo.LineString( + Geo.Point(Geo.Longitude(-73.96415), Geo.Latitude(40.79229)), + Geo.Point(Geo.Longitude(-73.95544), Geo.Latitude(40.78854)), + ), + Geo.LineString( + Geo.Point(Geo.Longitude(-73.97162), Geo.Latitude(40.78205)), + Geo.Point(Geo.Longitude(-73.96374), Geo.Latitude(40.77715)), + ), + Geo.LineString( + Geo.Point(Geo.Longitude(-73.97880), Geo.Latitude(40.77247)), + Geo.Point(Geo.Longitude(-73.97036), Geo.Latitude(40.76811)), + ), + ) + ).writeTo(this) + }, json("""{"type": "GeometryCollection", "geometries": [{"type": "MultiPoint", "coordinates": [[-73.958, 40.8003], [-73.9498, 40.7968], [-73.9737, 40.7648], [-73.9814, 40.7681]]}, {"type": "MultiLineString", "coordinates": [[[-73.96943, 40.78519], [-73.96082, 40.78095]], [[-73.96415, 40.79229], [-73.95544, 40.78854]], [[-73.97162, 40.78205], [-73.96374, 40.77715]], [[-73.9788, 40.77247], [-73.97036, 40.76811]]]}]}"""), document { writeString("type", "GeometryCollection") diff --git a/bson/src/commonMain/kotlin/types/Geo.kt b/bson/src/commonMain/kotlin/types/Geo.kt index df9275db..3dc388fe 100644 --- a/bson/src/commonMain/kotlin/types/Geo.kt +++ b/bson/src/commonMain/kotlin/types/Geo.kt @@ -26,6 +26,8 @@ import kotlinx.serialization.descriptors.buildSerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import opensavvy.ktmongo.bson.BsonDocument +import opensavvy.ktmongo.bson.BsonFieldWriteable +import opensavvy.ktmongo.bson.BsonFieldWriter import opensavvy.ktmongo.bson.decode import opensavvy.ktmongo.bson.types.Geo.CoordinateReferenceSystem.Companion.MongoDB import opensavvy.ktmongo.dsl.LowLevelApi @@ -50,7 +52,7 @@ annotation class ExperimentalGeoBsonApi @OptIn(LowLevelApi::class) @ExperimentalGeoBsonApi @Serializable(with = Geo.Serializer::class) -sealed class Geo { +sealed class Geo : BsonFieldWriteable { /** * A longitude. @@ -177,6 +179,14 @@ sealed class Geo { override fun toString() = "Point(${x.degrees}° E, ${y.degrees}° N)" + override fun writeTo(writer: BsonFieldWriter) = with(writer) { + writeString("type", "Point") + writeArray("coordinates") { + writeDouble(x.degrees) + writeDouble(y.degrees) + } + } + @Serializable private data class Surrogate( val type: String, @@ -258,6 +268,18 @@ sealed class Geo { val isClosed: Boolean get() = points.first() == points.last() + override fun writeTo(writer: BsonFieldWriter) = with(writer) { + writeString("type", "LineString") + writeArray("coordinates") { + for (point in points) { + writeArray { + writeDouble(point.x.degrees) + writeDouble(point.y.degrees) + } + } + } + } + override fun toString() = "LineString(${points.joinToString(", ")})" @Serializable @@ -395,6 +417,22 @@ sealed class Geo { } } + override fun writeTo(writer: BsonFieldWriter) = with(writer) { + writeString("type", "Polygon") + writeArray("coordinates") { + for (ring in rings) { + writeArray { + for (point in ring.points) { + writeArray { + writeDouble(point.x.degrees) + writeDouble(point.y.degrees) + } + } + } + } + } + } + override fun toString() = "Polygon(${rings.joinToString(", ")})" @Serializable @@ -471,6 +509,18 @@ sealed class Geo { */ constructor(vararg points: Point) : this(points.asList()) + override fun writeTo(writer: BsonFieldWriter) = with(writer) { + writeString("type", "MultiPoint") + writeArray("coordinates") { + for (point in points) { + writeArray { + writeDouble(point.x.degrees) + writeDouble(point.y.degrees) + } + } + } + } + override fun toString() = "MultiPoint(${points.joinToString(", ")})" @Serializable @@ -539,6 +589,22 @@ sealed class Geo { */ constructor(vararg lineStrings: LineString) : this(lineStrings.asList()) + override fun writeTo(writer: BsonFieldWriter) = with(writer) { + writeString("type", "MultiLineString") + writeArray("coordinates") { + for (lineString in lineStrings) { + writeArray { + for (point in lineString.points) { + writeArray { + writeDouble(point.x.degrees) + writeDouble(point.y.degrees) + } + } + } + } + } + } + override fun toString() = "MultiLineString(${lineStrings.joinToString(", ")})" @Serializable @@ -614,6 +680,26 @@ sealed class Geo { */ constructor(vararg polygons: Polygon) : this(polygons.asList()) + override fun writeTo(writer: BsonFieldWriter) = with(writer) { + writeString("type", "MultiPolygon") + writeArray("coordinates") { + for (polygon in polygons) { + writeArray { + for (ring in polygon.rings) { + writeArray { + for (point in ring.points) { + writeArray { + writeDouble(point.x.degrees) + writeDouble(point.y.degrees) + } + } + } + } + } + } + } + } + override fun toString() = "MultiPolygon(${polygons.joinToString(", ")})" @Serializable @@ -701,6 +787,17 @@ sealed class Geo { constructor(vararg geometries: Geo) : this(geometries.asList()) + override fun writeTo(writer: BsonFieldWriter) = with(writer) { + writeString("type", "GeometryCollection") + writeArray("geometries") { + for (geometry in geometries) { + writeDocument { + geometry.writeTo(this) + } + } + } + } + override fun toString(): String = "GeometryCollection(${geometries.joinToString(", ")})" @Serializable -- 2.51.2 From f2e71695a83d2bd2e2267a663de3f22c680073ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 30 May 2026 12:34:37 +0200 Subject: [PATCH 3/6] feat(dsl): Add $near (filter) --- .../commonMain/kotlin/query/FilterQuery.kt | 58 +++++++++ .../kotlin/query/FilterQueryImpl.kt | 10 ++ .../kotlin/query/FilterQueryPredicate.kt | 58 +++++++++ .../kotlin/query/FilterQueryPredicateImpl.kt | 38 ++++++ .../commonMain/kotlin/query/FilterQuery.kt | 108 +++++++++++++++++ .../kotlin/query/FilterQueryImpl.kt | 10 ++ .../kotlin/query/FilterQueryPredicate.kt | 58 +++++++++ .../kotlin/query/FilterQueryPredicateImpl.kt | 38 ++++++ .../kotlin/query/filter/FilterUtils.kt | 5 + .../query/filter/GeopositionalFilterTest.kt | 110 ++++++++++++++++++ 10 files changed, 493 insertions(+) create mode 100644 dsl/src/commonTest/kotlin/query/filter/GeopositionalFilterTest.kt diff --git a/dsl-template/src/commonMain/kotlin/query/FilterQuery.kt b/dsl-template/src/commonMain/kotlin/query/FilterQuery.kt index 1f68144c..5fb20b06 100644 --- a/dsl-template/src/commonMain/kotlin/query/FilterQuery.kt +++ b/dsl-template/src/commonMain/kotlin/query/FilterQuery.kt @@ -19,6 +19,8 @@ package opensavvy.ktmongo.dsl.query import opensavvy.ktmongo.bson.BsonDocument import opensavvy.ktmongo.bson.BsonType import opensavvy.ktmongo.bson.DEPRECATED_IN_BSON_SPEC +import opensavvy.ktmongo.bson.types.ExperimentalGeoBsonApi +import opensavvy.ktmongo.bson.types.Geo import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi @@ -136,6 +138,9 @@ import kotlin.reflect.typeOf * Text query: * - [`$regex`][regex] * + * Geopositional query: + * - [`$near`][near] + * * If you can't find an operator you're searching for, visit the [tracking issue](https://gitlab.com/opensavvy/ktmongo/-/issues/4). */ @KtMongoDsl @@ -2373,4 +2378,57 @@ interface FilterQuery : CompoundBsonNode, FieldDsl { } // endregion + // region Geopositional operators + // region $near + + /** + * Matches documents where a [Geo.Point] is near the [target]. + * + * Documents are returned sorted, from the closest to the furthest. + * For the best performance, avoid specifying an additional sort. + * + * ### Example + * + * Find all bear sightings with 1km of Périgueux: + * ```kotlin + * class BearSightings( + * val _id: ObjectId, + * val location: Geo.Point, + * ) + * + * sightings.find { + * BearSightings::location.near( + * target = Geo.Point(Longitude(0.7269), Latitude(45.1828)), + * maxDistance = 1000.0, + * ) + * }.toList() + * ``` + * + * ### Indexing + * + * This operator requires a `2dsphere` index. + * + * This operator cannot be combined with other operators requiring special indexes, like `$text`. + * + * This operator is not permitted inside an aggregation pipeline. + * Instead, use the `$geoNear` stage. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/near/) + * - [YouTube tutorial](https://www.youtube.com/watch?v=muy9Ls1gbY8) + * + * @param target The point to search near. + * @param minDistance If specified, only matches documents that are further away from the [target] than this distance, in meters. + * @param maxDistance If specified, only matches documents that are closer to the [target] than this distance, in meters. + */ + @ExperimentalGeoBsonApi + fun Field.near( + target: Geo.Point, + minDistance: Double? = null, + maxDistance: Double? = null, + ) + + // endregion + // endregion } diff --git a/dsl-template/src/commonMain/kotlin/query/FilterQueryImpl.kt b/dsl-template/src/commonMain/kotlin/query/FilterQueryImpl.kt index d0c976a3..c3f40b00 100644 --- a/dsl-template/src/commonMain/kotlin/query/FilterQueryImpl.kt +++ b/dsl-template/src/commonMain/kotlin/query/FilterQueryImpl.kt @@ -20,6 +20,8 @@ package opensavvy.ktmongo.dsl.query import opensavvy.ktmongo.bson.BsonFieldWriter +import opensavvy.ktmongo.bson.types.ExperimentalGeoBsonApi +import opensavvy.ktmongo.bson.types.Geo import opensavvy.ktmongo.dsl.BsonContext import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.KtMongoDsl @@ -315,6 +317,14 @@ private class FilterQueryImpl( } // endregion + // region Geopositional operators + + @ExperimentalGeoBsonApi + override fun Field.near(target: Geo.Point, minDistance: Double?, maxDistance: Double?) { + this { near(target, minDistance, maxDistance) } + } + + // endregion } diff --git a/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicate.kt b/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicate.kt index 2df05f07..0cdead37 100644 --- a/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicate.kt +++ b/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicate.kt @@ -18,6 +18,8 @@ package opensavvy.ktmongo.dsl.query import opensavvy.ktmongo.bson.BsonType import opensavvy.ktmongo.bson.DEPRECATED_IN_BSON_SPEC +import opensavvy.ktmongo.bson.types.ExperimentalGeoBsonApi +import opensavvy.ktmongo.bson.types.Geo import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.path.FieldDsl import opensavvy.ktmongo.dsl.tree.CompoundBsonNode @@ -1021,5 +1023,61 @@ interface FilterQueryPredicate : CompoundBsonNode, FieldDsl { fun bitsAnySet(mask: ByteArray) // endregion + // region Geopositional operators + + /** + * Matches documents where a [Geo.Point] is near the [target]. + * + * Documents are returned sorted, from the closest to the furthest. + * For the best performance, avoid specifying an additional sort. + * + * This operator should only be called on fields of type [Geo.Point]. + * + * ### Example + * + * Find all bear sightings with 1km of Périgueux: + * ```kotlin + * class BearSightings( + * val _id: ObjectId, + * val location: Geo.Point, + * ) + * + * sightings.find { + * BearSightings::location { + * near( + * target = Geo.Point(Longitude(0.7269), Latitude(45.1828)), + * maxDistance = 1000.0, + * ) + * } + * }.toList() + * ``` + * + * ### Indexing + * + * This operator requires a `2dsphere` index. + * + * This operator cannot be combined with other operators requiring special indexes, like `$text`. + * + * This operator is not permitted inside an aggregation pipeline. + * Instead, use the `$geoNear` stage. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/near/) + * - [YouTube tutorial](https://www.youtube.com/watch?v=muy9Ls1gbY8) + * + * @param target The point to search near. + * @param minDistance If specified, only matches documents that are further away from the [target] than this distance, in meters. + * @param maxDistance If specified, only matches documents that are closer to the [target] than this distance, in meters. + * @see FilterQuery.near Convenience method with better type-safety. + */ + @ExperimentalGeoBsonApi + fun near( + target: Geo.Point, + minDistance: Double? = null, + maxDistance: Double? = null, + ) + + // endregion } diff --git a/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt b/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt index 4e6c9cb5..a5f31d93 100644 --- a/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt +++ b/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt @@ -21,6 +21,8 @@ package opensavvy.ktmongo.dsl.query import opensavvy.ktmongo.bson.BsonFieldWriter import opensavvy.ktmongo.bson.BsonType +import opensavvy.ktmongo.bson.types.ExperimentalGeoBsonApi +import opensavvy.ktmongo.bson.types.Geo import opensavvy.ktmongo.dsl.BsonContext import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.KtMongoDsl @@ -419,6 +421,42 @@ private class FilterQueryPredicateImpl( } } + // endregion + // region Geopositional operators + + @OptIn(DangerousMongoApi::class, LowLevelApi::class) + @ExperimentalGeoBsonApi + override fun near(target: Geo.Point, minDistance: Double?, maxDistance: Double?) { + accept(GeoNearNode(context, target, minDistance, maxDistance)) + } + + @ExperimentalGeoBsonApi + @LowLevelApi + private class GeoNearNode( + context: BsonContext, + private val target: Geo.Point, + private val minDistance: Double?, + private val maxDistance: Double?, + ) : PredicateBsonNodeNode(context) { + + @LowLevelApi + override fun write(writer: BsonFieldWriter) = with(writer) { + writeDocument($$"$near") { + writeDocument($$"$geometry") { + target.writeTo(this) + } + + if (minDistance != null) { + writeDouble($$"$minDistance", minDistance) + } + + if (maxDistance != null) { + writeDouble($$"$maxDistance", maxDistance) + } + } + } + } + // endregion } diff --git a/dsl/src/commonMain/kotlin/query/FilterQuery.kt b/dsl/src/commonMain/kotlin/query/FilterQuery.kt index 989f7652..5be3cd0f 100644 --- a/dsl/src/commonMain/kotlin/query/FilterQuery.kt +++ b/dsl/src/commonMain/kotlin/query/FilterQuery.kt @@ -22,6 +22,8 @@ package opensavvy.ktmongo.dsl.query import opensavvy.ktmongo.bson.BsonDocument import opensavvy.ktmongo.bson.BsonType import opensavvy.ktmongo.bson.DEPRECATED_IN_BSON_SPEC +import opensavvy.ktmongo.bson.types.ExperimentalGeoBsonApi +import opensavvy.ktmongo.bson.types.Geo import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi @@ -139,6 +141,9 @@ import kotlin.reflect.typeOf * Text query: * - [`$regex`][regex] * + * Geopositional query: + * - [`$near`][near] + * * If you can't find an operator you're searching for, visit the [tracking issue](https://gitlab.com/opensavvy/ktmongo/-/issues/4). */ @KtMongoDsl @@ -3883,4 +3888,107 @@ interface FilterQuery : CompoundBsonNode, FieldDsl { } // endregion + // region Geopositional operators + // region $near + + /** + * Matches documents where a [Geo.Point] is near the [target]. + * + * Documents are returned sorted, from the closest to the furthest. + * For the best performance, avoid specifying an additional sort. + * + * ### Example + * + * Find all bear sightings with 1km of Périgueux: + * ```kotlin + * class BearSightings( + * val _id: ObjectId, + * val location: Geo.Point, + * ) + * + * sightings.find { + * BearSightings::location.near( + * target = Geo.Point(Longitude(0.7269), Latitude(45.1828)), + * maxDistance = 1000.0, + * ) + * }.toList() + * ``` + * + * ### Indexing + * + * This operator requires a `2dsphere` index. + * + * This operator cannot be combined with other operators requiring special indexes, like `$text`. + * + * This operator is not permitted inside an aggregation pipeline. + * Instead, use the `$geoNear` stage. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/near/) + * - [YouTube tutorial](https://www.youtube.com/watch?v=muy9Ls1gbY8) + * + * @param target The point to search near. + * @param minDistance If specified, only matches documents that are further away from the [target] than this distance, in meters. + * @param maxDistance If specified, only matches documents that are closer to the [target] than this distance, in meters. + */ + @ExperimentalGeoBsonApi + fun Field.near( + target: Geo.Point, + minDistance: Double? = null, + maxDistance: Double? = null, + ) + + /** + * Matches documents where a [Geo.Point] is near the [target]. + * + * Documents are returned sorted, from the closest to the furthest. + * For the best performance, avoid specifying an additional sort. + * + * ### Example + * + * Find all bear sightings with 1km of Périgueux: + * ```kotlin + * class BearSightings( + * val _id: ObjectId, + * val location: Geo.Point, + * ) + * + * sightings.find { + * BearSightings::location.near( + * target = Geo.Point(Longitude(0.7269), Latitude(45.1828)), + * maxDistance = 1000.0, + * ) + * }.toList() + * ``` + * + * ### Indexing + * + * This operator requires a `2dsphere` index. + * + * This operator cannot be combined with other operators requiring special indexes, like `$text`. + * + * This operator is not permitted inside an aggregation pipeline. + * Instead, use the `$geoNear` stage. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/near/) + * - [YouTube tutorial](https://www.youtube.com/watch?v=muy9Ls1gbY8) + * + * @param target The point to search near. + * @param minDistance If specified, only matches documents that are further away from the [target] than this distance, in meters. + * @param maxDistance If specified, only matches documents that are closer to the [target] than this distance, in meters. + */ + @ExperimentalGeoBsonApi + fun kotlin.reflect.KProperty1.near( + target: Geo.Point, + minDistance: Double? = null, + maxDistance: Double? = null, + ) { + return this.field.near(target, minDistance, maxDistance) + } + + // endregion + // endregion } diff --git a/dsl/src/commonMain/kotlin/query/FilterQueryImpl.kt b/dsl/src/commonMain/kotlin/query/FilterQueryImpl.kt index a54bacdb..d2f7f06e 100644 --- a/dsl/src/commonMain/kotlin/query/FilterQueryImpl.kt +++ b/dsl/src/commonMain/kotlin/query/FilterQueryImpl.kt @@ -23,6 +23,8 @@ package opensavvy.ktmongo.dsl.query import opensavvy.ktmongo.bson.BsonFieldWriter +import opensavvy.ktmongo.bson.types.ExperimentalGeoBsonApi +import opensavvy.ktmongo.bson.types.Geo import opensavvy.ktmongo.dsl.BsonContext import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.KtMongoDsl @@ -318,6 +320,14 @@ private class FilterQueryImpl( } // endregion + // region Geopositional operators + + @ExperimentalGeoBsonApi + override fun Field.near(target: Geo.Point, minDistance: Double?, maxDistance: Double?) { + this { near(target, minDistance, maxDistance) } + } + + // endregion } diff --git a/dsl/src/commonMain/kotlin/query/FilterQueryPredicate.kt b/dsl/src/commonMain/kotlin/query/FilterQueryPredicate.kt index d57560c0..97e53782 100644 --- a/dsl/src/commonMain/kotlin/query/FilterQueryPredicate.kt +++ b/dsl/src/commonMain/kotlin/query/FilterQueryPredicate.kt @@ -21,6 +21,8 @@ package opensavvy.ktmongo.dsl.query import opensavvy.ktmongo.bson.BsonType import opensavvy.ktmongo.bson.DEPRECATED_IN_BSON_SPEC +import opensavvy.ktmongo.bson.types.ExperimentalGeoBsonApi +import opensavvy.ktmongo.bson.types.Geo import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.path.FieldDsl import opensavvy.ktmongo.dsl.tree.CompoundBsonNode @@ -1024,5 +1026,61 @@ interface FilterQueryPredicate : CompoundBsonNode, FieldDsl { fun bitsAnySet(mask: ByteArray) // endregion + // region Geopositional operators + + /** + * Matches documents where a [Geo.Point] is near the [target]. + * + * Documents are returned sorted, from the closest to the furthest. + * For the best performance, avoid specifying an additional sort. + * + * This operator should only be called on fields of type [Geo.Point]. + * + * ### Example + * + * Find all bear sightings with 1km of Périgueux: + * ```kotlin + * class BearSightings( + * val _id: ObjectId, + * val location: Geo.Point, + * ) + * + * sightings.find { + * BearSightings::location { + * near( + * target = Geo.Point(Longitude(0.7269), Latitude(45.1828)), + * maxDistance = 1000.0, + * ) + * } + * }.toList() + * ``` + * + * ### Indexing + * + * This operator requires a `2dsphere` index. + * + * This operator cannot be combined with other operators requiring special indexes, like `$text`. + * + * This operator is not permitted inside an aggregation pipeline. + * Instead, use the `$geoNear` stage. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/near/) + * - [YouTube tutorial](https://www.youtube.com/watch?v=muy9Ls1gbY8) + * + * @param target The point to search near. + * @param minDistance If specified, only matches documents that are further away from the [target] than this distance, in meters. + * @param maxDistance If specified, only matches documents that are closer to the [target] than this distance, in meters. + * @see FilterQuery.near Convenience method with better type-safety. + */ + @ExperimentalGeoBsonApi + fun near( + target: Geo.Point, + minDistance: Double? = null, + maxDistance: Double? = null, + ) + + // endregion } diff --git a/dsl/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt b/dsl/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt index 3e6b34a8..56f9da7f 100644 --- a/dsl/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt +++ b/dsl/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt @@ -24,6 +24,8 @@ package opensavvy.ktmongo.dsl.query import opensavvy.ktmongo.bson.BsonFieldWriter import opensavvy.ktmongo.bson.BsonType +import opensavvy.ktmongo.bson.types.ExperimentalGeoBsonApi +import opensavvy.ktmongo.bson.types.Geo import opensavvy.ktmongo.dsl.BsonContext import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.KtMongoDsl @@ -422,6 +424,42 @@ private class FilterQueryPredicateImpl( } } + // endregion + // region Geopositional operators + + @OptIn(DangerousMongoApi::class, LowLevelApi::class) + @ExperimentalGeoBsonApi + override fun near(target: Geo.Point, minDistance: Double?, maxDistance: Double?) { + accept(GeoNearNode(context, target, minDistance, maxDistance)) + } + + @ExperimentalGeoBsonApi + @LowLevelApi + private class GeoNearNode( + context: BsonContext, + private val target: Geo.Point, + private val minDistance: Double?, + private val maxDistance: Double?, + ) : PredicateBsonNodeNode(context) { + + @LowLevelApi + override fun write(writer: BsonFieldWriter) = with(writer) { + writeDocument($$"$near") { + writeDocument($$"$geometry") { + target.writeTo(this) + } + + if (minDistance != null) { + writeDouble($$"$minDistance", minDistance) + } + + if (maxDistance != null) { + writeDouble($$"$maxDistance", maxDistance) + } + } + } + } + // endregion } diff --git a/dsl/src/commonTest/kotlin/query/filter/FilterUtils.kt b/dsl/src/commonTest/kotlin/query/filter/FilterUtils.kt index 5102735c..d8de3254 100644 --- a/dsl/src/commonTest/kotlin/query/filter/FilterUtils.kt +++ b/dsl/src/commonTest/kotlin/query/filter/FilterUtils.kt @@ -14,8 +14,12 @@ * limitations under the License. */ +@file:OptIn(ExperimentalGeoBsonApi::class) + package opensavvy.ktmongo.dsl.query.filter +import opensavvy.ktmongo.bson.types.ExperimentalGeoBsonApi +import opensavvy.ktmongo.bson.types.Geo import opensavvy.ktmongo.bson.types.ObjectId import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi @@ -35,6 +39,7 @@ class User( val grades: List, val pets: List, val isAlive: Boolean = true, + val home: Geo.Point, ) @OptIn(LowLevelApi::class) diff --git a/dsl/src/commonTest/kotlin/query/filter/GeopositionalFilterTest.kt b/dsl/src/commonTest/kotlin/query/filter/GeopositionalFilterTest.kt new file mode 100644 index 00000000..35103957 --- /dev/null +++ b/dsl/src/commonTest/kotlin/query/filter/GeopositionalFilterTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026, OpenSavvy and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalGeoBsonApi::class) + +package opensavvy.ktmongo.dsl.query.filter + +import opensavvy.ktmongo.bson.types.ExperimentalGeoBsonApi +import opensavvy.ktmongo.bson.types.Geo +import opensavvy.ktmongo.dsl.multiContextSuite +import opensavvy.ktmongo.dsl.query.shouldBeBson + +val GeopositionalFilterTest by multiContextSuite { + suite($$"$near") { + test("Unbounded") { + filter { + User::home.near(Geo.Point(Geo.Longitude(0.7269), Geo.Latitude(45.1828))) + } shouldBeBson $$""" + { + "home": { + "$near": { + "$geometry": { + "type": "Point", + "coordinates": [0.7269, 45.1828] + } + } + } + } + """.trimIndent() + } + + test("With minimum bound") { + filter { + User::home.near( + target = Geo.Point(Geo.Longitude(0.7269), Geo.Latitude(45.1828)), + minDistance = 100.0, + ) + } shouldBeBson $$""" + { + "home": { + "$near": { + "$geometry": { + "type": "Point", + "coordinates": [0.7269, 45.1828] + }, + "$minDistance": 100.0 + } + } + } + """.trimIndent() + } + + test("With maximum bound") { + filter { + User::home.near( + target = Geo.Point(Geo.Longitude(0.7269), Geo.Latitude(45.1828)), + maxDistance = 100.0, + ) + } shouldBeBson $$""" + { + "home": { + "$near": { + "$geometry": { + "type": "Point", + "coordinates": [0.7269, 45.1828] + }, + "$maxDistance": 100.0 + } + } + } + """.trimIndent() + } + + test("With both bounds") { + filter { + User::home.near( + target = Geo.Point(Geo.Longitude(0.7269), Geo.Latitude(45.1828)), + minDistance = 17.50, + maxDistance = 267.0, + ) + } shouldBeBson $$""" + { + "home": { + "$near": { + "$geometry": { + "type": "Point", + "coordinates": [0.7269, 45.1828] + }, + "$minDistance": 17.5, + "$maxDistance": 267.0 + } + } + } + """.trimIndent() + } + } +} -- 2.51.2 From 857941aaa43b9aa4d8666ad8834eda0a30b28ffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 30 May 2026 12:54:36 +0200 Subject: [PATCH 4/6] feat(dsl): Add $nearSphere (filter) --- .../commonMain/kotlin/query/FilterQuery.kt | 55 +++++++++ .../kotlin/query/FilterQueryImpl.kt | 5 + .../kotlin/query/FilterQueryPredicate.kt | 57 +++++++++ .../kotlin/query/FilterQueryPredicateImpl.kt | 33 ++++++ .../commonMain/kotlin/query/FilterQuery.kt | 108 ++++++++++++++++++ .../kotlin/query/FilterQueryImpl.kt | 5 + .../kotlin/query/FilterQueryPredicate.kt | 57 +++++++++ .../kotlin/query/FilterQueryPredicateImpl.kt | 33 ++++++ .../query/filter/GeopositionalFilterTest.kt | 84 ++++++++++++++ 9 files changed, 437 insertions(+) diff --git a/dsl-template/src/commonMain/kotlin/query/FilterQuery.kt b/dsl-template/src/commonMain/kotlin/query/FilterQuery.kt index 5fb20b06..2261793c 100644 --- a/dsl-template/src/commonMain/kotlin/query/FilterQuery.kt +++ b/dsl-template/src/commonMain/kotlin/query/FilterQuery.kt @@ -140,6 +140,7 @@ import kotlin.reflect.typeOf * * Geopositional query: * - [`$near`][near] + * - [`$nearSphere`][nearSphere] * * If you can't find an operator you're searching for, visit the [tracking issue](https://gitlab.com/opensavvy/ktmongo/-/issues/4). */ @@ -2387,6 +2388,8 @@ interface FilterQuery : CompoundBsonNode, FieldDsl { * Documents are returned sorted, from the closest to the furthest. * For the best performance, avoid specifying an additional sort. * + * For large areas, in which the curvature of the Earth becomes significant, consider using [nearSphere] instead. + * * ### Example * * Find all bear sightings with 1km of Périgueux: @@ -2429,6 +2432,58 @@ interface FilterQuery : CompoundBsonNode, FieldDsl { maxDistance: Double? = null, ) + // endregion + // region $nearSphere + + /** + * Matches documents where a [Geo.Point] is near the [target], using spherical geometry. + * + * Unlike [near], uses spherical geometry for distance calculations, so results are accurate across large areas. + * + * Documents are returned sorted, from the closest to the furthest. + * For the best performance, avoid specifying an additional sort. + * + * ### Example + * + * Find all bear sightings with 1km of Périgueux: + * ```kotlin + * class BearSightings( + * val _id: ObjectId, + * val location: Geo.Point, + * ) + * + * sightings.find { + * BearSightings::location.nearSphere( + * target = Geo.Point(Longitude(0.7269), Latitude(45.1828)), + * maxDistance = 1000.0, + * ) + * }.toList() + * ``` + * + * ### Indexing + * + * This operator requires a `2dsphere` index. + * + * This operator cannot be combined with other operators requiring special indexes, like `$text`. + * + * This operator is not permitted inside an aggregation pipeline. + * Instead, use the `$geoNear` stage. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/nearSphere/) + * + * @param target The point to search near. + * @param minDistance If specified, only matches documents that are further away from the [target] than this distance, in meters. + * @param maxDistance If specified, only matches documents that are closer to the [target] than this distance, in meters. + */ + @ExperimentalGeoBsonApi + fun Field.nearSphere( + target: Geo.Point, + minDistance: Double? = null, + maxDistance: Double? = null, + ) + // endregion // endregion } diff --git a/dsl-template/src/commonMain/kotlin/query/FilterQueryImpl.kt b/dsl-template/src/commonMain/kotlin/query/FilterQueryImpl.kt index c3f40b00..d1422411 100644 --- a/dsl-template/src/commonMain/kotlin/query/FilterQueryImpl.kt +++ b/dsl-template/src/commonMain/kotlin/query/FilterQueryImpl.kt @@ -324,6 +324,11 @@ private class FilterQueryImpl( this { near(target, minDistance, maxDistance) } } + @ExperimentalGeoBsonApi + override fun Field.nearSphere(target: Geo.Point, minDistance: Double?, maxDistance: Double?) { + this { nearSphere(target, minDistance, maxDistance) } + } + // endregion } diff --git a/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicate.kt b/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicate.kt index 0cdead37..d63c1010 100644 --- a/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicate.kt +++ b/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicate.kt @@ -1031,6 +1031,8 @@ interface FilterQueryPredicate : CompoundBsonNode, FieldDsl { * Documents are returned sorted, from the closest to the furthest. * For the best performance, avoid specifying an additional sort. * + * For large areas, in which the curvature of the Earth becomes significant, consider using [nearSphere] instead. + * * This operator should only be called on fields of type [Geo.Point]. * * ### Example @@ -1078,6 +1080,61 @@ interface FilterQueryPredicate : CompoundBsonNode, FieldDsl { maxDistance: Double? = null, ) + /** + * Matches documents where a [Geo.Point] is near the [target], using spherical geometry. + * + * Unlike [near], uses spherical geometry for distance calculations, so results are accurate across large areas. + * + * Documents are returned sorted, from the closest to the furthest. + * For the best performance, avoid specifying an additional sort. + * + * This operator should only be called on fields of type [Geo.Point]. + * + * ### Example + * + * Find all bear sightings with 1km of Périgueux: + * ```kotlin + * class BearSightings( + * val _id: ObjectId, + * val location: Geo.Point, + * ) + * + * sightings.find { + * BearSightings::location { + * nearSphere( + * target = Geo.Point(Longitude(0.7269), Latitude(45.1828)), + * maxDistance = 1000.0, + * ) + * } + * }.toList() + * ``` + * + * ### Indexing + * + * This operator requires a `2dsphere` index. + * + * This operator cannot be combined with other operators requiring special indexes, like `$text`. + * + * This operator is not permitted inside an aggregation pipeline. + * Instead, use the `$geoNear` stage. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/nearSphere/) + * - [YouTube tutorial](https://www.youtube.com/watch?v=muy9Ls1gbY8) + * + * @param target The point to search near. + * @param minDistance If specified, only matches documents that are further away from the [target] than this distance, in meters. + * @param maxDistance If specified, only matches documents that are closer to the [target] than this distance, in meters. + * @see FilterQuery.nearSphere Convenience method with better type-safety. + */ + @ExperimentalGeoBsonApi + fun nearSphere( + target: Geo.Point, + minDistance: Double? = null, + maxDistance: Double? = null, + ) + // endregion } diff --git a/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt b/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt index a5f31d93..64fa8383 100644 --- a/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt +++ b/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt @@ -457,6 +457,39 @@ private class FilterQueryPredicateImpl( } } + @OptIn(DangerousMongoApi::class, LowLevelApi::class) + @ExperimentalGeoBsonApi + override fun nearSphere(target: Geo.Point, minDistance: Double?, maxDistance: Double?) { + accept(GeoNearSphereNode(context, target, minDistance, maxDistance)) + } + + @ExperimentalGeoBsonApi + @LowLevelApi + private class GeoNearSphereNode( + context: BsonContext, + private val target: Geo.Point, + private val minDistance: Double?, + private val maxDistance: Double?, + ) : PredicateBsonNodeNode(context) { + + @LowLevelApi + override fun write(writer: BsonFieldWriter) = with(writer) { + writeDocument($$"$nearSphere") { + writeDocument($$"$geometry") { + target.writeTo(this) + } + + if (minDistance != null) { + writeDouble($$"$minDistance", minDistance) + } + + if (maxDistance != null) { + writeDouble($$"$maxDistance", maxDistance) + } + } + } + } + // endregion } diff --git a/dsl/src/commonMain/kotlin/query/FilterQuery.kt b/dsl/src/commonMain/kotlin/query/FilterQuery.kt index 5be3cd0f..eecbc14a 100644 --- a/dsl/src/commonMain/kotlin/query/FilterQuery.kt +++ b/dsl/src/commonMain/kotlin/query/FilterQuery.kt @@ -143,6 +143,7 @@ import kotlin.reflect.typeOf * * Geopositional query: * - [`$near`][near] + * - [`$nearSphere`][nearSphere] * * If you can't find an operator you're searching for, visit the [tracking issue](https://gitlab.com/opensavvy/ktmongo/-/issues/4). */ @@ -3897,6 +3898,8 @@ interface FilterQuery : CompoundBsonNode, FieldDsl { * Documents are returned sorted, from the closest to the furthest. * For the best performance, avoid specifying an additional sort. * + * For large areas, in which the curvature of the Earth becomes significant, consider using [nearSphere] instead. + * * ### Example * * Find all bear sightings with 1km of Périgueux: @@ -3945,6 +3948,8 @@ interface FilterQuery : CompoundBsonNode, FieldDsl { * Documents are returned sorted, from the closest to the furthest. * For the best performance, avoid specifying an additional sort. * + * For large areas, in which the curvature of the Earth becomes significant, consider using [nearSphere] instead. + * * ### Example * * Find all bear sightings with 1km of Périgueux: @@ -3989,6 +3994,109 @@ interface FilterQuery : CompoundBsonNode, FieldDsl { return this.field.near(target, minDistance, maxDistance) } + // endregion + // region $nearSphere + + /** + * Matches documents where a [Geo.Point] is near the [target], using spherical geometry. + * + * Unlike [near], uses spherical geometry for distance calculations, so results are accurate across large areas. + * + * Documents are returned sorted, from the closest to the furthest. + * For the best performance, avoid specifying an additional sort. + * + * ### Example + * + * Find all bear sightings with 1km of Périgueux: + * ```kotlin + * class BearSightings( + * val _id: ObjectId, + * val location: Geo.Point, + * ) + * + * sightings.find { + * BearSightings::location.nearSphere( + * target = Geo.Point(Longitude(0.7269), Latitude(45.1828)), + * maxDistance = 1000.0, + * ) + * }.toList() + * ``` + * + * ### Indexing + * + * This operator requires a `2dsphere` index. + * + * This operator cannot be combined with other operators requiring special indexes, like `$text`. + * + * This operator is not permitted inside an aggregation pipeline. + * Instead, use the `$geoNear` stage. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/nearSphere/) + * + * @param target The point to search near. + * @param minDistance If specified, only matches documents that are further away from the [target] than this distance, in meters. + * @param maxDistance If specified, only matches documents that are closer to the [target] than this distance, in meters. + */ + @ExperimentalGeoBsonApi + fun Field.nearSphere( + target: Geo.Point, + minDistance: Double? = null, + maxDistance: Double? = null, + ) + + /** + * Matches documents where a [Geo.Point] is near the [target], using spherical geometry. + * + * Unlike [near], uses spherical geometry for distance calculations, so results are accurate across large areas. + * + * Documents are returned sorted, from the closest to the furthest. + * For the best performance, avoid specifying an additional sort. + * + * ### Example + * + * Find all bear sightings with 1km of Périgueux: + * ```kotlin + * class BearSightings( + * val _id: ObjectId, + * val location: Geo.Point, + * ) + * + * sightings.find { + * BearSightings::location.nearSphere( + * target = Geo.Point(Longitude(0.7269), Latitude(45.1828)), + * maxDistance = 1000.0, + * ) + * }.toList() + * ``` + * + * ### Indexing + * + * This operator requires a `2dsphere` index. + * + * This operator cannot be combined with other operators requiring special indexes, like `$text`. + * + * This operator is not permitted inside an aggregation pipeline. + * Instead, use the `$geoNear` stage. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/nearSphere/) + * + * @param target The point to search near. + * @param minDistance If specified, only matches documents that are further away from the [target] than this distance, in meters. + * @param maxDistance If specified, only matches documents that are closer to the [target] than this distance, in meters. + */ + @ExperimentalGeoBsonApi + fun kotlin.reflect.KProperty1.nearSphere( + target: Geo.Point, + minDistance: Double? = null, + maxDistance: Double? = null, + ) { + return this.field.nearSphere(target, minDistance, maxDistance) + } + // endregion // endregion } diff --git a/dsl/src/commonMain/kotlin/query/FilterQueryImpl.kt b/dsl/src/commonMain/kotlin/query/FilterQueryImpl.kt index d2f7f06e..508d6d71 100644 --- a/dsl/src/commonMain/kotlin/query/FilterQueryImpl.kt +++ b/dsl/src/commonMain/kotlin/query/FilterQueryImpl.kt @@ -327,6 +327,11 @@ private class FilterQueryImpl( this { near(target, minDistance, maxDistance) } } + @ExperimentalGeoBsonApi + override fun Field.nearSphere(target: Geo.Point, minDistance: Double?, maxDistance: Double?) { + this { nearSphere(target, minDistance, maxDistance) } + } + // endregion } diff --git a/dsl/src/commonMain/kotlin/query/FilterQueryPredicate.kt b/dsl/src/commonMain/kotlin/query/FilterQueryPredicate.kt index 97e53782..c9803933 100644 --- a/dsl/src/commonMain/kotlin/query/FilterQueryPredicate.kt +++ b/dsl/src/commonMain/kotlin/query/FilterQueryPredicate.kt @@ -1034,6 +1034,8 @@ interface FilterQueryPredicate : CompoundBsonNode, FieldDsl { * Documents are returned sorted, from the closest to the furthest. * For the best performance, avoid specifying an additional sort. * + * For large areas, in which the curvature of the Earth becomes significant, consider using [nearSphere] instead. + * * This operator should only be called on fields of type [Geo.Point]. * * ### Example @@ -1081,6 +1083,61 @@ interface FilterQueryPredicate : CompoundBsonNode, FieldDsl { maxDistance: Double? = null, ) + /** + * Matches documents where a [Geo.Point] is near the [target], using spherical geometry. + * + * Unlike [near], uses spherical geometry for distance calculations, so results are accurate across large areas. + * + * Documents are returned sorted, from the closest to the furthest. + * For the best performance, avoid specifying an additional sort. + * + * This operator should only be called on fields of type [Geo.Point]. + * + * ### Example + * + * Find all bear sightings with 1km of Périgueux: + * ```kotlin + * class BearSightings( + * val _id: ObjectId, + * val location: Geo.Point, + * ) + * + * sightings.find { + * BearSightings::location { + * nearSphere( + * target = Geo.Point(Longitude(0.7269), Latitude(45.1828)), + * maxDistance = 1000.0, + * ) + * } + * }.toList() + * ``` + * + * ### Indexing + * + * This operator requires a `2dsphere` index. + * + * This operator cannot be combined with other operators requiring special indexes, like `$text`. + * + * This operator is not permitted inside an aggregation pipeline. + * Instead, use the `$geoNear` stage. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/nearSphere/) + * - [YouTube tutorial](https://www.youtube.com/watch?v=muy9Ls1gbY8) + * + * @param target The point to search near. + * @param minDistance If specified, only matches documents that are further away from the [target] than this distance, in meters. + * @param maxDistance If specified, only matches documents that are closer to the [target] than this distance, in meters. + * @see FilterQuery.nearSphere Convenience method with better type-safety. + */ + @ExperimentalGeoBsonApi + fun nearSphere( + target: Geo.Point, + minDistance: Double? = null, + maxDistance: Double? = null, + ) + // endregion } diff --git a/dsl/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt b/dsl/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt index 56f9da7f..9b9fb1b8 100644 --- a/dsl/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt +++ b/dsl/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt @@ -460,6 +460,39 @@ private class FilterQueryPredicateImpl( } } + @OptIn(DangerousMongoApi::class, LowLevelApi::class) + @ExperimentalGeoBsonApi + override fun nearSphere(target: Geo.Point, minDistance: Double?, maxDistance: Double?) { + accept(GeoNearSphereNode(context, target, minDistance, maxDistance)) + } + + @ExperimentalGeoBsonApi + @LowLevelApi + private class GeoNearSphereNode( + context: BsonContext, + private val target: Geo.Point, + private val minDistance: Double?, + private val maxDistance: Double?, + ) : PredicateBsonNodeNode(context) { + + @LowLevelApi + override fun write(writer: BsonFieldWriter) = with(writer) { + writeDocument($$"$nearSphere") { + writeDocument($$"$geometry") { + target.writeTo(this) + } + + if (minDistance != null) { + writeDouble($$"$minDistance", minDistance) + } + + if (maxDistance != null) { + writeDouble($$"$maxDistance", maxDistance) + } + } + } + } + // endregion } diff --git a/dsl/src/commonTest/kotlin/query/filter/GeopositionalFilterTest.kt b/dsl/src/commonTest/kotlin/query/filter/GeopositionalFilterTest.kt index 35103957..12201252 100644 --- a/dsl/src/commonTest/kotlin/query/filter/GeopositionalFilterTest.kt +++ b/dsl/src/commonTest/kotlin/query/filter/GeopositionalFilterTest.kt @@ -107,4 +107,88 @@ val GeopositionalFilterTest by multiContextSuite { """.trimIndent() } } + + suite($$"$nearSphere") { + test("Unbounded") { + filter { + User::home.nearSphere(Geo.Point(Geo.Longitude(0.7269), Geo.Latitude(45.1828))) + } shouldBeBson $$""" + { + "home": { + "$nearSphere": { + "$geometry": { + "type": "Point", + "coordinates": [0.7269, 45.1828] + } + } + } + } + """.trimIndent() + } + + test("With minimum bound") { + filter { + User::home.nearSphere( + target = Geo.Point(Geo.Longitude(0.7269), Geo.Latitude(45.1828)), + minDistance = 100.0, + ) + } shouldBeBson $$""" + { + "home": { + "$nearSphere": { + "$geometry": { + "type": "Point", + "coordinates": [0.7269, 45.1828] + }, + "$minDistance": 100.0 + } + } + } + """.trimIndent() + } + + test("With maximum bound") { + filter { + User::home.nearSphere( + target = Geo.Point(Geo.Longitude(0.7269), Geo.Latitude(45.1828)), + maxDistance = 100.0, + ) + } shouldBeBson $$""" + { + "home": { + "$nearSphere": { + "$geometry": { + "type": "Point", + "coordinates": [0.7269, 45.1828] + }, + "$maxDistance": 100.0 + } + } + } + """.trimIndent() + } + + test("With both bounds") { + filter { + User::home.nearSphere( + target = Geo.Point(Geo.Longitude(0.7269), Geo.Latitude(45.1828)), + minDistance = 17.50, + maxDistance = 267.0, + ) + } shouldBeBson $$""" + { + "home": { + "$nearSphere": { + "$geometry": { + "type": "Point", + "coordinates": [0.7269, 45.1828] + }, + "$minDistance": 17.5, + "$maxDistance": 267.0 + } + } + } + """.trimIndent() + } + } } -- 2.51.2 From 4904317e21019505980cae4c29d5e3fa05f23cf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 31 May 2026 11:19:52 +0200 Subject: [PATCH 5/6] feat(dsl): Add $geoWithin (filter) --- .../commonMain/kotlin/query/FilterQuery.kt | 79 +++++++++ .../kotlin/query/FilterQueryImpl.kt | 10 ++ .../kotlin/query/FilterQueryPredicate.kt | 79 +++++++++ .../kotlin/query/FilterQueryPredicateImpl.kt | 39 +++++ .../commonMain/kotlin/query/FilterQuery.kt | 157 ++++++++++++++++++ .../kotlin/query/FilterQueryImpl.kt | 10 ++ .../kotlin/query/FilterQueryPredicate.kt | 79 +++++++++ .../kotlin/query/FilterQueryPredicateImpl.kt | 39 +++++ .../query/filter/GeopositionalFilterTest.kt | 128 ++++++++++++++ 9 files changed, 620 insertions(+) diff --git a/dsl-template/src/commonMain/kotlin/query/FilterQuery.kt b/dsl-template/src/commonMain/kotlin/query/FilterQuery.kt index 2261793c..54b324e1 100644 --- a/dsl-template/src/commonMain/kotlin/query/FilterQuery.kt +++ b/dsl-template/src/commonMain/kotlin/query/FilterQuery.kt @@ -139,6 +139,7 @@ import kotlin.reflect.typeOf * - [`$regex`][regex] * * Geopositional query: + * - [`$geoWithin`][geoWithin] * - [`$near`][near] * - [`$nearSphere`][nearSphere] * @@ -2484,6 +2485,84 @@ interface FilterQuery : CompoundBsonNode, FieldDsl { maxDistance: Double? = null, ) + // endregion + // region $geoWithin + + /** + * Matches documents where a [Geo.Point] is within the given [polygon]. + * + * ### Example + * + * ```kotlin + * class Place( + * val _id: ObjectId, + * val name: String, + * val location: Geo.Point, + * val category: String, + * ) + * + * places.find { + * Place::location { + * geoWithin( + * Geo.Polygon( + * Geo.Point(Longitude(-73.95), Latitude(40.80)), + * Geo.Point(Longitude(-73.94), Latitude(40.79)), + * Geo.Point(Longitude(-73.97), Latitude(40.79)), + * Geo.Point(Longitude(-79.98), Latitude(40.76)), + * Geo.Point(Longitude(-73.95), Latitude(40.80)), + * ) + * ) + * } + * } + * ``` + * + * ### Indexing + * + * This operator does not require a `2d` or `2dsphere` index. + * However, such an index is recommended for performance. + * + * ### Big polygons + * + * For queries that specify a polygon greater with areas greater than a single hemisphere, + * the default [crs] results in queries for the complimentary geometry. + * + * In these cases, specify a [crs] of [Geo.CoordinateReferenceSystem.MongoDB]. + * Only [single-ringed polygons][Geo.Polygon] are supported. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/geoWithin/) + * - [Official tutorial](https://www.mongodb.com/docs/manual/core/indexes/index-types/geospatial/2dsphere/query/geojson-bound-by-polygon/) + */ + @ExperimentalGeoBsonApi + fun Field.geoWithin( + polygon: Geo.Polygon, + crs: Geo.CoordinateReferenceSystem? = null, + ) + + /** + * Matches documents where a [Geo.Point] is within the given [polygons]. + * + * ### Indexing + * + * This operator does not require a `2d` or `2dsphere` index. + * However, such an index is recommended for performance. + * + * ### Big polygons + * + * For queries that specify a polygon greater with areas greater than a single hemisphere, + * this operator matches the complimentary geometry. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/geoWithin/) + * - [Official tutorial](https://www.mongodb.com/docs/manual/core/indexes/index-types/geospatial/2dsphere/query/geojson-bound-by-polygon/) + */ + @ExperimentalGeoBsonApi + fun Field.geoWithin( + polygons: Geo.MultiPolygon, + ) + // endregion // endregion } diff --git a/dsl-template/src/commonMain/kotlin/query/FilterQueryImpl.kt b/dsl-template/src/commonMain/kotlin/query/FilterQueryImpl.kt index d1422411..6f59dab8 100644 --- a/dsl-template/src/commonMain/kotlin/query/FilterQueryImpl.kt +++ b/dsl-template/src/commonMain/kotlin/query/FilterQueryImpl.kt @@ -329,6 +329,16 @@ private class FilterQueryImpl( this { nearSphere(target, minDistance, maxDistance) } } + @ExperimentalGeoBsonApi + override fun Field.geoWithin(polygon: Geo.Polygon, crs: Geo.CoordinateReferenceSystem?) { + this { geoWithin(polygon, crs) } + } + + @ExperimentalGeoBsonApi + override fun Field.geoWithin(polygons: Geo.MultiPolygon) { + this { geoWithin(polygons) } + } + // endregion } diff --git a/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicate.kt b/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicate.kt index d63c1010..bb2375bf 100644 --- a/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicate.kt +++ b/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicate.kt @@ -1135,6 +1135,85 @@ interface FilterQueryPredicate : CompoundBsonNode, FieldDsl { maxDistance: Double? = null, ) + /** + * Matches documents where a [Geo.Point] is within the given [polygon]. + * + * This operator should only be called on fields of type [Geo.Point]. + * + * ### Example + * + * ```kotlin + * class Place( + * val _id: ObjectId, + * val name: String, + * val location: Geo.Point, + * val category: String, + * ) + * + * places.find { + * Place::location { + * geoWithin( + * Geo.Polygon( + * Geo.Point(Longitude(-73.95), Latitude(40.80)), + * Geo.Point(Longitude(-73.94), Latitude(40.79)), + * Geo.Point(Longitude(-73.97), Latitude(40.79)), + * Geo.Point(Longitude(-79.98), Latitude(40.76)), + * Geo.Point(Longitude(-73.95), Latitude(40.80)), + * ) + * ) + * } + * } + * ``` + * + * ### Indexing + * + * This operator does not require a `2d` or `2dsphere` index. + * However, such an index is recommended for performance. + * + * ### Big polygons + * + * For queries that specify a polygon greater with areas greater than a single hemisphere, + * the default [crs] results in queries for the complimentary geometry. + * + * In these cases, specify a [crs] of [Geo.CoordinateReferenceSystem.MongoDB]. + * Only [single-ringed polygons][Geo.Polygon] are supported. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/geoWithin/) + * - [Official tutorial](https://www.mongodb.com/docs/manual/core/indexes/index-types/geospatial/2dsphere/query/geojson-bound-by-polygon/) + */ + @ExperimentalGeoBsonApi + fun geoWithin( + polygon: Geo.Polygon, + crs: Geo.CoordinateReferenceSystem? = null, + ) + + /** + * Matches documents where a [Geo.Point] is within the given [polygons]. + * + * This operator should only be called on fields of type [Geo.Point]. + * + * ### Indexing + * + * This operator does not require a `2d` or `2dsphere` index. + * However, such an index is recommended for performance. + * + * ### Big polygons + * + * For queries that specify a polygon greater with areas greater than a single hemisphere, + * this operator matches the complimentary geometry. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/geoWithin/) + * - [Official tutorial](https://www.mongodb.com/docs/manual/core/indexes/index-types/geospatial/2dsphere/query/geojson-bound-by-polygon/) + */ + @ExperimentalGeoBsonApi + fun geoWithin( + polygons: Geo.MultiPolygon, + ) + // endregion } diff --git a/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt b/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt index 64fa8383..ca51bcbe 100644 --- a/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt +++ b/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt @@ -490,6 +490,45 @@ private class FilterQueryPredicateImpl( } } + @OptIn(DangerousMongoApi::class, LowLevelApi::class) + @ExperimentalGeoBsonApi + override fun geoWithin(polygon: Geo.Polygon, crs: Geo.CoordinateReferenceSystem?) { + accept(GeoWithinNode(context, polygon, crs)) + } + + @OptIn(DangerousMongoApi::class, LowLevelApi::class) + @ExperimentalGeoBsonApi + override fun geoWithin(polygons: Geo.MultiPolygon) { + accept(GeoWithinNode(context, polygons, null)) + } + + @ExperimentalGeoBsonApi + @LowLevelApi + private class GeoWithinNode( + context: BsonContext, + private val target: Geo, + private val crs: Geo.CoordinateReferenceSystem?, + ) : PredicateBsonNodeNode(context) { + + @LowLevelApi + override fun write(writer: BsonFieldWriter) = with(writer) { + writeDocument($$"$geoWithin") { + writeDocument($$"$geometry") { + target.writeTo(this) + + if (crs != null) { + writeDocument("crs") { + writeString("type", "name") + writeDocument("properties") { + writeString("name", crs.name) + } + } + } + } + } + } + } + // endregion } diff --git a/dsl/src/commonMain/kotlin/query/FilterQuery.kt b/dsl/src/commonMain/kotlin/query/FilterQuery.kt index eecbc14a..f771ceb7 100644 --- a/dsl/src/commonMain/kotlin/query/FilterQuery.kt +++ b/dsl/src/commonMain/kotlin/query/FilterQuery.kt @@ -4097,6 +4097,163 @@ interface FilterQuery : CompoundBsonNode, FieldDsl { return this.field.nearSphere(target, minDistance, maxDistance) } + // endregion + // region $geoWithin + + /** + * Matches documents where a [Geo.Point] is within the given [polygon]. + * + * ### Example + * + * ```kotlin + * class Place( + * val _id: ObjectId, + * val name: String, + * val location: Geo.Point, + * val category: String, + * ) + * + * places.find { + * Place::location { + * geoWithin( + * Geo.Polygon( + * Geo.Point(Longitude(-73.95), Latitude(40.80)), + * Geo.Point(Longitude(-73.94), Latitude(40.79)), + * Geo.Point(Longitude(-73.97), Latitude(40.79)), + * Geo.Point(Longitude(-79.98), Latitude(40.76)), + * Geo.Point(Longitude(-73.95), Latitude(40.80)), + * ) + * ) + * } + * } + * ``` + * + * ### Indexing + * + * This operator does not require a `2d` or `2dsphere` index. + * However, such an index is recommended for performance. + * + * ### Big polygons + * + * For queries that specify a polygon greater with areas greater than a single hemisphere, + * the default [crs] results in queries for the complimentary geometry. + * + * In these cases, specify a [crs] of [Geo.CoordinateReferenceSystem.MongoDB]. + * Only [single-ringed polygons][Geo.Polygon] are supported. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/geoWithin/) + * - [Official tutorial](https://www.mongodb.com/docs/manual/core/indexes/index-types/geospatial/2dsphere/query/geojson-bound-by-polygon/) + */ + @ExperimentalGeoBsonApi + fun Field.geoWithin( + polygon: Geo.Polygon, + crs: Geo.CoordinateReferenceSystem? = null, + ) + + /** + * Matches documents where a [Geo.Point] is within the given [polygon]. + * + * ### Example + * + * ```kotlin + * class Place( + * val _id: ObjectId, + * val name: String, + * val location: Geo.Point, + * val category: String, + * ) + * + * places.find { + * Place::location { + * geoWithin( + * Geo.Polygon( + * Geo.Point(Longitude(-73.95), Latitude(40.80)), + * Geo.Point(Longitude(-73.94), Latitude(40.79)), + * Geo.Point(Longitude(-73.97), Latitude(40.79)), + * Geo.Point(Longitude(-79.98), Latitude(40.76)), + * Geo.Point(Longitude(-73.95), Latitude(40.80)), + * ) + * ) + * } + * } + * ``` + * + * ### Indexing + * + * This operator does not require a `2d` or `2dsphere` index. + * However, such an index is recommended for performance. + * + * ### Big polygons + * + * For queries that specify a polygon greater with areas greater than a single hemisphere, + * the default [crs] results in queries for the complimentary geometry. + * + * In these cases, specify a [crs] of [Geo.CoordinateReferenceSystem.MongoDB]. + * Only [single-ringed polygons][Geo.Polygon] are supported. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/geoWithin/) + * - [Official tutorial](https://www.mongodb.com/docs/manual/core/indexes/index-types/geospatial/2dsphere/query/geojson-bound-by-polygon/) + */ + @ExperimentalGeoBsonApi + fun kotlin.reflect.KProperty1.geoWithin( + polygon: Geo.Polygon, + crs: Geo.CoordinateReferenceSystem? = null, + ) { + return this.field.geoWithin(polygon, crs) + } + + /** + * Matches documents where a [Geo.Point] is within the given [polygons]. + * + * ### Indexing + * + * This operator does not require a `2d` or `2dsphere` index. + * However, such an index is recommended for performance. + * + * ### Big polygons + * + * For queries that specify a polygon greater with areas greater than a single hemisphere, + * this operator matches the complimentary geometry. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/geoWithin/) + * - [Official tutorial](https://www.mongodb.com/docs/manual/core/indexes/index-types/geospatial/2dsphere/query/geojson-bound-by-polygon/) + */ + @ExperimentalGeoBsonApi + fun Field.geoWithin( + polygons: Geo.MultiPolygon, + ) + + /** + * Matches documents where a [Geo.Point] is within the given [polygons]. + * + * ### Indexing + * + * This operator does not require a `2d` or `2dsphere` index. + * However, such an index is recommended for performance. + * + * ### Big polygons + * + * For queries that specify a polygon greater with areas greater than a single hemisphere, + * this operator matches the complimentary geometry. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/geoWithin/) + * - [Official tutorial](https://www.mongodb.com/docs/manual/core/indexes/index-types/geospatial/2dsphere/query/geojson-bound-by-polygon/) + */ + @ExperimentalGeoBsonApi + fun kotlin.reflect.KProperty1.geoWithin( + polygons: Geo.MultiPolygon, + ) { + return this.field.geoWithin(polygons) + } + // endregion // endregion } diff --git a/dsl/src/commonMain/kotlin/query/FilterQueryImpl.kt b/dsl/src/commonMain/kotlin/query/FilterQueryImpl.kt index 508d6d71..db1ae392 100644 --- a/dsl/src/commonMain/kotlin/query/FilterQueryImpl.kt +++ b/dsl/src/commonMain/kotlin/query/FilterQueryImpl.kt @@ -332,6 +332,16 @@ private class FilterQueryImpl( this { nearSphere(target, minDistance, maxDistance) } } + @ExperimentalGeoBsonApi + override fun Field.geoWithin(polygon: Geo.Polygon, crs: Geo.CoordinateReferenceSystem?) { + this { geoWithin(polygon, crs) } + } + + @ExperimentalGeoBsonApi + override fun Field.geoWithin(polygons: Geo.MultiPolygon) { + this { geoWithin(polygons) } + } + // endregion } diff --git a/dsl/src/commonMain/kotlin/query/FilterQueryPredicate.kt b/dsl/src/commonMain/kotlin/query/FilterQueryPredicate.kt index c9803933..52c2bf1f 100644 --- a/dsl/src/commonMain/kotlin/query/FilterQueryPredicate.kt +++ b/dsl/src/commonMain/kotlin/query/FilterQueryPredicate.kt @@ -1138,6 +1138,85 @@ interface FilterQueryPredicate : CompoundBsonNode, FieldDsl { maxDistance: Double? = null, ) + /** + * Matches documents where a [Geo.Point] is within the given [polygon]. + * + * This operator should only be called on fields of type [Geo.Point]. + * + * ### Example + * + * ```kotlin + * class Place( + * val _id: ObjectId, + * val name: String, + * val location: Geo.Point, + * val category: String, + * ) + * + * places.find { + * Place::location { + * geoWithin( + * Geo.Polygon( + * Geo.Point(Longitude(-73.95), Latitude(40.80)), + * Geo.Point(Longitude(-73.94), Latitude(40.79)), + * Geo.Point(Longitude(-73.97), Latitude(40.79)), + * Geo.Point(Longitude(-79.98), Latitude(40.76)), + * Geo.Point(Longitude(-73.95), Latitude(40.80)), + * ) + * ) + * } + * } + * ``` + * + * ### Indexing + * + * This operator does not require a `2d` or `2dsphere` index. + * However, such an index is recommended for performance. + * + * ### Big polygons + * + * For queries that specify a polygon greater with areas greater than a single hemisphere, + * the default [crs] results in queries for the complimentary geometry. + * + * In these cases, specify a [crs] of [Geo.CoordinateReferenceSystem.MongoDB]. + * Only [single-ringed polygons][Geo.Polygon] are supported. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/geoWithin/) + * - [Official tutorial](https://www.mongodb.com/docs/manual/core/indexes/index-types/geospatial/2dsphere/query/geojson-bound-by-polygon/) + */ + @ExperimentalGeoBsonApi + fun geoWithin( + polygon: Geo.Polygon, + crs: Geo.CoordinateReferenceSystem? = null, + ) + + /** + * Matches documents where a [Geo.Point] is within the given [polygons]. + * + * This operator should only be called on fields of type [Geo.Point]. + * + * ### Indexing + * + * This operator does not require a `2d` or `2dsphere` index. + * However, such an index is recommended for performance. + * + * ### Big polygons + * + * For queries that specify a polygon greater with areas greater than a single hemisphere, + * this operator matches the complimentary geometry. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/geoWithin/) + * - [Official tutorial](https://www.mongodb.com/docs/manual/core/indexes/index-types/geospatial/2dsphere/query/geojson-bound-by-polygon/) + */ + @ExperimentalGeoBsonApi + fun geoWithin( + polygons: Geo.MultiPolygon, + ) + // endregion } diff --git a/dsl/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt b/dsl/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt index 9b9fb1b8..f0c31ce1 100644 --- a/dsl/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt +++ b/dsl/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt @@ -493,6 +493,45 @@ private class FilterQueryPredicateImpl( } } + @OptIn(DangerousMongoApi::class, LowLevelApi::class) + @ExperimentalGeoBsonApi + override fun geoWithin(polygon: Geo.Polygon, crs: Geo.CoordinateReferenceSystem?) { + accept(GeoWithinNode(context, polygon, crs)) + } + + @OptIn(DangerousMongoApi::class, LowLevelApi::class) + @ExperimentalGeoBsonApi + override fun geoWithin(polygons: Geo.MultiPolygon) { + accept(GeoWithinNode(context, polygons, null)) + } + + @ExperimentalGeoBsonApi + @LowLevelApi + private class GeoWithinNode( + context: BsonContext, + private val target: Geo, + private val crs: Geo.CoordinateReferenceSystem?, + ) : PredicateBsonNodeNode(context) { + + @LowLevelApi + override fun write(writer: BsonFieldWriter) = with(writer) { + writeDocument($$"$geoWithin") { + writeDocument($$"$geometry") { + target.writeTo(this) + + if (crs != null) { + writeDocument("crs") { + writeString("type", "name") + writeDocument("properties") { + writeString("name", crs.name) + } + } + } + } + } + } + } + // endregion } diff --git a/dsl/src/commonTest/kotlin/query/filter/GeopositionalFilterTest.kt b/dsl/src/commonTest/kotlin/query/filter/GeopositionalFilterTest.kt index 12201252..f424c79a 100644 --- a/dsl/src/commonTest/kotlin/query/filter/GeopositionalFilterTest.kt +++ b/dsl/src/commonTest/kotlin/query/filter/GeopositionalFilterTest.kt @@ -191,4 +191,132 @@ val GeopositionalFilterTest by multiContextSuite { """.trimIndent() } } + + suite($$"$geoWithin") { + test("Simple") { + filter { + User::home.geoWithin( + Geo.Polygon( + Geo.Point(Geo.Longitude(-73.95), Geo.Latitude(40.80)), + Geo.Point(Geo.Longitude(-73.94), Geo.Latitude(40.79)), + Geo.Point(Geo.Longitude(-73.97), Geo.Latitude(40.79)), + Geo.Point(Geo.Longitude(-79.98), Geo.Latitude(40.76)), + Geo.Point(Geo.Longitude(-73.95), Geo.Latitude(40.80)), + ) + ) + } shouldBeBson $$""" + { + "home": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-73.95, 40.8], + [-73.94, 40.79], + [-73.97, 40.79], + [-79.98, 40.76], + [-73.95, 40.8] + ] + ] + } + } + } + } + """.trimIndent() + } + + test("Large with CRS") { + filter { + User::home.geoWithin( + Geo.Polygon( + Geo.Point(Geo.Longitude(-73.95), Geo.Latitude(40.80)), + Geo.Point(Geo.Longitude(-73.94), Geo.Latitude(40.79)), + Geo.Point(Geo.Longitude(-73.97), Geo.Latitude(40.79)), + Geo.Point(Geo.Longitude(-79.98), Geo.Latitude(40.76)), + Geo.Point(Geo.Longitude(-73.95), Geo.Latitude(40.80)), + ), + crs = Geo.CoordinateReferenceSystem.MongoDB + ) + } shouldBeBson $$""" + { + "home": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-73.95, 40.8], + [-73.94, 40.79], + [-73.97, 40.79], + [-79.98, 40.76], + [-73.95, 40.8] + ] + ], + "crs": { + "type": "name", + "properties": { + "name": "urn:x-mongodb:crs:strictwinding:EPSG:4326" + } + } + } + } + } + } + """.trimIndent() + } + + test("MultiPolygon") { + filter { + User::home.geoWithin( + Geo.MultiPolygon( + Geo.Polygon( + Geo.Point(Geo.Longitude(-73.95), Geo.Latitude(40.80)), + Geo.Point(Geo.Longitude(-73.94), Geo.Latitude(40.79)), + Geo.Point(Geo.Longitude(-73.97), Geo.Latitude(40.79)), + Geo.Point(Geo.Longitude(-79.98), Geo.Latitude(40.76)), + Geo.Point(Geo.Longitude(-73.95), Geo.Latitude(40.80)), + ), + Geo.Polygon( + Geo.Point(Geo.Longitude(0.0), Geo.Latitude(0.0)), + Geo.Point(Geo.Longitude(10.0), Geo.Latitude(0.0)), + Geo.Point(Geo.Longitude(10.0), Geo.Latitude(10.0)), + Geo.Point(Geo.Longitude(0.0), Geo.Latitude(10.0)), + Geo.Point(Geo.Longitude(0.0), Geo.Latitude(0.0)), + ) + ) + ) + } shouldBeBson $$""" + { + "home": { + "$geoWithin": { + "$geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-73.95, 40.8], + [-73.94, 40.79], + [-73.97, 40.79], + [-79.98, 40.76], + [-73.95, 40.8] + ] + ], + [ + [ + [0.0, 0.0], + [10.0, 0.0], + [10.0, 10.0], + [0.0, 10.0], + [0.0, 0.0] + ] + ] + ] + } + } + } + } + """.trimIndent() + } + } } -- 2.51.2 From bfcd431898fdd2649b3d5d987c0f19da03db1208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 31 May 2026 12:00:13 +0200 Subject: [PATCH 6/6] feat(dsl): Add $geoIntersects (filter) --- .../commonMain/kotlin/query/FilterQuery.kt | 66 +++++++++ .../kotlin/query/FilterQueryImpl.kt | 10 ++ .../kotlin/query/FilterQueryPredicate.kt | 72 ++++++++++ .../kotlin/query/FilterQueryPredicateImpl.kt | 39 +++++ .../commonMain/kotlin/query/FilterQuery.kt | 134 ++++++++++++++++++ .../kotlin/query/FilterQueryImpl.kt | 10 ++ .../kotlin/query/FilterQueryPredicate.kt | 72 ++++++++++ .../kotlin/query/FilterQueryPredicateImpl.kt | 39 +++++ .../query/filter/GeopositionalFilterTest.kt | 100 +++++++++++++ 9 files changed, 542 insertions(+) diff --git a/dsl-template/src/commonMain/kotlin/query/FilterQuery.kt b/dsl-template/src/commonMain/kotlin/query/FilterQuery.kt index 54b324e1..1bdd7b72 100644 --- a/dsl-template/src/commonMain/kotlin/query/FilterQuery.kt +++ b/dsl-template/src/commonMain/kotlin/query/FilterQuery.kt @@ -2563,6 +2563,72 @@ interface FilterQuery : CompoundBsonNode, FieldDsl { polygons: Geo.MultiPolygon, ) + // endregion + // region $geoIntersects + + /** + * Matches documents whose geospatial data intersects with the given [geometry]. + * + * ### Example + * + * ```kotlin + * class GasStation( + * val _id: ObjectId, + * val name: String, + * val loc: Geo.Point, + * ) + * + * gasStations.find { + * GasStation::loc.geoIntersects( + * Geo.LineString( + * Geo.Point(Longitude(-105.82), Latitude(33.87)), + * Geo.Point(Longitude(-106.31), Latitude(35.65)), + * Geo.Point(Longitude(-107.39), Latitude(35.98)), + * ) + * ) + * } + * ``` + * + * ### Indexing + * + * This operator does not require a `2dsphere` index. + * However, such an index is recommended for performance. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/geoIntersects/) + * - [Official tutorial](https://www.mongodb.com/docs/manual/core/indexes/index-types/geospatial/2dsphere/query/intersections-of-geojson-objects/) + */ + @ExperimentalGeoBsonApi + fun Field.geoIntersects(geometry: Geo) + + /** + * Matches documents whose geospatial data intersects with the given [polygon]. + * + * ### Indexing + * + * This operator does not require a `2dsphere` index. + * However, such an index is recommended for performance. + * + * ### Big polygons + * + * For queries that specify a polygon with an area greater than a single hemisphere, + * the default [crs] results in queries for the complementary geometry. + * + * In these cases, specify a [crs] of [Geo.CoordinateReferenceSystem.MongoDB]. + * Only [single-ringed polygons][Geo.Polygon] are supported. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/geoIntersects/) + * - [Official tutorial](https://www.mongodb.com/docs/manual/core/indexes/index-types/geospatial/2dsphere/query/intersections-of-geojson-objects/) + */ + @ExperimentalGeoBsonApi + fun Field.geoIntersects( + polygon: Geo.Polygon, + crs: Geo.CoordinateReferenceSystem? = null, + ) + // endregion // endregion } diff --git a/dsl-template/src/commonMain/kotlin/query/FilterQueryImpl.kt b/dsl-template/src/commonMain/kotlin/query/FilterQueryImpl.kt index 6f59dab8..d2817238 100644 --- a/dsl-template/src/commonMain/kotlin/query/FilterQueryImpl.kt +++ b/dsl-template/src/commonMain/kotlin/query/FilterQueryImpl.kt @@ -339,6 +339,16 @@ private class FilterQueryImpl( this { geoWithin(polygons) } } + @ExperimentalGeoBsonApi + override fun Field.geoIntersects(geometry: Geo) { + this { geoIntersects(geometry) } + } + + @ExperimentalGeoBsonApi + override fun Field.geoIntersects(polygon: Geo.Polygon, crs: Geo.CoordinateReferenceSystem?) { + this { geoIntersects(polygon, crs) } + } + // endregion } diff --git a/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicate.kt b/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicate.kt index bb2375bf..1e4f670a 100644 --- a/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicate.kt +++ b/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicate.kt @@ -1215,5 +1215,77 @@ interface FilterQueryPredicate : CompoundBsonNode, FieldDsl { ) // endregion + // region $geoIntersects + + /** + * Matches documents whose geospatial data intersects with the given [geometry]. + * + * This operator should only be called on fields containing GeoJSON data. + * + * ### Example + * + * ```kotlin + * class GasStation( + * val _id: ObjectId, + * val name: String, + * val loc: Geo.Point, + * ) + * + * gasStations.find { + * GasStation::loc { + * geoIntersects( + * Geo.LineString( + * Geo.Point(Longitude(-105.82), Latitude(33.87)), + * Geo.Point(Longitude(-106.31), Latitude(35.65)), + * Geo.Point(Longitude(-107.39), Latitude(35.98)), + * ) + * ) + * } + * } + * ``` + * + * ### Indexing + * + * This operator does not require a `2dsphere` index. + * However, such an index is recommended for performance. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/geoIntersects/) + * - [Official tutorial](https://www.mongodb.com/docs/manual/core/indexes/index-types/geospatial/2dsphere/query/intersections-of-geojson-objects/) + */ + @ExperimentalGeoBsonApi + fun geoIntersects(geometry: Geo) + + /** + * Matches documents whose geospatial data intersects with the given [polygon]. + * + * This operator should only be called on fields containing GeoJSON data. + * + * ### Indexing + * + * This operator does not require a `2dsphere` index. + * However, such an index is recommended for performance. + * + * ### Big polygons + * + * For queries that specify a polygon with an area greater than a single hemisphere, + * the default [crs] results in queries for the complementary geometry. + * + * In these cases, specify a [crs] of [Geo.CoordinateReferenceSystem.MongoDB]. + * Only [single-ringed polygons][Geo.Polygon] are supported. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/geoIntersects/) + * - [Official tutorial](https://www.mongodb.com/docs/manual/core/indexes/index-types/geospatial/2dsphere/query/intersections-of-geojson-objects/) + */ + @ExperimentalGeoBsonApi + fun geoIntersects( + polygon: Geo.Polygon, + crs: Geo.CoordinateReferenceSystem? = null, + ) + + // endregion } diff --git a/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt b/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt index ca51bcbe..3c98412d 100644 --- a/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt +++ b/dsl-template/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt @@ -529,6 +529,45 @@ private class FilterQueryPredicateImpl( } } + @OptIn(DangerousMongoApi::class, LowLevelApi::class) + @ExperimentalGeoBsonApi + override fun geoIntersects(geometry: Geo) { + accept(GeoIntersectsNode(context, geometry, null)) + } + + @OptIn(DangerousMongoApi::class, LowLevelApi::class) + @ExperimentalGeoBsonApi + override fun geoIntersects(polygon: Geo.Polygon, crs: Geo.CoordinateReferenceSystem?) { + accept(GeoIntersectsNode(context, polygon, crs)) + } + + @ExperimentalGeoBsonApi + @LowLevelApi + private class GeoIntersectsNode( + context: BsonContext, + private val geometry: Geo, + private val crs: Geo.CoordinateReferenceSystem?, + ) : PredicateBsonNodeNode(context) { + + @LowLevelApi + override fun write(writer: BsonFieldWriter) = with(writer) { + writeDocument($$"$geoIntersects") { + writeDocument($$"$geometry") { + geometry.writeTo(this) + + if (crs != null) { + writeDocument("crs") { + writeString("type", "name") + writeDocument("properties") { + writeString("name", crs.name) + } + } + } + } + } + } + } + // endregion } diff --git a/dsl/src/commonMain/kotlin/query/FilterQuery.kt b/dsl/src/commonMain/kotlin/query/FilterQuery.kt index f771ceb7..49b9f59c 100644 --- a/dsl/src/commonMain/kotlin/query/FilterQuery.kt +++ b/dsl/src/commonMain/kotlin/query/FilterQuery.kt @@ -142,6 +142,7 @@ import kotlin.reflect.typeOf * - [`$regex`][regex] * * Geopositional query: + * - [`$geoWithin`][geoWithin] * - [`$near`][near] * - [`$nearSphere`][nearSphere] * @@ -4254,6 +4255,139 @@ interface FilterQuery : CompoundBsonNode, FieldDsl { return this.field.geoWithin(polygons) } + // endregion + // region $geoIntersects + + /** + * Matches documents whose geospatial data intersects with the given [geometry]. + * + * ### Example + * + * ```kotlin + * class GasStation( + * val _id: ObjectId, + * val name: String, + * val loc: Geo.Point, + * ) + * + * gasStations.find { + * GasStation::loc.geoIntersects( + * Geo.LineString( + * Geo.Point(Longitude(-105.82), Latitude(33.87)), + * Geo.Point(Longitude(-106.31), Latitude(35.65)), + * Geo.Point(Longitude(-107.39), Latitude(35.98)), + * ) + * ) + * } + * ``` + * + * ### Indexing + * + * This operator does not require a `2dsphere` index. + * However, such an index is recommended for performance. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/geoIntersects/) + * - [Official tutorial](https://www.mongodb.com/docs/manual/core/indexes/index-types/geospatial/2dsphere/query/intersections-of-geojson-objects/) + */ + @ExperimentalGeoBsonApi + fun Field.geoIntersects(geometry: Geo) + + /** + * Matches documents whose geospatial data intersects with the given [geometry]. + * + * ### Example + * + * ```kotlin + * class GasStation( + * val _id: ObjectId, + * val name: String, + * val loc: Geo.Point, + * ) + * + * gasStations.find { + * GasStation::loc.geoIntersects( + * Geo.LineString( + * Geo.Point(Longitude(-105.82), Latitude(33.87)), + * Geo.Point(Longitude(-106.31), Latitude(35.65)), + * Geo.Point(Longitude(-107.39), Latitude(35.98)), + * ) + * ) + * } + * ``` + * + * ### Indexing + * + * This operator does not require a `2dsphere` index. + * However, such an index is recommended for performance. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/geoIntersects/) + * - [Official tutorial](https://www.mongodb.com/docs/manual/core/indexes/index-types/geospatial/2dsphere/query/intersections-of-geojson-objects/) + */ + @ExperimentalGeoBsonApi + fun kotlin.reflect.KProperty1.geoIntersects(geometry: Geo) { + return this.field.geoIntersects(geometry) + } + + /** + * Matches documents whose geospatial data intersects with the given [polygon]. + * + * ### Indexing + * + * This operator does not require a `2dsphere` index. + * However, such an index is recommended for performance. + * + * ### Big polygons + * + * For queries that specify a polygon with an area greater than a single hemisphere, + * the default [crs] results in queries for the complementary geometry. + * + * In these cases, specify a [crs] of [Geo.CoordinateReferenceSystem.MongoDB]. + * Only [single-ringed polygons][Geo.Polygon] are supported. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/geoIntersects/) + * - [Official tutorial](https://www.mongodb.com/docs/manual/core/indexes/index-types/geospatial/2dsphere/query/intersections-of-geojson-objects/) + */ + @ExperimentalGeoBsonApi + fun Field.geoIntersects( + polygon: Geo.Polygon, + crs: Geo.CoordinateReferenceSystem? = null, + ) + + /** + * Matches documents whose geospatial data intersects with the given [polygon]. + * + * ### Indexing + * + * This operator does not require a `2dsphere` index. + * However, such an index is recommended for performance. + * + * ### Big polygons + * + * For queries that specify a polygon with an area greater than a single hemisphere, + * the default [crs] results in queries for the complementary geometry. + * + * In these cases, specify a [crs] of [Geo.CoordinateReferenceSystem.MongoDB]. + * Only [single-ringed polygons][Geo.Polygon] are supported. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/geoIntersects/) + * - [Official tutorial](https://www.mongodb.com/docs/manual/core/indexes/index-types/geospatial/2dsphere/query/intersections-of-geojson-objects/) + */ + @ExperimentalGeoBsonApi + fun kotlin.reflect.KProperty1.geoIntersects( + polygon: Geo.Polygon, + crs: Geo.CoordinateReferenceSystem? = null, + ) { + return this.field.geoIntersects(polygon, crs) + } + // endregion // endregion } diff --git a/dsl/src/commonMain/kotlin/query/FilterQueryImpl.kt b/dsl/src/commonMain/kotlin/query/FilterQueryImpl.kt index db1ae392..3fb336b0 100644 --- a/dsl/src/commonMain/kotlin/query/FilterQueryImpl.kt +++ b/dsl/src/commonMain/kotlin/query/FilterQueryImpl.kt @@ -342,6 +342,16 @@ private class FilterQueryImpl( this { geoWithin(polygons) } } + @ExperimentalGeoBsonApi + override fun Field.geoIntersects(geometry: Geo) { + this { geoIntersects(geometry) } + } + + @ExperimentalGeoBsonApi + override fun Field.geoIntersects(polygon: Geo.Polygon, crs: Geo.CoordinateReferenceSystem?) { + this { geoIntersects(polygon, crs) } + } + // endregion } diff --git a/dsl/src/commonMain/kotlin/query/FilterQueryPredicate.kt b/dsl/src/commonMain/kotlin/query/FilterQueryPredicate.kt index 52c2bf1f..fe7892ad 100644 --- a/dsl/src/commonMain/kotlin/query/FilterQueryPredicate.kt +++ b/dsl/src/commonMain/kotlin/query/FilterQueryPredicate.kt @@ -1218,5 +1218,77 @@ interface FilterQueryPredicate : CompoundBsonNode, FieldDsl { ) // endregion + // region $geoIntersects + + /** + * Matches documents whose geospatial data intersects with the given [geometry]. + * + * This operator should only be called on fields containing GeoJSON data. + * + * ### Example + * + * ```kotlin + * class GasStation( + * val _id: ObjectId, + * val name: String, + * val loc: Geo.Point, + * ) + * + * gasStations.find { + * GasStation::loc { + * geoIntersects( + * Geo.LineString( + * Geo.Point(Longitude(-105.82), Latitude(33.87)), + * Geo.Point(Longitude(-106.31), Latitude(35.65)), + * Geo.Point(Longitude(-107.39), Latitude(35.98)), + * ) + * ) + * } + * } + * ``` + * + * ### Indexing + * + * This operator does not require a `2dsphere` index. + * However, such an index is recommended for performance. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/geoIntersects/) + * - [Official tutorial](https://www.mongodb.com/docs/manual/core/indexes/index-types/geospatial/2dsphere/query/intersections-of-geojson-objects/) + */ + @ExperimentalGeoBsonApi + fun geoIntersects(geometry: Geo) + + /** + * Matches documents whose geospatial data intersects with the given [polygon]. + * + * This operator should only be called on fields containing GeoJSON data. + * + * ### Indexing + * + * This operator does not require a `2dsphere` index. + * However, such an index is recommended for performance. + * + * ### Big polygons + * + * For queries that specify a polygon with an area greater than a single hemisphere, + * the default [crs] results in queries for the complementary geometry. + * + * In these cases, specify a [crs] of [Geo.CoordinateReferenceSystem.MongoDB]. + * Only [single-ringed polygons][Geo.Polygon] are supported. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/geoIntersects/) + * - [Official tutorial](https://www.mongodb.com/docs/manual/core/indexes/index-types/geospatial/2dsphere/query/intersections-of-geojson-objects/) + */ + @ExperimentalGeoBsonApi + fun geoIntersects( + polygon: Geo.Polygon, + crs: Geo.CoordinateReferenceSystem? = null, + ) + + // endregion } diff --git a/dsl/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt b/dsl/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt index f0c31ce1..c102f8b5 100644 --- a/dsl/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt +++ b/dsl/src/commonMain/kotlin/query/FilterQueryPredicateImpl.kt @@ -532,6 +532,45 @@ private class FilterQueryPredicateImpl( } } + @OptIn(DangerousMongoApi::class, LowLevelApi::class) + @ExperimentalGeoBsonApi + override fun geoIntersects(geometry: Geo) { + accept(GeoIntersectsNode(context, geometry, null)) + } + + @OptIn(DangerousMongoApi::class, LowLevelApi::class) + @ExperimentalGeoBsonApi + override fun geoIntersects(polygon: Geo.Polygon, crs: Geo.CoordinateReferenceSystem?) { + accept(GeoIntersectsNode(context, polygon, crs)) + } + + @ExperimentalGeoBsonApi + @LowLevelApi + private class GeoIntersectsNode( + context: BsonContext, + private val geometry: Geo, + private val crs: Geo.CoordinateReferenceSystem?, + ) : PredicateBsonNodeNode(context) { + + @LowLevelApi + override fun write(writer: BsonFieldWriter) = with(writer) { + writeDocument($$"$geoIntersects") { + writeDocument($$"$geometry") { + geometry.writeTo(this) + + if (crs != null) { + writeDocument("crs") { + writeString("type", "name") + writeDocument("properties") { + writeString("name", crs.name) + } + } + } + } + } + } + } + // endregion } diff --git a/dsl/src/commonTest/kotlin/query/filter/GeopositionalFilterTest.kt b/dsl/src/commonTest/kotlin/query/filter/GeopositionalFilterTest.kt index f424c79a..7e5e4891 100644 --- a/dsl/src/commonTest/kotlin/query/filter/GeopositionalFilterTest.kt +++ b/dsl/src/commonTest/kotlin/query/filter/GeopositionalFilterTest.kt @@ -319,4 +319,104 @@ val GeopositionalFilterTest by multiContextSuite { """.trimIndent() } } + + suite($$"$geoIntersects") { + test("Simple") { + filter { + User::home.geoIntersects( + Geo.Polygon( + Geo.Point(Geo.Longitude(0.0), Geo.Latitude(0.0)), + Geo.Point(Geo.Longitude(3.0), Geo.Latitude(6.0)), + Geo.Point(Geo.Longitude(6.0), Geo.Latitude(1.0)), + Geo.Point(Geo.Longitude(0.0), Geo.Latitude(0.0)), + ) + ) + } shouldBeBson $$""" + { + "home": { + "$geoIntersects": { + "$geometry": { + "type": "Polygon", + "coordinates": [ + [ + [0.0, 0.0], + [3.0, 6.0], + [6.0, 1.0], + [0.0, 0.0] + ] + ] + } + } + } + } + """.trimIndent() + } + + test("Large with CRS") { + filter { + User::home.geoIntersects( + Geo.Polygon( + Geo.Point(Geo.Longitude(-100.0), Geo.Latitude(60.0)), + Geo.Point(Geo.Longitude(-100.0), Geo.Latitude(-60.0)), + Geo.Point(Geo.Longitude(100.0), Geo.Latitude(-60.0)), + Geo.Point(Geo.Longitude(100.0), Geo.Latitude(60.0)), + Geo.Point(Geo.Longitude(-100.0), Geo.Latitude(60.0)), + ), + crs = Geo.CoordinateReferenceSystem.MongoDB + ) + } shouldBeBson $$""" + { + "home": { + "$geoIntersects": { + "$geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-100.0, 60.0], + [-100.0, -60.0], + [100.0, -60.0], + [100.0, 60.0], + [-100.0, 60.0] + ] + ], + "crs": { + "type": "name", + "properties": { + "name": "urn:x-mongodb:crs:strictwinding:EPSG:4326" + } + } + } + } + } + } + """.trimIndent() + } + + test("LineString") { + filter { + User::home.geoIntersects( + Geo.LineString( + Geo.Point(Geo.Longitude(-105.82), Geo.Latitude(33.87)), + Geo.Point(Geo.Longitude(-106.31), Geo.Latitude(35.65)), + Geo.Point(Geo.Longitude(-107.39), Geo.Latitude(35.98)), + ) + ) + } shouldBeBson $$""" + { + "home": { + "$geoIntersects": { + "$geometry": { + "type": "LineString", + "coordinates": [ + [-105.82, 33.87], + [-106.31, 35.65], + [-107.39, 35.98] + ] + } + } + } + } + """.trimIndent() + } + } } -- 2.51.2