diff --git a/bson-kotlinx/build.gradle.kts b/bson-kotlinx/build.gradle.kts index bb9dd42e10b..5f707239581 100644 --- a/bson-kotlinx/build.gradle.kts +++ b/bson-kotlinx/build.gradle.kts @@ -38,6 +38,12 @@ description = "Bson Kotlinx Codecs" ext.set("pomName", "Bson Kotlinx") +ext.set("kotlinxDatetimeVersion", "0.4.0") + +val kotlinxDatetimeVersion: String by ext + +java { registerFeature("dateTimeSupport") { usingSourceSet(sourceSets["main"]) } } + dependencies { // Align versions of all Kotlin components implementation(platform("org.jetbrains.kotlin:kotlin-bom")) @@ -45,12 +51,14 @@ dependencies { implementation(platform("org.jetbrains.kotlinx:kotlinx-serialization-bom:1.5.0")) implementation("org.jetbrains.kotlinx:kotlinx-serialization-core") + "dateTimeSupportImplementation"("org.jetbrains.kotlinx:kotlinx-datetime:$kotlinxDatetimeVersion") api(project(path = ":bson", configuration = "default")) implementation("org.jetbrains.kotlin:kotlin-reflect") testImplementation("org.jetbrains.kotlin:kotlin-test-junit") testImplementation(project(path = ":driver-core", configuration = "default")) + testImplementation("org.jetbrains.kotlinx:kotlinx-datetime:$kotlinxDatetimeVersion") } kotlin { explicitApi() } diff --git a/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/BsonSerializers.kt b/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/BsonSerializers.kt index 05d84b65987..26c19c0fe17 100644 --- a/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/BsonSerializers.kt +++ b/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/BsonSerializers.kt @@ -61,7 +61,7 @@ import org.bson.types.ObjectId */ @ExperimentalSerializationApi public val defaultSerializersModule: SerializersModule = - ObjectIdSerializer.serializersModule + BsonValueSerializer.serializersModule + ObjectIdSerializer.serializersModule + BsonValueSerializer.serializersModule + dateTimeSerializersModule @ExperimentalSerializationApi @Serializer(forClass = ObjectId::class) diff --git a/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/DateTimeSerializers.kt b/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/DateTimeSerializers.kt new file mode 100644 index 00000000000..e3e228ecbfb --- /dev/null +++ b/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/DateTimeSerializers.kt @@ -0,0 +1,216 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * 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 org.bson.codecs.kotlinx + +import java.time.ZoneOffset +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.UtcOffset +import kotlinx.datetime.atDate +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.plus +import org.bson.BsonDateTime +import org.bson.codecs.kotlinx.utils.SerializationModuleUtils.isClassAvailable + +/** + * The default serializers module + * + * Handles: + * - ObjectId serialization + * - BsonValue serialization + * - Instant serialization + * - LocalDate serialization + * - LocalDateTime serialization + * - LocalTime serialization + */ +@ExperimentalSerializationApi +public val dateTimeSerializersModule: SerializersModule by lazy { + var module = SerializersModule {} + if (isClassAvailable("kotlinx.datetime.Instant")) { + module += + InstantAsBsonDateTime.serializersModule + + LocalDateAsBsonDateTime.serializersModule + + LocalDateTimeAsBsonDateTime.serializersModule + + LocalTimeAsBsonDateTime.serializersModule + } + module +} + +/** + * Instant KSerializer. + * + * Encodes and decodes `Instant` objects to and from `BsonDateTime`. Data is extracted via + * [kotlinx.datetime.Instant.fromEpochMilliseconds] and stored to millisecond accuracy. + * + * @since 5.2 + */ +@ExperimentalSerializationApi +public object InstantAsBsonDateTime : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("InstantAsBsonDateTime", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Instant) { + when (encoder) { + is BsonEncoder -> encoder.encodeBsonValue(BsonDateTime(value.toEpochMilliseconds())) + else -> throw SerializationException("Instant is not supported by ${encoder::class}") + } + } + + override fun deserialize(decoder: Decoder): Instant { + return when (decoder) { + is BsonDecoder -> Instant.fromEpochMilliseconds(decoder.decodeBsonValue().asDateTime().value) + else -> throw SerializationException("Instant is not supported by ${decoder::class}") + } + } + + @Suppress("UNCHECKED_CAST") + public val serializersModule: SerializersModule = SerializersModule { + contextual(Instant::class, InstantAsBsonDateTime as KSerializer) + } +} + +/** + * LocalDate KSerializer. + * + * Encodes and decodes `LocalDate` objects to and from `BsonDateTime`. + * + * Converts the `LocalDate` values to and from `UTC`. + * + * @since 5.2 + */ +@ExperimentalSerializationApi +public object LocalDateAsBsonDateTime : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("LocalDateAsBsonDateTime", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: LocalDate) { + when (encoder) { + is BsonEncoder -> { + val epochMillis = value.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() + encoder.encodeBsonValue(BsonDateTime(epochMillis)) + } + else -> throw SerializationException("LocalDate is not supported by ${encoder::class}") + } + } + + override fun deserialize(decoder: Decoder): LocalDate { + return when (decoder) { + is BsonDecoder -> + Instant.fromEpochMilliseconds(decoder.decodeBsonValue().asDateTime().value) + .toLocalDateTime(TimeZone.UTC) + .date + else -> throw SerializationException("LocalDate is not supported by ${decoder::class}") + } + } + + @Suppress("UNCHECKED_CAST") + public val serializersModule: SerializersModule = SerializersModule { + contextual(LocalDate::class, LocalDateAsBsonDateTime as KSerializer) + } +} + +/** + * LocalDateTime KSerializer. + * + * Encodes and decodes `LocalDateTime` objects to and from `BsonDateTime`. Data is stored to millisecond accuracy. + * + * Converts the `LocalDateTime` values to and from `UTC`. + * + * @since 5.2 + */ +@ExperimentalSerializationApi +public object LocalDateTimeAsBsonDateTime : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("LocalDateTimeAsBsonDateTime", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: LocalDateTime) { + when (encoder) { + is BsonEncoder -> { + val epochMillis = value.toInstant(UtcOffset(ZoneOffset.UTC)).toEpochMilliseconds() + encoder.encodeBsonValue(BsonDateTime(epochMillis)) + } + else -> throw SerializationException("LocalDateTime is not supported by ${encoder::class}") + } + } + + override fun deserialize(decoder: Decoder): LocalDateTime { + return when (decoder) { + is BsonDecoder -> + Instant.fromEpochMilliseconds(decoder.decodeBsonValue().asDateTime().value) + .toLocalDateTime(TimeZone.UTC) + else -> throw SerializationException("LocalDateTime is not supported by ${decoder::class}") + } + } + + @Suppress("UNCHECKED_CAST") + public val serializersModule: SerializersModule = SerializersModule { + contextual(LocalDateTime::class, LocalDateTimeAsBsonDateTime as KSerializer) + } +} + +/** + * LocalTime KSerializer. + * + * Encodes and decodes `LocalTime` objects to and from `BsonDateTime`. Data is stored to millisecond accuracy. + * + * Converts the `LocalTime` values to and from EpochDay at `UTC`. + * + * @since 5.2 + */ +@ExperimentalSerializationApi +public object LocalTimeAsBsonDateTime : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("LocalTimeAsBsonDateTime", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: LocalTime) { + when (encoder) { + is BsonEncoder -> { + val epochMillis = + value.atDate(LocalDate.fromEpochDays(0)).toInstant(UtcOffset(ZoneOffset.UTC)).toEpochMilliseconds() + encoder.encodeBsonValue(BsonDateTime(epochMillis)) + } + else -> throw SerializationException("LocalTime is not supported by ${encoder::class}") + } + } + + override fun deserialize(decoder: Decoder): LocalTime { + return when (decoder) { + is BsonDecoder -> + Instant.fromEpochMilliseconds(decoder.decodeBsonValue().asDateTime().value) + .toLocalDateTime(TimeZone.UTC) + .time + else -> throw SerializationException("LocalTime is not supported by ${decoder::class}") + } + } + + @Suppress("UNCHECKED_CAST") + public val serializersModule: SerializersModule = SerializersModule { + contextual(LocalTime::class, LocalTimeAsBsonDateTime as KSerializer) + } +} diff --git a/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/utils/SerializationModuleUtils.kt b/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/utils/SerializationModuleUtils.kt new file mode 100644 index 00000000000..306644c81ad --- /dev/null +++ b/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/utils/SerializationModuleUtils.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * 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 org.bson.codecs.kotlinx.utils + +internal object SerializationModuleUtils { + @Suppress("SwallowedException") + fun isClassAvailable(className: String): Boolean { + return try { + Class.forName(className) + true + } catch (e: ClassNotFoundException) { + false + } + } +} diff --git a/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/KotlinSerializerCodecTest.kt b/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/KotlinSerializerCodecTest.kt index 05a0d3ffd7d..e9d3742db10 100644 --- a/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/KotlinSerializerCodecTest.kt +++ b/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/KotlinSerializerCodecTest.kt @@ -17,6 +17,10 @@ package org.bson.codecs.kotlinx import java.util.stream.Stream import kotlin.test.assertEquals +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.MissingFieldException import kotlinx.serialization.SerializationException @@ -72,7 +76,9 @@ import org.bson.codecs.kotlinx.samples.DataClassWithBsonIgnore import org.bson.codecs.kotlinx.samples.DataClassWithBsonProperty import org.bson.codecs.kotlinx.samples.DataClassWithBsonRepresentation import org.bson.codecs.kotlinx.samples.DataClassWithCollections +import org.bson.codecs.kotlinx.samples.DataClassWithContextualDateValues import org.bson.codecs.kotlinx.samples.DataClassWithDataClassMapKey +import org.bson.codecs.kotlinx.samples.DataClassWithDateValues import org.bson.codecs.kotlinx.samples.DataClassWithDefaults import org.bson.codecs.kotlinx.samples.DataClassWithEmbedded import org.bson.codecs.kotlinx.samples.DataClassWithEncodeDefault @@ -198,6 +204,46 @@ class KotlinSerializerCodecTest { assertDecodesTo(data, expectedDataClass) } + @Test + fun testDataClassWithDateValuesContextualSerialization() { + val expected = + "{\n" + + " \"instant\": {\"\$date\": \"2001-09-09T01:46:40Z\"}, \n" + + " \"localTime\": {\"\$date\": \"1970-01-01T00:00:10Z\"}, \n" + + " \"localDateTime\": {\"\$date\": \"2021-01-01T00:00:04Z\"}, \n" + + " \"localDate\": {\"\$date\": \"1970-10-28T00:00:00Z\"}\n" + + "}".trimMargin() + + val expectedDataClass = + DataClassWithContextualDateValues( + Instant.fromEpochMilliseconds(10_000_000_000_00), + LocalTime.fromMillisecondOfDay(10_000), + LocalDateTime.parse("2021-01-01T00:00:04"), + LocalDate.fromEpochDays(300)) + + assertRoundTrips(expected, expectedDataClass) + } + + @Test + fun testDataClassWithDateValuesStandard() { + val expected = + "{\n" + + " \"instant\": \"1970-01-01T00:00:01Z\", \n" + + " \"localTime\": \"00:00:01\", \n" + + " \"localDateTime\": \"2021-01-01T00:00:04\", \n" + + " \"localDate\": \"1970-01-02\"\n" + + "}".trimMargin() + + val expectedDataClass = + DataClassWithDateValues( + Instant.fromEpochMilliseconds(1000), + LocalTime.fromMillisecondOfDay(1000), + LocalDateTime.parse("2021-01-01T00:00:04"), + LocalDate.fromEpochDays(1)) + + assertRoundTrips(expected, expectedDataClass) + } + @Test fun testDataClassWithComplexTypes() { val expected = diff --git a/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/samples/DataClasses.kt b/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/samples/DataClasses.kt index 66907bff103..cbdf41ab2f3 100644 --- a/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/samples/DataClasses.kt +++ b/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/samples/DataClasses.kt @@ -15,6 +15,10 @@ */ package org.bson.codecs.kotlinx.samples +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime import kotlinx.serialization.Contextual import kotlinx.serialization.EncodeDefault import kotlinx.serialization.ExperimentalSerializationApi @@ -63,6 +67,22 @@ data class DataClassWithSimpleValues( val string: String ) +@Serializable +data class DataClassWithContextualDateValues( + @Contextual val instant: Instant, + @Contextual val localTime: LocalTime, + @Contextual val localDateTime: LocalDateTime, + @Contextual val localDate: LocalDate, +) + +@Serializable +data class DataClassWithDateValues( + val instant: Instant, + val localTime: LocalTime, + val localDateTime: LocalDateTime, + val localDate: LocalDate, +) + @Serializable data class DataClassWithCollections( val listSimple: List, diff --git a/gradle/publish.gradle b/gradle/publish.gradle index 498184db983..25edda53f49 100644 --- a/gradle/publish.gradle +++ b/gradle/publish.gradle @@ -100,6 +100,8 @@ configure(javaProjects) { project -> artifact sourcesJar artifact javadocJar + suppressPomMetadataWarningsFor("dateTimeSupportApiElements") + suppressPomMetadataWarningsFor("dateTimeRuntimeElements") } }