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
1 change: 1 addition & 0 deletions bson-kotlinx/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ dependencies {

implementation(platform("org.jetbrains.kotlinx:kotlinx-serialization-bom:1.5.0"))
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0")
Copy link
Member Author

@vbabanin vbabanin Jul 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This version is compatible with Kotlin versions 1.5 and later. The next version 0.5.0 requires Kotlin 1.9 or higher, as indicated by this commit:

Commit details on kotlinx-datetime

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't take a hard dependency on this library - so this will need refactoring to make it optional. #1459 adds a check for kotlinx-json support, you could use that style to add an if statement for the defaultSerializersModule

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have refactored it to make the library optional, using the approach outlined in #1459 for checking kotlinx-datetime support. Thanks!


api(project(path = ":bson", configuration = "default"))
implementation("org.jetbrains.kotlin:kotlin-reflect")
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,212 @@
/*
* 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

/**
* The default serializers module
*
* Handles:
* - ObjectId serialization
* - BsonValue serialization
* - Instant serialization
* - LocalDate serialization
* - LocalDateTime serialization
* - LocalTime serialization
*/
@ExperimentalSerializationApi
public val dateTimeSerializersModule: SerializersModule =
InstantAsBsonDateTime.serializersModule +
LocalDateAsBsonDateTime.serializersModule +
LocalDateTimeAsBsonDateTime.serializersModule +
LocalTimeAsBsonDateTime.serializersModule

/**
* Instant KSerializer.
*
* <p>
* Encodes and decodes {@code Instant} objects to and from {@code BsonDateTime}. Data is extracted via {@link
* Instant#fromEpochMilliseconds()} and stored to millisecond accuracy. </p>
*
* @since 5.2
*/
vbabanin marked this conversation as resolved.
Show resolved Hide resolved
@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.
*
* <p>Encodes and decodes {@code LocalDate} objects to and from {@code BsonDateTime}.</p>
*
* <p>Converts the {@code LocalDate} values to and from {@code UTC}.</p>
rozza marked this conversation as resolved.
Show resolved Hide resolved
*
* @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.
*
* <p>Encodes and decodes {@code LocalDateTime} objects to and from {@code BsonDateTime}. Data is stored to millisecond
* accuracy.</p>
*
* <p>Converts the {@code LocalDateTime} values to and from {@code UTC}.</p>
*
* @since 5.2
rozza marked this conversation as resolved.
Show resolved Hide resolved
*/
@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.
*
* <p>Encodes and decodes {@code LocalTime} objects to and from {@code BsonDateTime}. Data is stored to millisecond
* accuracy.</p>
*
* <p>Converts the {@code LocalTime} values to and from EpochDay at {@code UTC}.</p>
rozza marked this conversation as resolved.
Show resolved Hide resolved
*
* @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
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 @@ -71,7 +75,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 @@ -196,6 +202,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