Skip to content

Commit 48fdf2a

Browse files
nhachichaleesungbinrozza
authoredMar 19, 2025
JAVA-5736 Add bsonNamingStrategy option to support snake_case (#1627)
Add bsonNamingStrategy option to support snake_case naming strategy --------- Co-authored-by: leesungbin <lee@sungbin.dev> Co-authored-by: Ross Lawley <ross.lawley@gmail.com>
1 parent 2849421 commit 48fdf2a

File tree

9 files changed

+192
-2
lines changed

9 files changed

+192
-2
lines changed
 

‎THIRD-PARTY-NOTICES

+16
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,19 @@ https://github.com/mongodb/mongo-java-driver.
184184
See the License for the specific language governing permissions and
185185
limitations under the License.
186186

187+
9) The following files: BsonCodecUtils.kt
188+
189+
Copyright 2008-present MongoDB, Inc.
190+
Copyright 2017-2021 JetBrains s.r.o.
191+
192+
Licensed under the Apache License, Version 2.0 (the "License");
193+
you may not use this file except in compliance with the License.
194+
You may obtain a copy of the License at
195+
196+
http://www.apache.org/licenses/LICENSE-2.0
197+
198+
Unless required by applicable law or agreed to in writing, software
199+
distributed under the License is distributed on an "AS IS" BASIS,
200+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201+
See the License for the specific language governing permissions and
202+
limitations under the License.

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

+15
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,19 @@ public data class BsonConfiguration(
3131
val encodeDefaults: Boolean = true,
3232
val explicitNulls: Boolean = false,
3333
val classDiscriminator: String = "_t",
34+
val bsonNamingStrategy: BsonNamingStrategy? = null
3435
)
36+
37+
/**
38+
* Optional BSON naming strategy for a field.
39+
*
40+
* @since 5.4
41+
*/
42+
public enum class BsonNamingStrategy {
43+
44+
/**
45+
* A strategy that transforms serial names from camel case to snake case — lowercase characters with words separated
46+
* by underscores.
47+
*/
48+
SNAKE_CASE,
49+
}

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

+10-1
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,13 @@ import org.bson.BsonType
3737
import org.bson.BsonValue
3838
import org.bson.codecs.BsonValueCodec
3939
import org.bson.codecs.DecoderContext
40+
import org.bson.codecs.kotlinx.utils.BsonCodecUtils.cacheElementNamesByDescriptor
4041
import org.bson.codecs.kotlinx.utils.BsonCodecUtils.createBsonArrayDecoder
4142
import org.bson.codecs.kotlinx.utils.BsonCodecUtils.createBsonDecoder
4243
import org.bson.codecs.kotlinx.utils.BsonCodecUtils.createBsonDocumentDecoder
4344
import org.bson.codecs.kotlinx.utils.BsonCodecUtils.createBsonMapDecoder
4445
import org.bson.codecs.kotlinx.utils.BsonCodecUtils.createBsonPolymorphicDecoder
46+
import org.bson.codecs.kotlinx.utils.BsonCodecUtils.getCachedElementNamesByDescriptor
4547
import org.bson.internal.NumberCodecHelper
4648
import org.bson.internal.StringCodecHelper
4749
import org.bson.types.ObjectId
@@ -102,6 +104,7 @@ internal sealed class AbstractBsonDecoder(
102104
elementDescriptor.serialName, elementDescriptor.isNullable && !descriptor.isElementOptional(it))
103105
}
104106
this.elementsMetadata = elementsMetadata
107+
cacheElementNamesByDescriptor(descriptor, configuration)
105108
}
106109

