From 5050a20e10f86f73d694fd0b3ac21249e32b31e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 23 Nov 2025 16:42:20 +0100 Subject: [PATCH] feat(bson): Create the Geo.Point data type --- .../src/commonMain/kotlin/BsonWriterTest.kt | 2 + .../src/commonMain/kotlin/geo/GeoTest.kt | 60 +++++++ bson/src/commonMain/kotlin/types/Geo.kt | 170 ++++++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 bson-tests/src/commonMain/kotlin/geo/GeoTest.kt create mode 100644 bson/src/commonMain/kotlin/types/Geo.kt diff --git a/bson-tests/src/commonMain/kotlin/BsonWriterTest.kt b/bson-tests/src/commonMain/kotlin/BsonWriterTest.kt index 64264889..56c462fb 100644 --- a/bson-tests/src/commonMain/kotlin/BsonWriterTest.kt +++ b/bson-tests/src/commonMain/kotlin/BsonWriterTest.kt @@ -16,6 +16,7 @@ package opensavvy.ktmongo.bson +import opensavvy.ktmongo.bson.geo.validateGeo import opensavvy.ktmongo.bson.path.verifyBsonPath import opensavvy.ktmongo.bson.types.* import opensavvy.ktmongo.dsl.DangerousMongoApi @@ -168,4 +169,5 @@ fun SuiteDsl.verifyBsonFactory( verifyBsonPath(prepareFactory) verifyDiffAlgorithms(prepareFactory) + validateGeo(prepareFactory) } diff --git a/bson-tests/src/commonMain/kotlin/geo/GeoTest.kt b/bson-tests/src/commonMain/kotlin/geo/GeoTest.kt new file mode 100644 index 00000000..ed58c46b --- /dev/null +++ b/bson-tests/src/commonMain/kotlin/geo/GeoTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025-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, LowLevelApi::class) + +package opensavvy.ktmongo.bson.geo + +import opensavvy.ktmongo.bson.BsonFactory +import opensavvy.ktmongo.bson.decode +import opensavvy.ktmongo.bson.types.BsonDeclaration.Companion.document +import opensavvy.ktmongo.bson.types.BsonDeclaration.Companion.json +import opensavvy.ktmongo.bson.types.BsonDeclaration.Companion.serialize +import opensavvy.ktmongo.bson.types.BsonDeclaration.Companion.verify +import opensavvy.ktmongo.bson.types.ExperimentalGeoBsonApi +import opensavvy.ktmongo.bson.types.Geo +import opensavvy.ktmongo.bson.types.testBson +import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.prepared.suite.Prepared +import opensavvy.prepared.suite.SuiteDsl + +fun SuiteDsl.validateGeo(factory: Prepared) = suite("Geo") { + geoPoint(factory) +} + +private fun SuiteDsl.geoPoint(factory: Prepared) = suite("Point") { + + testBson( + factory, + "Serialize simple point", + serialize(Geo.Point(Geo.Longitude(2.0), Geo.Latitude(3.5))), + document { + writeString("type", "Point") + writeArray("coordinates") { + writeDouble(2.0) + writeDouble(3.5) + } + }, + json("""{"type": "Point", "coordinates": [2.0, 3.5]}"""), + verify("The longitude is correct") { + check(decode().x == Geo.Longitude(2.0)) + }, + verify("The latitude is correct") { + check(decode().y == Geo.Latitude(3.5)) + }, + ) + +} diff --git a/bson/src/commonMain/kotlin/types/Geo.kt b/bson/src/commonMain/kotlin/types/Geo.kt new file mode 100644 index 00000000..ac0bf05b --- /dev/null +++ b/bson/src/commonMain/kotlin/types/Geo.kt @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2025-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. + */ + +package opensavvy.ktmongo.bson.types + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import opensavvy.ktmongo.dsl.LowLevelApi +import kotlin.jvm.JvmInline + +@RequiresOptIn("This API is part of the experimental GeoJSON implementation. Please provide feedback in https://gitlab.com/opensavvy/ktmongo/-/work_items/76") +annotation class ExperimentalGeoBsonApi + +/** + * GeoJSON types supported by MongoDB. + * + * Our goal is to represent the GeoJSON RFC, **as it is implemented by MongoDB**. + * MongoDB does not support large sections of the RFC, so we won't support them either. + * + * GeoJSON operators calculate on a sphere, using the [WGS84 reference system](http://spatialreference.org/ref/epsg/4326/). + * + * ### External resources + * + * - [MongoDB documentation](https://www.mongodb.com/docs/manual/reference/geojson) + * - [GeoJSON RFC](https://datatracker.ietf.org/doc/html/rfc7946) + */ +@OptIn(LowLevelApi::class) +@ExperimentalGeoBsonApi +sealed class Geo { + + /** + * A longitude. + * + * The longitude is measured in degrees, between -180° and 180°, both inclusive. + * + * Positive values are east of the prime meridian, negative values are west. + * + * This type is a helper to avoid confusing [Longitude] and [Latitude], it isn't itself a proper GeoJSON + * type. For this reason, it isn't serializable by itself (but types that contain it, such as [Point], are). + * + * ### Example + * + * ```kotlin + * val bordeaux = Geo.Point( + * Geo.Longitude(-0.5811), + * Geo.Latitude(44.8416), + * ) + * ``` + * + * ### External resources + * + * - [MongoDB documentation](https://www.mongodb.com/docs/manual/reference/geojson/#overview) + * - [GeoJSON RFC](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.1) + */ + @JvmInline + @ExperimentalGeoBsonApi + value class Longitude( + val degrees: Double, + ) { + + init { + require(degrees in -180.0..180.0) { "Valid longitude values are between -180° and 180°, both inclusive, but found: $degrees" } + } + + override fun toString() = "Longitude($degrees°)" + } + + /** + * A latitude. + * + * The latitude is measured in degrees, between -90° and 90°, both inclusive. + * + * Positive values are north of the equator, negative values are south. + * + * This type is a helper to avoid confusing [Longitude] and [Latitude], it isn't itself a proper GeoJSON + * type. For this reason, it isn't serializable by itself (but types that contain it, such as [Point], are). + * + * ### Example + * + * ```kotlin + * val bordeaux = Geo.Point( + * Geo.Longitude(-0.5811), + * Geo.Latitude(44.8416), + * ) + * ``` + * + * ### External resources + * + * - [MongoDB documentation](https://www.mongodb.com/docs/manual/reference/geojson/#overview) + * - [GeoJSON RFC](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.1) + */ + @JvmInline + @ExperimentalGeoBsonApi + value class Latitude( + val degrees: Double, + ) { + + init { + require(degrees in -90.0..90.0) { "Valid latitude values are between -90° and 90°, both inclusive, but found: $degrees" } + } + + override fun toString() = "Latitude($degrees°)" + } + + /** + * A GeoJSON point. + * + * A point is a basic 2d coordinate. + * + * ### Example + * + * ```kotlin + * val point = Geo.Point(Geo.Longitude(0.2), Geo.Latitude(5.3)) + * ``` + * + * ### External resources + * + * - [MongoDB documentation](https://www.mongodb.com/docs/manual/reference/geojson/#point) + * - [GeoJSON RFC](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.2) + */ + @Serializable(with = Point.Serializer::class) + @ExperimentalGeoBsonApi + data class Point( + val x: Longitude, + val y: Latitude, + ) : Geo() { + + override fun toString() = "Point(${x.degrees}° E, ${y.degrees}° N)" + + @Serializable + private data class Surrogate( + val type: String, + val coordinates: List, + ) + + @LowLevelApi + object Serializer : KSerializer { + private val surrogateSerializer = Surrogate.serializer() + override val descriptor: SerialDescriptor = surrogateSerializer.descriptor + + override fun serialize(encoder: Encoder, value: Point) { + surrogateSerializer.serialize(encoder, Surrogate("Point", listOf(value.x.degrees, value.y.degrees))) + } + + override fun deserialize(decoder: Decoder): Point { + val surrogate = surrogateSerializer.deserialize(decoder) + require(surrogate.coordinates.size == 2) { + "Point coordinates must have exactly 2 elements, got ${surrogate.coordinates.size}" + } + return Point(Longitude(surrogate.coordinates[0]), Latitude(surrogate.coordinates[1])) + } + } + } +} -- 2.51.2