From 570c164868502b544487faa835fdf6bf82930bda Mon Sep 17 00:00:00 2001 From: Ross Lawley Date: Thu, 1 Aug 2024 08:20:29 +0100 Subject: [PATCH] Added Bson-Kotlin Array Codec (#1457) Adds Kotlin array support to the bson-kotlin library JAVA-5122 Co-authored-by: Viacheslav Babanin --- .../org/bson/codecs/kotlin/ArrayCodec.kt | 128 ++++++++++++++++++ .../bson/codecs/kotlin/ArrayCodecProvider.kt | 31 +++++ .../org/bson/codecs/kotlin/DataClassCodec.kt | 7 +- .../bson/codecs/kotlin/DataClassCodecTest.kt | 57 +++++++- .../bson/codecs/kotlin/samples/DataClasses.kt | 85 ++++++++++++ .../main/com/mongodb/KotlinCodecProvider.java | 6 +- 6 files changed, 310 insertions(+), 4 deletions(-) create mode 100644 bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/ArrayCodec.kt create mode 100644 bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/ArrayCodecProvider.kt diff --git a/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/ArrayCodec.kt b/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/ArrayCodec.kt new file mode 100644 index 00000000000..10ea90aee1b --- /dev/null +++ b/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/ArrayCodec.kt @@ -0,0 +1,128 @@ +/* + * 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.kotlin + +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type +import kotlin.reflect.KClass +import org.bson.BsonReader +import org.bson.BsonType +import org.bson.BsonWriter +import org.bson.codecs.Codec +import org.bson.codecs.DecoderContext +import org.bson.codecs.EncoderContext +import org.bson.codecs.configuration.CodecRegistry + +@Suppress("UNCHECKED_CAST") +internal data class ArrayCodec(private val kClass: KClass, private val codec: Codec) : Codec { + + companion object { + internal fun create( + kClass: KClass, + typeArguments: List, + codecRegistry: CodecRegistry + ): Codec { + assert(kClass.javaObjectType.isArray) { "$kClass must be an array type" } + val (valueClass, nestedTypes) = + if (typeArguments.isEmpty()) { + Pair(kClass.java.componentType.kotlin.javaObjectType as Class, emptyList()) + } else { + // Unroll the actual class and any type arguments + when (val pType = typeArguments[0]) { + is Class<*> -> Pair(pType as Class, emptyList()) + is ParameterizedType -> Pair(pType.rawType as Class, pType.actualTypeArguments.toList()) + else -> Pair(Object::class.java as Class, emptyList()) + } + } + val codec = + if (nestedTypes.isEmpty()) codecRegistry.get(valueClass) else codecRegistry.get(valueClass, nestedTypes) + return ArrayCodec(kClass, codec) + } + } + + private val isPrimitiveArray = kClass.java.componentType != kClass.java.componentType.kotlin.javaObjectType + + override fun encode(writer: BsonWriter, arrayValue: R, encoderContext: EncoderContext) { + writer.writeStartArray() + + boxed(arrayValue).forEach { + if (it == null) writer.writeNull() else encoderContext.encodeWithChildContext(codec, writer, it) + } + + writer.writeEndArray() + } + + override fun getEncoderClass(): Class = kClass.java + + override fun decode(reader: BsonReader, decoderContext: DecoderContext): R { + reader.readStartArray() + val data = ArrayList() + while (reader.readBsonType() != BsonType.END_OF_DOCUMENT) { + if (reader.currentBsonType == BsonType.NULL) { + reader.readNull() + data.add(null) + } else { + data.add(decoderContext.decodeWithChildContext(codec, reader)) + } + } + reader.readEndArray() + return unboxed(data) + } + + fun boxed(arrayValue: R): Iterable { + val boxedValue = + if (!isPrimitiveArray) { + (arrayValue as Array).asIterable() + } else if (arrayValue is BooleanArray) { + arrayValue.asIterable() + } else if (arrayValue is ByteArray) { + arrayValue.asIterable() + } else if (arrayValue is CharArray) { + arrayValue.asIterable() + } else if (arrayValue is DoubleArray) { + arrayValue.asIterable() + } else if (arrayValue is FloatArray) { + arrayValue.asIterable() + } else if (arrayValue is IntArray) { + arrayValue.asIterable() + } else if (arrayValue is LongArray) { + arrayValue.asIterable() + } else if (arrayValue is ShortArray) { + arrayValue.asIterable() + } else { + throw IllegalArgumentException("Unsupported array type ${arrayValue.javaClass}") + } + return boxedValue as Iterable + } + + private fun unboxed(data: ArrayList): R { + return when (kClass) { + BooleanArray::class -> (data as ArrayList).toBooleanArray() as R + ByteArray::class -> (data as ArrayList).toByteArray() as R + CharArray::class -> (data as ArrayList).toCharArray() as R + DoubleArray::class -> (data as ArrayList).toDoubleArray() as R + FloatArray::class -> (data as ArrayList).toFloatArray() as R + IntArray::class -> (data as ArrayList).toIntArray() as R + LongArray::class -> (data as ArrayList).toLongArray() as R + ShortArray::class -> (data as ArrayList).toShortArray() as R + else -> data.toArray(arrayOfNulls(data.size)) as R + } + } + + private fun arrayOfNulls(size: Int): Array { + return java.lang.reflect.Array.newInstance(codec.encoderClass, size) as Array + } +} diff --git a/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/ArrayCodecProvider.kt b/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/ArrayCodecProvider.kt new file mode 100644 index 00000000000..eccb5b88b27 --- /dev/null +++ b/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/ArrayCodecProvider.kt @@ -0,0 +1,31 @@ +/* + * 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.kotlin + +import java.lang.reflect.Type +import org.bson.codecs.Codec +import org.bson.codecs.configuration.CodecProvider +import org.bson.codecs.configuration.CodecRegistry + +/** A Kotlin reflection based Codec Provider for data classes */ +public class ArrayCodecProvider : CodecProvider { + override fun get(clazz: Class, registry: CodecRegistry): Codec? = get(clazz, emptyList(), registry) + + override fun get(clazz: Class, typeArguments: List, registry: CodecRegistry): Codec? = + if (clazz.isArray) { + ArrayCodec.create(clazz.kotlin, typeArguments, registry) + } else null +} diff --git a/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/DataClassCodec.kt b/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/DataClassCodec.kt index 412a0483231..1f24ef301f8 100644 --- a/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/DataClassCodec.kt +++ b/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/DataClassCodec.kt @@ -206,7 +206,7 @@ internal data class DataClassCodec( is KTypeParameter -> { when (val pType = typeMap[kParameter.type] ?: kParameter.type.javaType) { is Class<*> -> - codecRegistry.getCodec(kParameter, (pType as Class).kotlin.javaObjectType, emptyList()) + codecRegistry.getCodec(kParameter, (pType as Class).kotlin.java, emptyList()) is ParameterizedType -> codecRegistry.getCodec( kParameter, @@ -231,11 +231,14 @@ internal data class DataClassCodec( @Suppress("UNCHECKED_CAST") private fun CodecRegistry.getCodec(kParameter: KParameter, clazz: Class, types: List): Codec { val codec = - if (types.isEmpty()) { + if (clazz.isArray) { + ArrayCodec.create(clazz.kotlin, types, this) + } else if (types.isEmpty()) { this.get(clazz) } else { this.get(clazz, types) } + return kParameter.findAnnotation()?.let { if (codec !is RepresentationConfigurable<*>) { throw CodecConfigurationException( diff --git a/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/DataClassCodecTest.kt b/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/DataClassCodecTest.kt index d2dbfc580cc..d8f2e672737 100644 --- a/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/DataClassCodecTest.kt +++ b/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/DataClassCodecTest.kt @@ -36,6 +36,7 @@ import org.bson.codecs.kotlin.samples.DataClassSealedA import org.bson.codecs.kotlin.samples.DataClassSealedB import org.bson.codecs.kotlin.samples.DataClassSealedC import org.bson.codecs.kotlin.samples.DataClassSelfReferential +import org.bson.codecs.kotlin.samples.DataClassWithArrays import org.bson.codecs.kotlin.samples.DataClassWithBooleanMapKey import org.bson.codecs.kotlin.samples.DataClassWithBsonConstructor import org.bson.codecs.kotlin.samples.DataClassWithBsonDiscriminator @@ -54,6 +55,7 @@ import org.bson.codecs.kotlin.samples.DataClassWithInvalidBsonRepresentation import org.bson.codecs.kotlin.samples.DataClassWithMutableList import org.bson.codecs.kotlin.samples.DataClassWithMutableMap import org.bson.codecs.kotlin.samples.DataClassWithMutableSet +import org.bson.codecs.kotlin.samples.DataClassWithNativeArrays import org.bson.codecs.kotlin.samples.DataClassWithNestedParameterized import org.bson.codecs.kotlin.samples.DataClassWithNestedParameterizedDataClass import org.bson.codecs.kotlin.samples.DataClassWithNullableGeneric @@ -110,6 +112,59 @@ class DataClassCodecTest { assertRoundTrips(expected, dataClass) } + @Test + fun testDataClassWithArrays() { + val expected = + """{ + | "arraySimple": ["a", "b", "c", "d"], + | "nestedArrays": [["e", "f"], [], ["g", "h"]], + | "arrayOfMaps": [{"A": ["aa"], "B": ["bb"]}, {}, {"C": ["cc", "ccc"]}], + |}""" + .trimMargin() + + val dataClass = + DataClassWithArrays( + arrayOf("a", "b", "c", "d"), + arrayOf(arrayOf("e", "f"), emptyArray(), arrayOf("g", "h")), + arrayOf( + mapOf("A" to arrayOf("aa"), "B" to arrayOf("bb")), emptyMap(), mapOf("C" to arrayOf("cc", "ccc")))) + + assertRoundTrips(expected, dataClass) + } + + @Test + fun testDataClassWithNativeArrays() { + val expected = + """{ + | "booleanArray": [true, false], + | "byteArray": [1, 2], + | "charArray": ["a", "b"], + | "doubleArray": [ 1.1, 2.2, 3.3], + | "floatArray": [1.0, 2.0, 3.0], + | "intArray": [10, 20, 30, 40], + | "longArray": [{ "$numberLong": "111" }, { "$numberLong": "222" }, { "$numberLong": "333" }], + | "shortArray": [1, 2, 3], + | "listOfArrays": [[true, false], [false, true]], + | "mapOfArrays": {"A": [1, 2], "B":[], "C": [3, 4]} + |}""" + .trimMargin() + + val dataClass = + DataClassWithNativeArrays( + booleanArrayOf(true, false), + byteArrayOf(1, 2), + charArrayOf('a', 'b'), + doubleArrayOf(1.1, 2.2, 3.3), + floatArrayOf(1.0f, 2.0f, 3.0f), + intArrayOf(10, 20, 30, 40), + longArrayOf(111, 222, 333), + shortArrayOf(1, 2, 3), + listOf(booleanArrayOf(true, false), booleanArrayOf(false, true)), + mapOf(Pair("A", intArrayOf(1, 2)), Pair("B", intArrayOf()), Pair("C", intArrayOf(3, 4)))) + + assertRoundTrips(expected, dataClass) + } + @Test fun testDataClassWithDefaults() { val expectedDefault = @@ -516,5 +571,5 @@ class DataClassCodecTest { assertEquals(expected, decoded) } - private fun registry() = fromProviders(DataClassCodecProvider(), Bson.DEFAULT_CODEC_REGISTRY) + private fun registry() = fromProviders(ArrayCodecProvider(), DataClassCodecProvider(), Bson.DEFAULT_CODEC_REGISTRY) } diff --git a/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/samples/DataClasses.kt b/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/samples/DataClasses.kt index a320470cf23..5e49b53c9c2 100644 --- a/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/samples/DataClasses.kt +++ b/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/samples/DataClasses.kt @@ -49,6 +49,91 @@ data class DataClassWithCollections( val mapMap: Map> ) +data class DataClassWithArrays( + val arraySimple: Array, + val nestedArrays: Array>, + val arrayOfMaps: Array>> +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DataClassWithArrays + + if (!arraySimple.contentEquals(other.arraySimple)) return false + if (!nestedArrays.contentDeepEquals(other.nestedArrays)) return false + + if (arrayOfMaps.size != other.arrayOfMaps.size) return false + arrayOfMaps.forEachIndexed { i, map -> + val otherMap = other.arrayOfMaps[i] + if (map.keys != otherMap.keys) return false + map.keys.forEach { key -> if (!map[key].contentEquals(otherMap[key])) return false } + } + + return true + } + + override fun hashCode(): Int { + var result = arraySimple.contentHashCode() + result = 31 * result + nestedArrays.contentDeepHashCode() + result = 31 * result + arrayOfMaps.contentHashCode() + return result + } +} + +data class DataClassWithNativeArrays( + val booleanArray: BooleanArray, + val byteArray: ByteArray, + val charArray: CharArray, + val doubleArray: DoubleArray, + val floatArray: FloatArray, + val intArray: IntArray, + val longArray: LongArray, + val shortArray: ShortArray, + val listOfArrays: List, + val mapOfArrays: Map +) { + + @SuppressWarnings("ComplexMethod") + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DataClassWithNativeArrays + + if (!booleanArray.contentEquals(other.booleanArray)) return false + if (!byteArray.contentEquals(other.byteArray)) return false + if (!charArray.contentEquals(other.charArray)) return false + if (!doubleArray.contentEquals(other.doubleArray)) return false + if (!floatArray.contentEquals(other.floatArray)) return false + if (!intArray.contentEquals(other.intArray)) return false + if (!longArray.contentEquals(other.longArray)) return false + if (!shortArray.contentEquals(other.shortArray)) return false + + if (listOfArrays.size != other.listOfArrays.size) return false + listOfArrays.forEachIndexed { i, value -> if (!value.contentEquals(other.listOfArrays[i])) return false } + + if (mapOfArrays.keys != other.mapOfArrays.keys) return false + mapOfArrays.keys.forEach { key -> if (!mapOfArrays[key].contentEquals(other.mapOfArrays[key])) return false } + + return true + } + + override fun hashCode(): Int { + var result = booleanArray.contentHashCode() + result = 31 * result + byteArray.contentHashCode() + result = 31 * result + charArray.contentHashCode() + result = 31 * result + doubleArray.contentHashCode() + result = 31 * result + floatArray.contentHashCode() + result = 31 * result + intArray.contentHashCode() + result = 31 * result + longArray.contentHashCode() + result = 31 * result + shortArray.contentHashCode() + result = 31 * result + listOfArrays.hashCode() + result = 31 * result + mapOfArrays.hashCode() + return result + } +} + data class DataClassWithDefaults( val boolean: Boolean = false, val string: String = "String", diff --git a/driver-core/src/main/com/mongodb/KotlinCodecProvider.java b/driver-core/src/main/com/mongodb/KotlinCodecProvider.java index 5a1a2f84645..d3bc5fc5604 100644 --- a/driver-core/src/main/com/mongodb/KotlinCodecProvider.java +++ b/driver-core/src/main/com/mongodb/KotlinCodecProvider.java @@ -19,6 +19,7 @@ import org.bson.codecs.Codec; import org.bson.codecs.configuration.CodecProvider; import org.bson.codecs.configuration.CodecRegistry; +import org.bson.codecs.kotlin.ArrayCodecProvider; import org.bson.codecs.kotlin.DataClassCodecProvider; import org.bson.codecs.kotlinx.KotlinSerializerCodecProvider; @@ -26,6 +27,9 @@ import java.util.Collections; import java.util.List; + +import static org.bson.codecs.configuration.CodecRegistries.fromProviders; + /** * A CodecProvider for Kotlin data classes. * Delegates to {@code org.bson.codecs.kotlinx.KotlinSerializerCodecProvider} @@ -56,7 +60,7 @@ public class KotlinCodecProvider implements CodecProvider { possibleCodecProvider = null; try { Class.forName("org.bson.codecs.kotlin.DataClassCodecProvider"); // Kotlin bson canary test - possibleCodecProvider = new DataClassCodecProvider(); + possibleCodecProvider = fromProviders(new ArrayCodecProvider(), new DataClassCodecProvider()); } catch (ClassNotFoundException e) { // No kotlin data class support }