107110
override fun decodeElementIndex(descriptor: SerialDescriptor): Int {
@@ -129,7 +132,13 @@ internal sealed class AbstractBsonDecoder(
129132
}
130133

131134
return name?.let {
132-
val index = descriptor.getElementIndex(it)
135+
val index =
136+
if (configuration.bsonNamingStrategy == BsonNamingStrategy.SNAKE_CASE) {
137+
getCachedElementNamesByDescriptor(descriptor)[it]?.let { name -> descriptor.getElementIndex(name) }
138+
?: UNKNOWN_NAME
139+
} else {
140+
descriptor.getElementIndex(it)
141+
}
133142
return if (index == UNKNOWN_NAME) {
134143
reader.skipValue()
135144
decodeElementIndexImpl(descriptor)

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

+10-1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import org.bson.BsonValue
3131
import org.bson.BsonWriter
3232
import org.bson.codecs.BsonValueCodec
3333
import org.bson.codecs.EncoderContext
34+
import org.bson.codecs.kotlinx.utils.BsonCodecUtils.convertCamelCase
3435
import org.bson.types.ObjectId
3536

3637
/**
@@ -203,7 +204,15 @@ internal open class BsonEncoderImpl(
203204
}
204205

205206
internal fun encodeName(value: Any) {
206-
writer.writeName(value.toString())
207+
val name =
208+
value.toString().let {
209+
if (configuration.bsonNamingStrategy == BsonNamingStrategy.SNAKE_CASE) {
210+
convertCamelCase(it, '_')
211+
} else {
212+
it
213+
}
214+
}
215+
writer.writeName(name)
207216
state = STATE.VALUE
208217
}
209218

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

+2
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import org.bson.AbstractBsonReader
3131
import org.bson.BsonBinarySubType
3232
import org.bson.BsonType
3333
import org.bson.UuidRepresentation
34+
import org.bson.codecs.kotlinx.utils.BsonCodecUtils.toJsonNamingStrategy
3435
import org.bson.internal.UuidHelper
3536

3637
@OptIn(ExperimentalSerializationApi::class)
@@ -42,6 +43,7 @@ internal interface JsonBsonDecoder : BsonDecoder, JsonDecoder {
4243
explicitNulls = configuration.explicitNulls
4344
encodeDefaults = configuration.encodeDefaults
4445
classDiscriminator = configuration.classDiscriminator
46+
namingStrategy = configuration.bsonNamingStrategy.toJsonNamingStrategy()
4547
serializersModule = this@JsonBsonDecoder.serializersModule
4648
}
4749

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

+2
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import kotlinx.serialization.json.int
3030
import kotlinx.serialization.json.long
3131
import kotlinx.serialization.modules.SerializersModule
3232
import org.bson.BsonWriter
33+
import org.bson.codecs.kotlinx.utils.BsonCodecUtils.toJsonNamingStrategy
3334
import org.bson.types.Decimal128
3435

3536
@OptIn(ExperimentalSerializationApi::class)
@@ -52,6 +53,7 @@ internal class JsonBsonEncoder(
5253
explicitNulls = configuration.explicitNulls
5354
encodeDefaults = configuration.encodeDefaults
5455
classDiscriminator = configuration.classDiscriminator
56+
namingStrategy = configuration.bsonNamingStrategy.toJsonNamingStrategy()
5557
serializersModule = this@JsonBsonEncoder.serializersModule
5658
}
5759

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

+75
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
package org.bson.codecs.kotlinx.utils
1717

1818
import kotlinx.serialization.ExperimentalSerializationApi
19+
import kotlinx.serialization.SerializationException
1920
import kotlinx.serialization.descriptors.SerialDescriptor
21+
import kotlinx.serialization.descriptors.elementNames
22+
import kotlinx.serialization.json.JsonNamingStrategy
2023
import kotlinx.serialization.modules.SerializersModule
2124
import org.bson.AbstractBsonReader
2225
import org.bson.BsonWriter
@@ -28,6 +31,7 @@ import org.bson.codecs.kotlinx.BsonDocumentDecoder
2831
import org.bson.codecs.kotlinx.BsonEncoder
2932
import org.bson.codecs.kotlinx.BsonEncoderImpl
3033
import org.bson.codecs.kotlinx.BsonMapDecoder
34+
import org.bson.codecs.kotlinx.BsonNamingStrategy
3135
import org.bson.codecs.kotlinx.BsonPolymorphicDecoder
3236
import org.bson.codecs.kotlinx.JsonBsonArrayDecoder
3337
import org.bson.codecs.kotlinx.JsonBsonDecoderImpl
@@ -59,6 +63,8 @@ internal object BsonCodecUtils {
5963
}
6064
}
6165

66+
private val cachedElementNamesByDescriptor: MutableMap<String, Map<String, String>> = mutableMapOf()
67+
6268
internal fun createBsonEncoder(
6369
writer: BsonWriter,
6470
serializersModule: SerializersModule,
@@ -116,4 +122,73 @@ internal object BsonCodecUtils {
116122
return if (hasJsonDecoder) JsonBsonMapDecoder(descriptor, reader, serializersModule, configuration)
117123
else BsonMapDecoder(descriptor, reader, serializersModule, configuration)
118124
}
125+
126+
internal fun cacheElementNamesByDescriptor(descriptor: SerialDescriptor, configuration: BsonConfiguration) {
127+
val convertedNameMap =
128+
when (configuration.bsonNamingStrategy) {
129+
BsonNamingStrategy.SNAKE_CASE -> {
130+
val snakeCasedNames = descriptor.elementNames.associateWith { name -> convertCamelCase(name, '_') }
131+
132+
snakeCasedNames.entries
133+
.groupBy { entry -> entry.value }
134+
.filter { group -> group.value.size > 1 }
135+
.entries
136+
.fold(StringBuilder("")) { acc, group ->
137+
val keys = group.value.joinToString(", ") { entry -> entry.key }
138+
acc.append("$keys in ${descriptor.serialName} generate same name: ${group.key}.\n")
139+
}
140+
.toString()
141+
.takeIf { it.trim().isNotEmpty() }
142+
?.let { errorMessage: String -> throw SerializationException(errorMessage) }
143+
144+
snakeCasedNames.entries.associate { it.value to it.key }
145+
}
146+
else -> emptyMap()
147+
}
148+
149+
cachedElementNamesByDescriptor[descriptor.serialName] = convertedNameMap
150+
}
151+
152+
internal fun getCachedElementNamesByDescriptor(descriptor: SerialDescriptor): Map<String, String> {
153+
return cachedElementNamesByDescriptor[descriptor.serialName] ?: emptyMap()
154+
}
155+
156+
// https://github.com/Kotlin/kotlinx.serialization/blob/f9f160a680da9f92c3bb121ae3644c96e57ba42e/formats/json/commonMain/src/kotlinx/serialization/json/JsonNamingStrategy.kt#L142-L174
157+
internal fun convertCamelCase(value: String, delimiter: Char) =
158+
buildString(value.length * 2) {
159+
var bufferedChar: Char? = null
160+
var previousUpperCharsCount = 0
161+
162+
value.forEach { c ->
163+
if (c.isUpperCase()) {
164+
if (previousUpperCharsCount == 0 && isNotEmpty() && last() != delimiter) append(delimiter)
165+
166+
bufferedChar?.let(::append)
167+
168+
previousUpperCharsCount++
169+
bufferedChar = c.lowercaseChar()
170+
} else {
171+
if (bufferedChar != null) {
172+
if (previousUpperCharsCount > 1 && c.isLetter()) {
173+
append(delimiter)
174+
}
175+
append(bufferedChar)
176+
previousUpperCharsCount = 0
177+
bufferedChar = null
178+
}
179+
append(c)
180+
}
181+
}
182+
183+
if (bufferedChar != null) {
184+
append(bufferedChar)
185+
}
186+
}
187+
188+
internal fun BsonNamingStrategy?.toJsonNamingStrategy(): JsonNamingStrategy? {
189+
return when (this) {
190+
BsonNamingStrategy.SNAKE_CASE -> JsonNamingStrategy.SnakeCase
191+
else -> null
192+
}
193+
}
119194
}

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

+37
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import org.bson.codecs.kotlinx.samples.DataClassWithBsonId
8181
import org.bson.codecs.kotlinx.samples.DataClassWithBsonIgnore
8282
import org.bson.codecs.kotlinx.samples.DataClassWithBsonProperty
8383
import org.bson.codecs.kotlinx.samples.DataClassWithBsonRepresentation
84+
import org.bson.codecs.kotlinx.samples.DataClassWithCamelCase
8485
import org.bson.codecs.kotlinx.samples.DataClassWithCollections
8586
import org.bson.codecs.kotlinx.samples.DataClassWithContextualDateValues
8687
import org.bson.codecs.kotlinx.samples.DataClassWithDataClassMapKey
@@ -94,6 +95,7 @@ import org.bson.codecs.kotlinx.samples.DataClassWithFailingInit
9495
import org.bson.codecs.kotlinx.samples.DataClassWithJsonElement
9596
import org.bson.codecs.kotlinx.samples.DataClassWithJsonElements
9697
import org.bson.codecs.kotlinx.samples.DataClassWithJsonElementsNullable
98+
import org.bson.codecs.kotlinx.samples.DataClassWithKotlinAllowedName
9799
import org.bson.codecs.kotlinx.samples.DataClassWithListThatLastItemDefaultsToNull
98100
import org.bson.codecs.kotlinx.samples.DataClassWithMutableList
99101
import org.bson.codecs.kotlinx.samples.DataClassWithMutableMap
@@ -105,6 +107,7 @@ import org.bson.codecs.kotlinx.samples.DataClassWithNulls
105107
import org.bson.codecs.kotlinx.samples.DataClassWithPair
106108
import org.bson.codecs.kotlinx.samples.DataClassWithParameterizedDataClass
107109
import org.bson.codecs.kotlinx.samples.DataClassWithRequired
110+
import org.bson.codecs.kotlinx.samples.DataClassWithSameSnakeCaseName
108111
import org.bson.codecs.kotlinx.samples.DataClassWithSequence
109112
import org.bson.codecs.kotlinx.samples.DataClassWithSimpleValues
110113
import org.bson.codecs.kotlinx.samples.DataClassWithTriple
@@ -1126,6 +1129,40 @@ class KotlinSerializerCodecTest {
11261129
}
11271130
}
11281131

1132+
@Test
1133+
fun testSnakeCaseNamingStrategy() {
1134+
val expected =
1135+
"""{"two_words": "", "my_property": "", "camel_case_underscores": "", "url_mapping": "",
1136+
| "my_http_auth": "", "my_http2_api_key": "", "my_http2fast_api_key": ""}"""
1137+
.trimMargin()
1138+
val dataClass = DataClassWithCamelCase()
1139+
assertRoundTrips(expected, dataClass, BsonConfiguration(bsonNamingStrategy = BsonNamingStrategy.SNAKE_CASE))
1140+
}
1141+
1142+
@Test
1143+
fun testSameSnakeCaseName() {
1144+
val expected = """{"my_http_auth": "", "my_http_auth1": ""}"""
1145+
val dataClass = DataClassWithSameSnakeCaseName()
1146+
val exception =
1147+
assertThrows<SerializationException> {
1148+
assertRoundTrips(
1149+
expected, dataClass, BsonConfiguration(bsonNamingStrategy = BsonNamingStrategy.SNAKE_CASE))
1150+
}
1151+
assertEquals(
1152+
"myHTTPAuth, myHttpAuth in org.bson.codecs.kotlinx.samples.DataClassWithSameSnakeCaseName" +
1153+
" generate same name: my_http_auth.\n" +
1154+
"myHTTPAuth1, myHttpAuth1 in org.bson.codecs.kotlinx.samples.DataClassWithSameSnakeCaseName" +
1155+
" generate same name: my_http_auth1.\n",
1156+
exception.message)
1157+
}
1158+
1159+
@Test
1160+
fun testKotlinAllowedName() {
1161+
val expected = """{"имя_переменной": "", "variable _name": ""}"""
1162+
val dataClass = DataClassWithKotlinAllowedName()
1163+
assertRoundTrips(expected, dataClass, BsonConfiguration(bsonNamingStrategy = BsonNamingStrategy.SNAKE_CASE))
1164+
}
1165+
11291166
private inline fun <reified T : Any> assertRoundTrips(
11301167
expected: String,
11311168
value: T,

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

+25
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,31 @@ data class DataClassWithDefaults(
102102
val listSimple: List<String> = listOf("a", "b", "c")
103103
)
104104

105+
@Serializable
106+
data class DataClassWithCamelCase(
107+
val twoWords: String = "",
108+
@Suppress("ConstructorParameterNaming") val MyProperty: String = "",
109+
@Suppress("ConstructorParameterNaming") val camel_Case_Underscores: String = "",
110+
@Suppress("ConstructorParameterNaming") val URLMapping: String = "",
111+
val myHTTPAuth: String = "",
112+
val myHTTP2ApiKey: String = "",
113+
val myHTTP2fastApiKey: String = "",
114+
)
115+
116+
@Serializable
117+
data class DataClassWithSameSnakeCaseName(
118+
val myHTTPAuth: String = "",
119+
val myHttpAuth: String = "",
120+
val myHTTPAuth1: String = "",
121+
val myHttpAuth1: String = "",
122+
)
123+
124+
@Serializable
125+
data class DataClassWithKotlinAllowedName(
126+
@Suppress("ConstructorParameterNaming") val имяПеременной: String = "",
127+
@Suppress("ConstructorParameterNaming") val `variable Name`: String = "",
128+
)
129+
105130
@Serializable data class DataClassWithNulls(val boolean: Boolean?, val string: String?, val listSimple: List<String?>?)
106131

107132
@Serializable

0 commit comments

Comments
 (0)
Failed to load comments.