Skip to content

Commit c4af1c5

Browse files
vbabaninrozza
andauthoredAug 30, 2024
Add support for kotlinx-datetime serializers mapping to BSON (#1462)
- Add kotlinx-datetime serializers that map to BSON as the expected types. - Add kotlinx-datetime as optional dependency. - Make it easily configurable via `@Contextual` annotation. JAVA-5330 --------- Co-authored-by: Ross Lawley <[email protected]>
1 parent 86863c9 commit c4af1c5

File tree

7 files changed

+321
-1
lines changed

7 files changed

+321
-1
lines changed
 

‎bson-kotlinx/build.gradle.kts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,19 +38,27 @@ description = "Bson Kotlinx Codecs"
3838

3939
ext.set("pomName", "Bson Kotlinx")
4040

41+
ext.set("kotlinxDatetimeVersion", "0.4.0")
42+
43+
val kotlinxDatetimeVersion: String by ext
44+
45+
java { registerFeature("dateTimeSupport") { usingSourceSet(sourceSets["main"]) } }
46+
4147
dependencies {
4248
// Align versions of all Kotlin components
4349
implementation(platform("org.jetbrains.kotlin:kotlin-bom"))
4450
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
4551

4652
implementation(platform("org.jetbrains.kotlinx:kotlinx-serialization-bom:1.5.0"))
4753
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core")
54+
"dateTimeSupportImplementation"("org.jetbrains.kotlinx:kotlinx-datetime:$kotlinxDatetimeVersion")
4855

4956
api(project(path = ":bson", configuration = "default"))
5057
implementation("org.jetbrains.kotlin:kotlin-reflect")
5158

5259
testImplementation("org.jetbrains.kotlin:kotlin-test-junit")
5360
testImplementation(project(path = ":driver-core", configuration = "default"))
61+
testImplementation("org.jetbrains.kotlinx:kotlinx-datetime:$kotlinxDatetimeVersion")
5462
}
5563

5664
kotlin { explicitApi() }

‎bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/BsonSerializers.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ import org.bson.types.ObjectId
6161
*/
6262
@ExperimentalSerializationApi
6363
public val defaultSerializersModule: SerializersModule =
64-
ObjectIdSerializer.serializersModule + BsonValueSerializer.serializersModule
64+
ObjectIdSerializer.serializersModule + BsonValueSerializer.serializersModule + dateTimeSerializersModule
6565

6666
@ExperimentalSerializationApi
6767
@Serializer(forClass = ObjectId::class)
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
/*
2+
* Copyright 2008-present MongoDB, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.bson.codecs.kotlinx
17+
18+
import java.time.ZoneOffset
19+
import kotlinx.datetime.Instant
20+
import kotlinx.datetime.LocalDate
21+
import kotlinx.datetime.LocalDateTime
22+
import kotlinx.datetime.LocalTime
23+
import kotlinx.datetime.TimeZone
24+
import kotlinx.datetime.UtcOffset
25+
import kotlinx.datetime.atDate
26+
import kotlinx.datetime.atStartOfDayIn
27+
import kotlinx.datetime.toInstant
28+
import kotlinx.datetime.toLocalDateTime
29+
import kotlinx.serialization.ExperimentalSerializationApi
30+
import kotlinx.serialization.KSerializer
31+
import kotlinx.serialization.SerializationException
32+
import kotlinx.serialization.descriptors.PrimitiveKind
33+
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
34+
import kotlinx.serialization.descriptors.SerialDescriptor
35+
import kotlinx.serialization.encoding.Decoder
36+
import kotlinx.serialization.encoding.Encoder
37+
import kotlinx.serialization.modules.SerializersModule
38+
import kotlinx.serialization.modules.plus
39+
import org.bson.BsonDateTime
40+
import org.bson.codecs.kotlinx.utils.SerializationModuleUtils.isClassAvailable
41+
42+
/**
43+
* The default serializers module
44+
*
45+
* Handles:
46+
* - ObjectId serialization
47+
* - BsonValue serialization
48+
* - Instant serialization
49+
* - LocalDate serialization
50+
* - LocalDateTime serialization
51+
* - LocalTime serialization
52+
*/
53+
@ExperimentalSerializationApi
54+
public val dateTimeSerializersModule: SerializersModule by lazy {
55+
var module = SerializersModule {}
56+
if (isClassAvailable("kotlinx.datetime.Instant")) {
57+
module +=
58+
InstantAsBsonDateTime.serializersModule +
59+
LocalDateAsBsonDateTime.serializersModule +
60+
LocalDateTimeAsBsonDateTime.serializersModule +
61+
LocalTimeAsBsonDateTime.serializersModule
62+
}
63+
module
64+
}
65+
66+
/**
67+
* Instant KSerializer.
68+
*
69+
* Encodes and decodes `Instant` objects to and from `BsonDateTime`. Data is extracted via
70+
* [kotlinx.datetime.Instant.fromEpochMilliseconds] and stored to millisecond accuracy.
71+
*
72+
* @since 5.2
73+
*/
74+
@ExperimentalSerializationApi
75+
public object InstantAsBsonDateTime : KSerializer<Instant> {
76+
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("InstantAsBsonDateTime", PrimitiveKind.STRING)
77+
78+
override fun serialize(encoder: Encoder, value: Instant) {
79+
when (encoder) {
80+
is BsonEncoder -> encoder.encodeBsonValue(BsonDateTime(value.toEpochMilliseconds()))
81+
else -> throw SerializationException("Instant is not supported by ${encoder::class}")
82+
}
83+
}
84+
85+
override fun deserialize(decoder: Decoder): Instant {
86+
return when (decoder) {
87+
is BsonDecoder -> Instant.fromEpochMilliseconds(decoder.decodeBsonValue().asDateTime().value)
88+
else -> throw SerializationException("Instant is not supported by ${decoder::class}")
89+
}
90+
}
91+
92+
@Suppress("UNCHECKED_CAST")
93+
public val serializersModule: SerializersModule = SerializersModule {
94+
contextual(Instant::class, InstantAsBsonDateTime as KSerializer<Instant>)
95+
}
96+
}
97+
98+
/**
99+
* LocalDate KSerializer.
100+
*
101+
* Encodes and decodes `LocalDate` objects to and from `BsonDateTime`.
102+
*
103+
* Converts the `LocalDate` values to and from `UTC`.
104+
*
105+
* @since 5.2
106+
*/
107+
@ExperimentalSerializationApi
108+
public object LocalDateAsBsonDateTime : KSerializer<LocalDate> {
109+
override val descriptor: SerialDescriptor =
110+
PrimitiveSerialDescriptor("LocalDateAsBsonDateTime", PrimitiveKind.STRING)
111+
112+
override fun serialize(encoder: Encoder, value: LocalDate) {
113+
when (encoder) {
114+
is BsonEncoder -> {
115+
val epochMillis = value.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
116+
encoder.encodeBsonValue(BsonDateTime(epochMillis))
117+
}
118+
else -> throw SerializationException("LocalDate is not supported by ${encoder::class}")
119+
}
120+
}
121+
122+
override fun deserialize(decoder: Decoder): LocalDate {
123+
return when (decoder) {
124+
is BsonDecoder ->
125+
Instant.fromEpochMilliseconds(decoder.decodeBsonValue().asDateTime().value)
126+
.toLocalDateTime(TimeZone.UTC)
127+
.date
128+
else -> throw SerializationException("LocalDate is not supported by ${decoder::class}")
129+
}
130+
}
131+
132+
@Suppress("UNCHECKED_CAST")
133+
public val serializersModule: SerializersModule = SerializersModule {
134+
contextual(LocalDate::class, LocalDateAsBsonDateTime as KSerializer<LocalDate>)
135+
}
136+
}
137+
138+
/**
139+
* LocalDateTime KSerializer.
140+
*
141+
* Encodes and decodes `LocalDateTime` objects to and from `BsonDateTime`. Data is stored to millisecond accuracy.
142+
*
143+
* Converts the `LocalDateTime` values to and from `UTC`.
144+
*
145+
* @since 5.2
146+
*/
147+
@ExperimentalSerializationApi
148+
public object LocalDateTimeAsBsonDateTime : KSerializer<LocalDateTime> {
149+
override val descriptor: SerialDescriptor =
150+
PrimitiveSerialDescriptor("LocalDateTimeAsBsonDateTime", PrimitiveKind.STRING)
151+
152+
override fun serialize(encoder: Encoder, value: LocalDateTime) {
153+
when (encoder) {
154+
is BsonEncoder -> {
155+
val epochMillis = value.toInstant(UtcOffset(ZoneOffset.UTC)).toEpochMilliseconds()
156+
encoder.encodeBsonValue(BsonDateTime(epochMillis))
157+
}
158+
else -> throw SerializationException("LocalDateTime is not supported by ${encoder::class}")
159+
}
160+
}
161+
162+
override fun deserialize(decoder: Decoder): LocalDateTime {
163+
return when (decoder) {
164+
is BsonDecoder ->
165+
Instant.fromEpochMilliseconds(decoder.decodeBsonValue().asDateTime().value)
166+
.toLocalDateTime(TimeZone.UTC)
167+
else -> throw SerializationException("LocalDateTime is not supported by ${decoder::class}")
168+
}
169+
}
170+
171+
@Suppress("UNCHECKED_CAST")
172+
public val serializersModule: SerializersModule = SerializersModule {
173+
contextual(LocalDateTime::class, LocalDateTimeAsBsonDateTime as KSerializer<LocalDateTime>)
174+
}
175+
}
176+
177+
/**
178+
* LocalTime KSerializer.
179+
*
180+
* Encodes and decodes `LocalTime` objects to and from `BsonDateTime`. Data is stored to millisecond accuracy.
181+
*
182+
* Converts the `LocalTime` values to and from EpochDay at `UTC`.
183+
*
184+
* @since 5.2
185+
*/
186+
@ExperimentalSerializationApi
187+
public object LocalTimeAsBsonDateTime : KSerializer<LocalTime> {
188+
override val descriptor: SerialDescriptor =
189+
PrimitiveSerialDescriptor("LocalTimeAsBsonDateTime", PrimitiveKind.STRING)
190+
191+
override fun serialize(encoder: Encoder, value: LocalTime) {
192+
when (encoder) {
193+
is BsonEncoder -> {
194+
val epochMillis =
195+
value.atDate(LocalDate.fromEpochDays(0)).toInstant(UtcOffset(ZoneOffset.UTC)).toEpochMilliseconds()
196+
encoder.encodeBsonValue(BsonDateTime(epochMillis))
197+
}
198+
else -> throw SerializationException("LocalTime is not supported by ${encoder::class}")
199+
}
200+
}
201+
202+
override fun deserialize(decoder: Decoder): LocalTime {
203+
return when (decoder) {
204+
is BsonDecoder ->
205+
Instant.fromEpochMilliseconds(decoder.decodeBsonValue().asDateTime().value)
206+
.toLocalDateTime(TimeZone.UTC)
207+
.time
208+
else -> throw SerializationException("LocalTime is not supported by ${decoder::class}")
209+
}
210+
}
211+
212+
@Suppress("UNCHECKED_CAST")
213+
public val serializersModule: SerializersModule = SerializersModule {
214+
contextual(LocalTime::class, LocalTimeAsBsonDateTime as KSerializer<LocalTime>)
215+
}
216+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* Copyright 2008-present MongoDB, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.bson.codecs.kotlinx.utils
17+
18+
internal object SerializationModuleUtils {
19+
@Suppress("SwallowedException")
20+
fun isClassAvailable(className: String): Boolean {
21+
return try {
22+
Class.forName(className)
23+
true
24+
} catch (e: ClassNotFoundException) {
25+
false
26+
}
27+
}
28+
}

‎bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/KotlinSerializerCodecTest.kt

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ package org.bson.codecs.kotlinx
1717

1818
import java.util.stream.Stream
1919
import kotlin.test.assertEquals
20+
import kotlinx.datetime.Instant
21+
import kotlinx.datetime.LocalDate
22+
import kotlinx.datetime.LocalDateTime
23+
import kotlinx.datetime.LocalTime
2024
import kotlinx.serialization.ExperimentalSerializationApi
2125
import kotlinx.serialization.MissingFieldException
2226
import kotlinx.serialization.SerializationException
@@ -72,7 +76,9 @@ import org.bson.codecs.kotlinx.samples.DataClassWithBsonIgnore
7276
import org.bson.codecs.kotlinx.samples.DataClassWithBsonProperty
7377
import org.bson.codecs.kotlinx.samples.DataClassWithBsonRepresentation
7478
import org.bson.codecs.kotlinx.samples.DataClassWithCollections
79+
import org.bson.codecs.kotlinx.samples.DataClassWithContextualDateValues
7580
import org.bson.codecs.kotlinx.samples.DataClassWithDataClassMapKey
81+
import org.bson.codecs.kotlinx.samples.DataClassWithDateValues
7682
import org.bson.codecs.kotlinx.samples.DataClassWithDefaults
7783
import org.bson.codecs.kotlinx.samples.DataClassWithEmbedded
7884
import org.bson.codecs.kotlinx.samples.DataClassWithEncodeDefault
@@ -198,6 +204,46 @@ class KotlinSerializerCodecTest {
198204
assertDecodesTo(data, expectedDataClass)
199205
}
200206

207+
@Test
208+
fun testDataClassWithDateValuesContextualSerialization() {
209+
val expected =
210+
"{\n" +
211+
" \"instant\": {\"\$date\": \"2001-09-09T01:46:40Z\"}, \n" +
212+
" \"localTime\": {\"\$date\": \"1970-01-01T00:00:10Z\"}, \n" +
213+
" \"localDateTime\": {\"\$date\": \"2021-01-01T00:00:04Z\"}, \n" +
214+
" \"localDate\": {\"\$date\": \"1970-10-28T00:00:00Z\"}\n" +
215+
"}".trimMargin()
216+
217+
val expectedDataClass =
218+
DataClassWithContextualDateValues(
219+
Instant.fromEpochMilliseconds(10_000_000_000_00),
220+
LocalTime.fromMillisecondOfDay(10_000),
221+
LocalDateTime.parse("2021-01-01T00:00:04"),
222+
LocalDate.fromEpochDays(300))
223+
224+
assertRoundTrips(expected, expectedDataClass)
225+
}
226+
227+
@Test
228+
fun testDataClassWithDateValuesStandard() {
229+
val expected =
230+
"{\n" +
231+
" \"instant\": \"1970-01-01T00:00:01Z\", \n" +
232+
" \"localTime\": \"00:00:01\", \n" +
233+
" \"localDateTime\": \"2021-01-01T00:00:04\", \n" +
234+
" \"localDate\": \"1970-01-02\"\n" +
235+
"}".trimMargin()
236+
237+
val expectedDataClass =
238+
DataClassWithDateValues(
239+
Instant.fromEpochMilliseconds(1000),
240+
LocalTime.fromMillisecondOfDay(1000),
241+
LocalDateTime.parse("2021-01-01T00:00:04"),
242+
LocalDate.fromEpochDays(1))
243+
244+
assertRoundTrips(expected, expectedDataClass)
245+
}
246+
201247
@Test
202248
fun testDataClassWithComplexTypes() {
203249
val expected =

‎bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/samples/DataClasses.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
*/
1616
package org.bson.codecs.kotlinx.samples
1717

18+
import kotlinx.datetime.Instant
19+
import kotlinx.datetime.LocalDate
20+
import kotlinx.datetime.LocalDateTime
21+
import kotlinx.datetime.LocalTime
1822
import kotlinx.serialization.Contextual
1923
import kotlinx.serialization.EncodeDefault
2024
import kotlinx.serialization.ExperimentalSerializationApi
@@ -63,6 +67,22 @@ data class DataClassWithSimpleValues(
6367
val string: String
6468
)
6569

70+
@Serializable
71+
data class DataClassWithContextualDateValues(
72+
@Contextual val instant: Instant,
73+
@Contextual val localTime: LocalTime,
74+
@Contextual val localDateTime: LocalDateTime,
75+
@Contextual val localDate: LocalDate,
76+
)
77+
78+
@Serializable
79+
data class DataClassWithDateValues(
80+
val instant: Instant,
81+
val localTime: LocalTime,
82+
val localDateTime: LocalDateTime,
83+
val localDate: LocalDate,
84+
)
85+
6686
@Serializable
6787
data class DataClassWithCollections(
6888
val listSimple: List<String>,

‎gradle/publish.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ configure(javaProjects) { project ->
100100
artifact sourcesJar
101101
artifact javadocJar
102102

103+
suppressPomMetadataWarningsFor("dateTimeSupportApiElements")
104+
suppressPomMetadataWarningsFor("dateTimeRuntimeElements")
103105
}
104106
}
105107

0 commit comments

Comments
 (0)
Please sign in to comment.