Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for kotlinx-datetime serializers mapping to BSON #1462

Merged
merged 11 commits into from
Aug 30, 2024
8 changes: 8 additions & 0 deletions bson-kotlinx/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,27 @@ 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"))
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")

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() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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")) {
rozza marked this conversation as resolved.
Show resolved Hide resolved
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<Instant> {
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<Instant>)
}
}

/**
* 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<LocalDate> {
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<LocalDate>)
}
}

/**
* 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<LocalDateTime> {
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<LocalDateTime>)
}
}

/**
* 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<LocalTime> {
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<LocalTime>)
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String>,
Expand Down
2 changes: 2 additions & 0 deletions gradle/publish.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ configure(javaProjects) { project ->
artifact sourcesJar
artifact javadocJar

suppressPomMetadataWarningsFor("dateTimeSupportApiElements")
suppressPomMetadataWarningsFor("dateTimeRuntimeElements")
}
}

Expand Down