diff --git a/bson/src/commonMain/kotlin/types/Vector.kt b/bson/src/commonMain/kotlin/types/Vector.kt new file mode 100644 index 00000000..5d0f3d77 --- /dev/null +++ b/bson/src/commonMain/kotlin/types/Vector.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026, OpenSavvy and contributors. + * + * 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 opensavvy.ktmongo.bson.types + +import opensavvy.ktmongo.bson.BsonType +import opensavvy.ktmongo.dsl.LowLevelApi + +/** + * A dense array of numeric values stored in a binary storage efficient for storage and retrieval. + * + * Vectors are effectively used to represent data in artificial intelligence, machine learning, semantic search, computer vision, and natural language processing applications. + * + * All values within the vector must be of the same [type]. + * + * ### Comparison with BSON arrays + * + * BSON arrays are serialized as objects, where the keys are integers (but still encoded as UTF8 strings). + * Arrays have a minimum overhead of 3 bytes per stored element. + * + * Vectors are serialized contiguously, so there is no overhead per element. + * + * However, arrays and vectors are not interchangeable. Most MongoDB operators expect one or the other, + * but will not work with both. + * + * ### External resources + * + * - [Atlas Vector Search](https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-overview/) + * - [Specification](https://github.com/mongodb/specifications/blob/master/source/bson-binary-vector/bson-binary-vector.md) + */ +interface Vector { + + /** + * The type of the elements in the vector (called `dtype` in the specification). + * + * Currently, the following types are implemented: + * - `0x03`: [ByteVector] + * - `0x27`: [FloatVector] + * - `0x10`: [PackedBitVector] + * + * In most situations, users of this library should use `is` checks with one of the implementing subclasses + * rather than attempting to match on this property. + */ + @LowLevelApi + val type: Byte + + /** + * The raw data stored in this vector. + * + * **When reading this property, remember to take into account any declared [padding]!** + * + * For more information on this field, read [the specification](https://github.com/mongodb/specifications/blob/master/source/bson-binary-vector/bson-binary-vector.md). + */ + @LowLevelApi + val raw: ByteArray + + /** + * The number of bits in the final byte of [raw] that are to be ignored. + * + * This is useful for [types][type] that don't fit in multiples of 8 bits. + * + * For more information on this field, read [the specification](https://github.com/mongodb/specifications/blob/master/source/bson-binary-vector/bson-binary-vector.md). + */ + @LowLevelApi + val padding: Byte + + /** + * Converts this [Vector] into a [ByteArray] that fits into the content of [BsonType.BinaryData]. + * + * Vector is the binary subtype `0x09`. + */ + @LowLevelApi + fun toBinaryData(): ByteArray { + val r = raw + return ByteArray(r.size + 2) { index -> + when (index) { + 0 -> type + 1 -> padding + else -> r[index - 2] + } + } + } +} -- 2.51.2 From e05a5e61543c97d113d306367948e6709f947ae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 12 Feb 2026 23:28:47 +0100 Subject: [PATCH 2/9] feat(bson): Read and write Vector instances --- .../src/commonMain/kotlin/raw/BinaryTest.kt | 81 ++++++++++++++++++- bson/src/commonMain/kotlin/BsonReader.kt | 10 +++ bson/src/commonMain/kotlin/BsonWriter.kt | 9 +++ bson/src/commonMain/kotlin/types/Vector.kt | 58 +++++++++++++ .../docs/tutorials/multiplatform/index.md | 1 + 5 files changed, 155 insertions(+), 4 deletions(-) diff --git a/bson-tests/src/commonMain/kotlin/raw/BinaryTest.kt b/bson-tests/src/commonMain/kotlin/raw/BinaryTest.kt index adaa14f6..f7d381e7 100644 --- a/bson-tests/src/commonMain/kotlin/raw/BinaryTest.kt +++ b/bson-tests/src/commonMain/kotlin/raw/BinaryTest.kt @@ -25,6 +25,7 @@ import opensavvy.ktmongo.bson.raw.BsonDeclaration.Companion.hex import opensavvy.ktmongo.bson.raw.BsonDeclaration.Companion.json import opensavvy.ktmongo.bson.raw.BsonDeclaration.Companion.serialize import opensavvy.ktmongo.bson.raw.BsonDeclaration.Companion.verify +import opensavvy.ktmongo.bson.types.Vector import opensavvy.ktmongo.bson.types.UuidAsBsonBinarySerializer import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.prepared.suite.Prepared @@ -260,6 +261,9 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { document { writeBinaryData("x", 0x09u, Base64.decode("JwAAAP5CAADgQA==")) }, + document { + writeVector("x", Vector.fromBinaryData(Base64.decode("JwAAAP5CAADgQA=="))) + }, hex("170000000578000A0000000927000000FE420000E04000"), json($$"""{"x": {"$binary": {"base64": "JwAAAP5CAADgQA==", "subType": "09"}}}"""), verify("Read type") { @@ -267,7 +271,16 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { }, verify("Read data") { check(read("x")?.readBinaryData().contentEquals(Base64.decode("JwAAAP5CAADgQA=="))) - } + }, + verify("Read vector type") { + check(read("x")?.readVector()?.type == 0x27.toByte()) + }, + verify("Read vector padding") { + check(read("x")?.readVector()?.padding == 0x0.toByte()) + }, + verify("Read vector content") { + check(read("x")?.readVector()?.raw.contentEquals(byteArrayOf(0, 0, -2, 66, 0, 0, -32, 64))) + }, ) testBson( @@ -276,6 +289,9 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { document { writeBinaryData("x", 0x09u, Base64.decode("AwB/Bw==")) }, + document { + writeVector("x", Vector.fromBinaryData(Base64.decode("AwB/Bw=="))) + }, hex("11000000057800040000000903007F0700"), json($$"""{"x": {"$binary": {"base64": "AwB/Bw==", "subType": "09"}}}"""), verify("Read type") { @@ -284,6 +300,15 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { verify("Read data") { check(read("x")?.readBinaryData().contentEquals(Base64.decode("AwB/Bw=="))) }, + verify("Read vector type") { + check(read("x")?.readVector()?.type == 0x03.toByte()) + }, + verify("Read vector padding") { + check(read("x")?.readVector()?.padding == 0x0.toByte()) + }, + verify("Read vector content") { + check(read("x")?.readVector()?.raw.contentEquals(byteArrayOf(127, 7))) + }, ) testBson( @@ -292,6 +317,9 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { document { writeBinaryData("x", 0x09u, Base64.decode("EAB/Bw==")) }, + document { + writeVector("x", Vector.fromBinaryData(Base64.decode("EAB/Bw=="))) + }, hex("11000000057800040000000910007F0700"), json($$"""{"x": {"$binary": {"base64": "EAB/Bw==", "subType": "09"}}}"""), verify("Read type") { @@ -300,6 +328,15 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { verify("Read data") { check(read("x")?.readBinaryData().contentEquals(Base64.decode("EAB/Bw=="))) }, + verify("Read vector type") { + check(read("x")?.readVector()?.type == 0x10.toByte()) + }, + verify("Read vector padding") { + check(read("x")?.readVector()?.padding == 0x0.toByte()) + }, + verify("Read vector content") { + check(read("x")?.readVector()?.raw.contentEquals(byteArrayOf(127, 7))) + }, ) testBson( @@ -308,6 +345,9 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { document { writeBinaryData("x", 0x09u, Base64.decode("JwA=")) }, + document { + writeVector("x", Vector.fromBinaryData(Base64.decode("JwA="))) + }, hex("0F0000000578000200000009270000"), json($$"""{"x": {"$binary": {"base64": "JwA=", "subType": "09"}}}"""), verify("Read type") { @@ -315,7 +355,16 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { }, verify("Read data") { check(read("x")?.readBinaryData().contentEquals(Base64.decode("JwA="))) - } + }, + verify("Read vector type") { + check(read("x")?.readVector()?.type == 0x27.toByte()) + }, + verify("Read vector padding") { + check(read("x")?.readVector()?.padding == 0x0.toByte()) + }, + verify("Read vector content") { + check(read("x")?.readVector()?.raw?.size == 0) + }, ) testBson( @@ -324,6 +373,9 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { document { writeBinaryData("x", 0x09u, Base64.decode("AwA=")) }, + document { + writeVector("x", Vector.fromBinaryData(Base64.decode("AwA="))) + }, hex("0F0000000578000200000009030000"), json($$"""{"x": {"$binary": {"base64": "AwA=", "subType": "09"}}}"""), verify("Read type") { @@ -331,7 +383,16 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { }, verify("Read data") { check(read("x")?.readBinaryData().contentEquals(Base64.decode("AwA="))) - } + }, + verify("Read vector type") { + check(read("x")?.readVector()?.type == 0x03.toByte()) + }, + verify("Read vector padding") { + check(read("x")?.readVector()?.padding == 0x0.toByte()) + }, + verify("Read vector content") { + check(read("x")?.readVector()?.raw?.size == 0) + }, ) testBson( @@ -340,6 +401,9 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { document { writeBinaryData("x", 0x09u, Base64.decode("EAA=")) }, + document { + writeVector("x", Vector.fromBinaryData(Base64.decode("EAA="))) + }, hex("0F0000000578000200000009100000"), json($$"""{"x": {"$binary": {"base64": "EAA=", "subType": "09"}}}"""), verify("Read type") { @@ -347,7 +411,16 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { }, verify("Read data") { check(read("x")?.readBinaryData().contentEquals(Base64.decode("EAA="))) - } + }, + verify("Read vector type") { + check(read("x")?.readVector()?.type == 0x10.toByte()) + }, + verify("Read vector padding") { + check(read("x")?.readVector()?.padding == 0x0.toByte()) + }, + verify("Read vector content") { + check(read("x")?.readVector()?.raw?.size == 0) + }, ) } diff --git a/bson/src/commonMain/kotlin/BsonReader.kt b/bson/src/commonMain/kotlin/BsonReader.kt index f2c2888f..65abc856 100644 --- a/bson/src/commonMain/kotlin/BsonReader.kt +++ b/bson/src/commonMain/kotlin/BsonReader.kt @@ -18,6 +18,7 @@ package opensavvy.ktmongo.bson import opensavvy.ktmongo.bson.types.ObjectId import opensavvy.ktmongo.bson.types.Timestamp +import opensavvy.ktmongo.bson.types.Vector import opensavvy.ktmongo.dsl.LowLevelApi import kotlin.reflect.KClass import kotlin.reflect.KType @@ -354,6 +355,15 @@ interface BsonValueReader { @Throws(BsonReaderException::class) fun readBinaryData(): ByteArray + @LowLevelApi + @Throws(BsonReaderException::class) + fun readVector(): Vector { + val type = readBinaryDataType() + if (type != 0x09u.toUByte()) + throw BsonReaderException("Vectors use the BSON binary subtype 0x09, but found: $type") + return Vector.fromBinaryData(readBinaryData()) + } + @LowLevelApi @Throws(BsonReaderException::class) fun readJavaScript(): String diff --git a/bson/src/commonMain/kotlin/BsonWriter.kt b/bson/src/commonMain/kotlin/BsonWriter.kt index d2ac3477..09026ea3 100644 --- a/bson/src/commonMain/kotlin/BsonWriter.kt +++ b/bson/src/commonMain/kotlin/BsonWriter.kt @@ -18,6 +18,7 @@ package opensavvy.ktmongo.bson import opensavvy.ktmongo.bson.types.ObjectId import opensavvy.ktmongo.bson.types.Timestamp +import opensavvy.ktmongo.bson.types.Vector import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.LowLevelApi import kotlin.experimental.and @@ -92,6 +93,10 @@ interface BsonValueWriter : AnyBsonWriter { @LowLevelApi fun writeBinaryData(type: UByte, data: ByteArray) + + @LowLevelApi + fun writeVector(vector: Vector) = + writeBinaryData(0x09u, vector.toBinaryData()) @LowLevelApi fun writeJavaScript(code: String) @LowLevelApi fun writeMinKey() @@ -225,6 +230,10 @@ interface BsonFieldWriter : AnyBsonWriter { @LowLevelApi fun writeBinaryData(name: String, type: UByte, data: ByteArray) + + @LowLevelApi + fun writeVector(name: String, vector: Vector) = + writeBinaryData(name, 0x09u, vector.toBinaryData()) @LowLevelApi fun writeJavaScript(name: String, code: String) @LowLevelApi fun writeMinKey(name: String) diff --git a/bson/src/commonMain/kotlin/types/Vector.kt b/bson/src/commonMain/kotlin/types/Vector.kt index 5d0f3d77..4f9cd484 100644 --- a/bson/src/commonMain/kotlin/types/Vector.kt +++ b/bson/src/commonMain/kotlin/types/Vector.kt @@ -18,6 +18,7 @@ package opensavvy.ktmongo.bson.types import opensavvy.ktmongo.bson.BsonType import opensavvy.ktmongo.dsl.LowLevelApi +import kotlin.math.min /** * A dense array of numeric values stored in a binary storage efficient for storage and retrieval. @@ -93,4 +94,61 @@ interface Vector { } } } + + companion object { + @LowLevelApi + fun fromBinaryData(content: ByteArray): Vector = + UnknownVector(content) + } +} + +private class UnknownVector( + private val binaryData: ByteArray, +) : Vector { + @LowLevelApi + override val type: Byte + get() = binaryData[0] + + @LowLevelApi + override val raw: ByteArray + get() = binaryData.copyOfRange(2, binaryData.size) + + @LowLevelApi + override val padding: Byte + get() = binaryData[1] + + @LowLevelApi + override fun toBinaryData(): ByteArray = + binaryData.copyOf() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is UnknownVector) return false + + if (!binaryData.contentEquals(other.binaryData)) return false + + return true + } + + override fun hashCode(): Int { + return binaryData.contentHashCode() + } + + @OptIn(LowLevelApi::class) + override fun toString(): String = buildString { + append("Vector(type=") + append(type) + append(", padding=") + append(padding) + append(", content=[") + for (index in 2..min(34, binaryData.size - 1)) { + append(binaryData[index]) + append(" ") + } + if (binaryData.size > 34) + append("…") + else + deleteAt(this.length - 1) + append("])") + } } diff --git a/docs/website/docs/tutorials/multiplatform/index.md b/docs/website/docs/tutorials/multiplatform/index.md index 6152e507..6badc481 100644 --- a/docs/website/docs/tutorials/multiplatform/index.md +++ b/docs/website/docs/tutorials/multiplatform/index.md @@ -45,6 +45,7 @@ If you'd like to contribute, please get in touch. - [x] `ObjectId` - [x] `Timestamp` - [x] `DateTime` (using Kotlin's `Instant`) + - [x] `Vector` - [ ] `Decimal128` - [ ] Provide new pure Kotlin implementations of deprecated MongoDB data types: - [ ] `JavaScript` -- 2.51.2 From 371f2eeaa6e3a7462e9571eac2380b6a5f8973c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 19 Feb 2026 22:34:49 +0100 Subject: [PATCH 3/9] feat(bson): Create ByteVector --- .../src/commonMain/kotlin/raw/BinaryTest.kt | 15 ++- bson/src/commonMain/kotlin/types/Vector.kt | 109 +++++++++++++++++- 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/bson-tests/src/commonMain/kotlin/raw/BinaryTest.kt b/bson-tests/src/commonMain/kotlin/raw/BinaryTest.kt index f7d381e7..b7d33a8b 100644 --- a/bson-tests/src/commonMain/kotlin/raw/BinaryTest.kt +++ b/bson-tests/src/commonMain/kotlin/raw/BinaryTest.kt @@ -25,8 +25,9 @@ import opensavvy.ktmongo.bson.raw.BsonDeclaration.Companion.hex import opensavvy.ktmongo.bson.raw.BsonDeclaration.Companion.json import opensavvy.ktmongo.bson.raw.BsonDeclaration.Companion.serialize import opensavvy.ktmongo.bson.raw.BsonDeclaration.Companion.verify -import opensavvy.ktmongo.bson.types.Vector +import opensavvy.ktmongo.bson.types.ByteVector import opensavvy.ktmongo.bson.types.UuidAsBsonBinarySerializer +import opensavvy.ktmongo.bson.types.Vector import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.prepared.suite.Prepared import opensavvy.prepared.suite.SuiteDsl @@ -292,6 +293,9 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { document { writeVector("x", Vector.fromBinaryData(Base64.decode("AwB/Bw=="))) }, + document { + writeVector("x", ByteVector(127, 7)) + }, hex("11000000057800040000000903007F0700"), json($$"""{"x": {"$binary": {"base64": "AwB/Bw==", "subType": "09"}}}"""), verify("Read type") { @@ -309,6 +313,9 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { verify("Read vector content") { check(read("x")?.readVector()?.raw.contentEquals(byteArrayOf(127, 7))) }, + verify("Read vector content as list") { + check(read("x")?.readVector() == listOf(127, 7)) + }, ) testBson( @@ -376,6 +383,9 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { document { writeVector("x", Vector.fromBinaryData(Base64.decode("AwA="))) }, + document { + writeVector("x", ByteVector()) + }, hex("0F0000000578000200000009030000"), json($$"""{"x": {"$binary": {"base64": "AwA=", "subType": "09"}}}"""), verify("Read type") { @@ -393,6 +403,9 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { verify("Read vector content") { check(read("x")?.readVector()?.raw?.size == 0) }, + verify("Read vector content as list") { + check(read("x")?.readVector() == emptyList()) + }, ) testBson( diff --git a/bson/src/commonMain/kotlin/types/Vector.kt b/bson/src/commonMain/kotlin/types/Vector.kt index 4f9cd484..7e209d81 100644 --- a/bson/src/commonMain/kotlin/types/Vector.kt +++ b/bson/src/commonMain/kotlin/types/Vector.kt @@ -97,8 +97,10 @@ interface Vector { companion object { @LowLevelApi - fun fromBinaryData(content: ByteArray): Vector = - UnknownVector(content) + fun fromBinaryData(content: ByteArray): Vector = when (content[0]) { + 0x03.toByte() -> ByteVector(content.sliceArray(2 until content.size), Unit) + else -> UnknownVector(content) + } } } @@ -152,3 +154,106 @@ private class UnknownVector( append("])") } } + +/** + * A [Vector] of [Byte] elements (BSON's `Int8Vector`). + * + * The different bytes can be extracted with [toArray]. + * + * Alternatively, this class implements [List]. + */ +@OptIn(LowLevelApi::class) +class ByteVector internal constructor( + /** + * The underlying byte storage. **Do not mutate this array!** + * + * Note that this storage does NOT include the type nor the padding. + */ + private val rawUnsafe: ByteArray, + @Suppress("unused") unused: Unit, // avoid platform declaration clash with the vararg overload +) : Vector, Iterable, Collection, List { + + /** + * Constructs a [ByteVector] from a collection of bytes. + */ + constructor(bytes: Collection) : this(bytes.toByteArray(), Unit) + + /** + * Constructs a [ByteVector] from multiple bytes. + */ + constructor(vararg bytes: Byte) : this(bytes.asList()) + + override val type: Byte + get() = 0x03 + + @LowLevelApi + override val raw: ByteArray + get() = rawUnsafe.copyOf() + + @LowLevelApi + override val padding: Byte + get() = 0 + + override val size: Int + get() = rawUnsafe.size + + override fun isEmpty(): Boolean = + rawUnsafe.size == 0 + + override fun contains(element: Byte): Boolean = + rawUnsafe.contains(element) + + override fun containsAll(elements: Collection): Boolean = + elements.all { rawUnsafe.contains(it) } + + override fun get(index: Int): Byte = + rawUnsafe[index] + + override fun indexOf(element: Byte): Int = + rawUnsafe.indexOf(element) + + override fun lastIndexOf(element: Byte): Int = + rawUnsafe.lastIndexOf(element) + + override fun listIterator(): ListIterator = + rawUnsafe.asList().listIterator() + + override fun listIterator(index: Int): ListIterator = + rawUnsafe.asList().listIterator(index) + + override fun subList(fromIndex: Int, toIndex: Int): List = + rawUnsafe.slice(fromIndex until toIndex) + + override fun iterator(): ByteIterator = + rawUnsafe.iterator() + + fun toArray(): ByteArray = + raw // 'raw' is cloned on access + + override fun equals(other: Any?): Boolean { + return when { + this === other -> true + other === null -> false + other is ByteVector -> rawUnsafe.contentEquals(other.rawUnsafe) + other is List<*> -> { + if (size != other.size) return false + for (i in indices) { + if (rawUnsafe[i] != other[i]) return false + } + true + } + + else -> false + } + } + + override fun hashCode(): Int { + var hashCode = 1 + for (e in this) + hashCode = 31 * hashCode + e.hashCode() + return hashCode + } + + override fun toString(): String = + joinToString(separator = ", ", prefix = "ByteVector[", postfix = "]") +} -- 2.51.2 From 1a4d0843003edc1bad9244874c6a47dfbcd2f10f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 19 Feb 2026 22:35:11 +0100 Subject: [PATCH 4/9] feat(bson): Create FloatVector --- .../src/commonMain/kotlin/raw/BinaryTest.kt | 13 ++ bson/src/commonMain/kotlin/types/Vector.kt | 186 ++++++++++++++++++ 2 files changed, 199 insertions(+) diff --git a/bson-tests/src/commonMain/kotlin/raw/BinaryTest.kt b/bson-tests/src/commonMain/kotlin/raw/BinaryTest.kt index b7d33a8b..3e3bbbbc 100644 --- a/bson-tests/src/commonMain/kotlin/raw/BinaryTest.kt +++ b/bson-tests/src/commonMain/kotlin/raw/BinaryTest.kt @@ -26,6 +26,7 @@ import opensavvy.ktmongo.bson.raw.BsonDeclaration.Companion.json import opensavvy.ktmongo.bson.raw.BsonDeclaration.Companion.serialize import opensavvy.ktmongo.bson.raw.BsonDeclaration.Companion.verify import opensavvy.ktmongo.bson.types.ByteVector +import opensavvy.ktmongo.bson.types.FloatVector import opensavvy.ktmongo.bson.types.UuidAsBsonBinarySerializer import opensavvy.ktmongo.bson.types.Vector import opensavvy.ktmongo.dsl.LowLevelApi @@ -265,6 +266,9 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { document { writeVector("x", Vector.fromBinaryData(Base64.decode("JwAAAP5CAADgQA=="))) }, + document { + writeVector("x", FloatVector(127f, 7f)) + }, hex("170000000578000A0000000927000000FE420000E04000"), json($$"""{"x": {"$binary": {"base64": "JwAAAP5CAADgQA==", "subType": "09"}}}"""), verify("Read type") { @@ -282,6 +286,9 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { verify("Read vector content") { check(read("x")?.readVector()?.raw.contentEquals(byteArrayOf(0, 0, -2, 66, 0, 0, -32, 64))) }, + verify("Read vector content as list") { + check(read("x")?.readVector() == listOf(127f, 7f)) + }, ) testBson( @@ -355,6 +362,9 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { document { writeVector("x", Vector.fromBinaryData(Base64.decode("JwA="))) }, + document { + writeVector("x", FloatVector()) + }, hex("0F0000000578000200000009270000"), json($$"""{"x": {"$binary": {"base64": "JwA=", "subType": "09"}}}"""), verify("Read type") { @@ -372,6 +382,9 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { verify("Read vector content") { check(read("x")?.readVector()?.raw?.size == 0) }, + verify("Read vector content as list") { + check(read("x")?.readVector() == emptyList()) + }, ) testBson( diff --git a/bson/src/commonMain/kotlin/types/Vector.kt b/bson/src/commonMain/kotlin/types/Vector.kt index 7e209d81..ddc69133 100644 --- a/bson/src/commonMain/kotlin/types/Vector.kt +++ b/bson/src/commonMain/kotlin/types/Vector.kt @@ -18,6 +18,7 @@ package opensavvy.ktmongo.bson.types import opensavvy.ktmongo.bson.BsonType import opensavvy.ktmongo.dsl.LowLevelApi +import kotlin.math.max import kotlin.math.min /** @@ -99,6 +100,7 @@ interface Vector { @LowLevelApi fun fromBinaryData(content: ByteArray): Vector = when (content[0]) { 0x03.toByte() -> ByteVector(content.sliceArray(2 until content.size), Unit) + 0x27.toByte() -> FloatVector(content.sliceArray(2 until content.size)) else -> UnknownVector(content) } } @@ -257,3 +259,187 @@ class ByteVector internal constructor( override fun toString(): String = joinToString(separator = ", ", prefix = "ByteVector[", postfix = "]") } + +private fun floatsToBytes(floats: Collection): ByteArray { + val array = ByteArray(floats.size * 4) + floats.forEachIndexed { index, float -> + val bits = float.toBits() + + // Little-endian byte order (LSB first) + array[index * 4] = (bits and 0xFF).toByte() + array[index * 4 + 1] = ((bits shr 8) and 0xFF).toByte() + array[index * 4 + 2] = ((bits shr 16) and 0xFF).toByte() + array[index * 4 + 3] = ((bits shr 24) and 0xFF).toByte() + } + return array +} + +/** + * A [Vector] of [Float] elements (BSON's `Float32Vector`). + * + * The different bytes can be extracted with [toArray]. + * + * Alternatively, this class implements [List]. + */ +class FloatVector internal constructor( + /** + * The underlying byte storage. **Do not mutate this array!** + * + * Note that this storage does NOT include the type nor the padding. + */ + private val rawUnsafe: ByteArray, +) : Vector, Iterable, Collection, List { + + init { + require(rawUnsafe.size % 4 == 0) { "Each float takes 4 bytes, so the underlying byte array should have a size divisible by 4, found: ${rawUnsafe.size}" } + } + + constructor(floats: Collection) : this(floatsToBytes(floats)) + + constructor(vararg floats: Float) : this(floats.asList()) + + @LowLevelApi + override val type: Byte + get() = 0x27 + + @LowLevelApi + override val raw: ByteArray + get() = rawUnsafe.copyOf() + + @LowLevelApi + override val padding: Byte + get() = 0 + + override fun iterator(): Iterator = + IteratorImpl() + + private inner class IteratorImpl : Iterator { + private var index = 0 + + override fun hasNext(): Boolean = + index < size + + override fun next(): Float = + get(index++) + } + + override val size: Int + get() = rawUnsafe.size / 4 + + override fun isEmpty(): Boolean = + rawUnsafe.isEmpty() + + override fun contains(element: Float): Boolean { + for (i in indices) { + if (get(i) == element) + return true + } + return false + } + + override fun containsAll(elements: Collection): Boolean = + elements.all { contains(it) } + + override fun get(index: Int): Float { + val startIndex = index * 4 + + val bits = (rawUnsafe[startIndex].toUByte().toUInt()) or + (rawUnsafe[startIndex + 1].toUByte().toUInt() shl 8) or + (rawUnsafe[startIndex + 2].toUByte().toUInt() shl 16) or + (rawUnsafe[startIndex + 3].toUByte().toUInt() shl 24) + + return Float.fromBits(bits.toInt()) + } + + override fun indexOf(element: Float): Int { + for (i in indices) { + if (get(i) == element) + return i + } + return -1 + } + + override fun lastIndexOf(element: Float): Int { + for (i in size - 1 downTo 0) { + if (get(i) == element) return i + } + return -1 + } + + override fun listIterator(): ListIterator = + ListIteratorImpl(0) + + override fun listIterator(index: Int): ListIterator = + ListIteratorImpl(index) + + private inner class ListIteratorImpl( + private var index: Int = 0, + ) : ListIterator { + override fun next(): Float = + get(index++) + + override fun hasNext(): Boolean = + index < size + + override fun hasPrevious(): Boolean = + index > 0 + + override fun previous(): Float = + get(--index) + + override fun nextIndex(): Int = + min(index + 1, size) + + override fun previousIndex(): Int = + max(index - 1, 0) + } + + override fun subList(fromIndex: Int, toIndex: Int): List { + if (fromIndex < 0) + throw IndexOutOfBoundsException("fromIndex must be non-negative, found: $fromIndex") + + if (toIndex > size) + throw IndexOutOfBoundsException("toIndex must be less than size ($size), found: $toIndex") + + if (toIndex < fromIndex) + throw IllegalArgumentException("toIndex must be greater than or equal to fromIndex, found: toIndex=$toIndex, fromIndex=$fromIndex") + + val list = ArrayList(toIndex - fromIndex) + + for (i in fromIndex until toIndex) { + list += get(i) + } + + return list + } + + fun toArray(): FloatArray = + FloatArray(size) { get(it) } + + override fun equals(other: Any?): Boolean { + return when { + this === other -> true + other === null -> false + other is FloatVector -> rawUnsafe.contentEquals(other.rawUnsafe) + other is List<*> -> { + if (size != other.size) return false + for (i in indices) { + if (get(i) != other[i]) return false + } + true + } + + else -> false + } + } + + override fun hashCode(): Int { + var hashCode = 1 + for (e in this) + hashCode = 31 * hashCode + e.hashCode() + return hashCode + } + + override fun toString(): String = + joinToString(separator = ", ", prefix = "FloatVector[", postfix = "]") +} -- 2.51.2 From a36c65426758b071bc3ed46492c5ad1e3f38d938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 27 Feb 2026 16:27:58 +0100 Subject: [PATCH 5/9] feat(bson): Create BooleanVector --- .../src/commonMain/kotlin/raw/BinaryTest.kt | 17 +- bson/src/commonMain/kotlin/types/Vector.kt | 192 +++++++++++++++++- 2 files changed, 204 insertions(+), 5 deletions(-) diff --git a/bson-tests/src/commonMain/kotlin/raw/BinaryTest.kt b/bson-tests/src/commonMain/kotlin/raw/BinaryTest.kt index 3e3bbbbc..1a64cc3e 100644 --- a/bson-tests/src/commonMain/kotlin/raw/BinaryTest.kt +++ b/bson-tests/src/commonMain/kotlin/raw/BinaryTest.kt @@ -25,10 +25,7 @@ import opensavvy.ktmongo.bson.raw.BsonDeclaration.Companion.hex import opensavvy.ktmongo.bson.raw.BsonDeclaration.Companion.json import opensavvy.ktmongo.bson.raw.BsonDeclaration.Companion.serialize import opensavvy.ktmongo.bson.raw.BsonDeclaration.Companion.verify -import opensavvy.ktmongo.bson.types.ByteVector -import opensavvy.ktmongo.bson.types.FloatVector -import opensavvy.ktmongo.bson.types.UuidAsBsonBinarySerializer -import opensavvy.ktmongo.bson.types.Vector +import opensavvy.ktmongo.bson.types.* import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.prepared.suite.Prepared import opensavvy.prepared.suite.SuiteDsl @@ -334,6 +331,9 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { document { writeVector("x", Vector.fromBinaryData(Base64.decode("EAB/Bw=="))) }, + document { + writeVector("x", BooleanVector(true, true, true, true, true, true, true, false, true, true, true, false, false, false, false, false)) + }, hex("11000000057800040000000910007F0700"), json($$"""{"x": {"$binary": {"base64": "EAB/Bw==", "subType": "09"}}}"""), verify("Read type") { @@ -351,6 +351,9 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { verify("Read vector content") { check(read("x")?.readVector()?.raw.contentEquals(byteArrayOf(127, 7))) }, + verify("Read vector content as list") { + check(read("x")?.readVector() == listOf(true, true, true, true, true, true, true, false, true, true, true, false, false, false, false, false)) + }, ) testBson( @@ -430,6 +433,9 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { document { writeVector("x", Vector.fromBinaryData(Base64.decode("EAA="))) }, + document { + writeVector("x", BooleanVector()) + }, hex("0F0000000578000200000009100000"), json($$"""{"x": {"$binary": {"base64": "EAA=", "subType": "09"}}}"""), verify("Read type") { @@ -447,6 +453,9 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { verify("Read vector content") { check(read("x")?.readVector()?.raw?.size == 0) }, + verify("Read vector content as list") { + check(read("x")?.readVector() == emptyList()) + }, ) } diff --git a/bson/src/commonMain/kotlin/types/Vector.kt b/bson/src/commonMain/kotlin/types/Vector.kt index ddc69133..2a391b83 100644 --- a/bson/src/commonMain/kotlin/types/Vector.kt +++ b/bson/src/commonMain/kotlin/types/Vector.kt @@ -18,6 +18,8 @@ package opensavvy.ktmongo.bson.types import opensavvy.ktmongo.bson.BsonType import opensavvy.ktmongo.dsl.LowLevelApi +import kotlin.experimental.or +import kotlin.io.encoding.Base64 import kotlin.math.max import kotlin.math.min @@ -51,7 +53,7 @@ interface Vector { * Currently, the following types are implemented: * - `0x03`: [ByteVector] * - `0x27`: [FloatVector] - * - `0x10`: [PackedBitVector] + * - `0x10`: [BooleanVector] * * In most situations, users of this library should use `is` checks with one of the implementing subclasses * rather than attempting to match on this property. @@ -101,6 +103,7 @@ interface Vector { fun fromBinaryData(content: ByteArray): Vector = when (content[0]) { 0x03.toByte() -> ByteVector(content.sliceArray(2 until content.size), Unit) 0x27.toByte() -> FloatVector(content.sliceArray(2 until content.size)) + 0x10.toByte() -> BooleanVector(content.sliceArray(2 until content.size), content[1]) else -> UnknownVector(content) } } @@ -443,3 +446,190 @@ class FloatVector internal constructor( override fun toString(): String = joinToString(separator = ", ", prefix = "FloatVector[", postfix = "]") } + +private fun booleansToBytes(booleans: Collection): ByteArray { + val unpaddedSize = booleans.size / 8 + val hasPadding = booleans.size % 8 != 0 + val array = ByteArray(unpaddedSize + if (hasPadding) 1 else 0) + booleans.forEachIndexed { index, bool -> + val booleanIndex = index / 8 + val remainder = index % 8 + array[booleanIndex] = array[booleanIndex] or (if (bool) 1 shl remainder else 0).toByte() + } + return array +} + +/** + * A [Vector] of [Boolean] elements (BSON's `PackedBitVector`). + * + * The different bytes can be extracted with [toArray]. + * + * Alternatively, this class implements [List]. + */ +class BooleanVector internal constructor( + /** + * The underlying byte storage. **Do not mutate this array!** + * + * Note that this storage does NOT include the type nor the padding. + */ + private val rawUnsafe: ByteArray, + + @property:LowLevelApi + override val padding: Byte, +) : Vector, Iterable, Collection, List { + + init { + @OptIn(LowLevelApi::class) + require(padding in 0..7) { "A vector can only have a maximum padding of 1 byte (8 bits), but found: $padding declared bits" } + } + + constructor(booleans: Collection) : this( + rawUnsafe = booleansToBytes(booleans), + padding = (booleans.size % 8).toByte(), + ) + + constructor(vararg booleans: Boolean) : this(booleans.asList()) + + @LowLevelApi + override val type: Byte + get() = 0x10 + + @LowLevelApi + override val raw: ByteArray + get() = rawUnsafe.copyOf() + + override fun iterator(): BooleanIterator = + IteratorImpl() + + private inner class IteratorImpl : BooleanIterator() { + private var index = 0 + + override fun hasNext(): Boolean = + index < size + + override fun nextBoolean(): Boolean = + get(index++) + } + + @OptIn(LowLevelApi::class) + override val size: Int = + rawUnsafe.size * 8 - padding.toInt() + + override fun isEmpty(): Boolean = + size == 0 + + override fun contains(element: Boolean): Boolean { + for (i in indices) { + if (get(i) == element) + return true + } + return false + } + + override fun containsAll(elements: Collection): Boolean = + elements.all { contains(it) } + + override fun get(index: Int): Boolean { + if (index < 0) + throw IndexOutOfBoundsException("Index must be non-negative, found: $index") + + if (index >= size) + throw IndexOutOfBoundsException("Index must be less than size ($size), found: $index") + + val booleanIndex = index / 8 + val remainder = index % 8 + return rawUnsafe[booleanIndex].toInt() and (1 shl remainder) != 0 + } + + override fun indexOf(element: Boolean): Int { + for (i in indices) { + if (get(i) == element) + return i + } + return -1 + } + + override fun lastIndexOf(element: Boolean): Int { + for (i in size - 1 downTo 0) { + if (get(i) == element) return i + } + return -1 + } + + override fun listIterator(): ListIterator = + ListIteratorImpl() + + override fun listIterator(index: Int): ListIterator = + ListIteratorImpl(index) + + private inner class ListIteratorImpl( + private var index: Int = 0, + ) : ListIterator { + override fun next(): Boolean = + get(index++) + + override fun hasNext(): Boolean = + index < size + + override fun hasPrevious(): Boolean = + index > 0 + + override fun previous(): Boolean = + get(--index) + + override fun nextIndex(): Int = + min(index + 1, size) + + override fun previousIndex(): Int = + max(index - 1, 0) + } + + override fun subList(fromIndex: Int, toIndex: Int): List { + if (fromIndex < 0) + throw IndexOutOfBoundsException("fromIndex must be non-negative, found: $fromIndex") + + if (toIndex > size) + throw IndexOutOfBoundsException("toIndex must be less than size ($size), found: $toIndex") + + if (toIndex < fromIndex) + throw IllegalArgumentException("toIndex must be greater than or equal to fromIndex, found: toIndex=$toIndex, fromIndex=$fromIndex") + + val list = ArrayList(toIndex - fromIndex) + + for (i in fromIndex until toIndex) { + list += get(i) + } + + return list + } + + fun toArray(): BooleanArray = + BooleanArray(size) { get(it) } + + override fun equals(other: Any?): Boolean { + return when { + this === other -> true + other === null -> false + other is BooleanVector -> rawUnsafe.contentEquals(other.rawUnsafe) + other is List<*> -> { + if (size != other.size) return false + for (i in indices) { + if (get(i) != other[i]) return false + } + true + } + + else -> false + } + } + + override fun hashCode(): Int { + var hashCode = 1 + for (e in this) + hashCode = 31 * hashCode + e.hashCode() + return hashCode + } + + override fun toString(): String = + joinToString(separator = ", ", prefix = "BooleanVector[", postfix = "]") +} -- 2.51.2 From eedaaef65b45551fef367ff6183d74a4e0535359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 8 Mar 2026 19:58:12 +0100 Subject: [PATCH 6/9] feat(bson): Expose the BooleanVector constructor with a ByteArray and padding --- bson/src/commonMain/kotlin/types/Vector.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/bson/src/commonMain/kotlin/types/Vector.kt b/bson/src/commonMain/kotlin/types/Vector.kt index 2a391b83..f74bfca0 100644 --- a/bson/src/commonMain/kotlin/types/Vector.kt +++ b/bson/src/commonMain/kotlin/types/Vector.kt @@ -476,6 +476,9 @@ class BooleanVector internal constructor( @property:LowLevelApi override val padding: Byte, + + // Unused parameter, to allow us to have a public constructor with the same signature + @Suppress("unused") marker: Unit, ) : Vector, Iterable, Collection, List { init { @@ -486,10 +489,24 @@ class BooleanVector internal constructor( constructor(booleans: Collection) : this( rawUnsafe = booleansToBytes(booleans), padding = (booleans.size % 8).toByte(), + marker = Unit, ) constructor(vararg booleans: Boolean) : this(booleans.asList()) + /** + * Constructs a [BooleanVector] from the [raw] byte contents with a given [padding]. + * + * Note that [raw] is only the data part of the vector. It is not the entire binary data. + * To construct a [BooleanVector] from binary data, see [Vector.fromBinaryData]. + */ + @LowLevelApi + constructor(raw: ByteArray, padding: Byte) : this( + rawUnsafe = raw.copyOf(), + padding = padding, + marker = Unit, + ) + @LowLevelApi override val type: Byte get() = 0x10 -- 2.51.2 From cee6c85b9895db3f49930b41f644e7696362d8c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 28 Feb 2026 20:49:33 +0100 Subject: [PATCH 7/9] feat(bson-official): Support serialization of Vector subclasses --- .../src/jvmMain/kotlin/BsonContext.jvm.kt | 4 + .../src/jvmMain/kotlin/types/Vector.jvm.kt | 141 ++++++++++++++++++ .../SerializationOptionsCompatibility.kt | 46 +++++- .../src/jvmTest/kotlin/types/VectorJvmTest.kt | 55 +++++++ bson/src/commonMain/kotlin/types/Vector.kt | 99 ++++++++++++ bson/src/jvmMain/kotlin/types/Vector.jvm.kt | 46 ++++++ .../nativeMain/kotlin/types/Vector.native.kt | 26 ++++ .../kotlin/types/Vector.wasmWasi.kt | 26 ++++ bson/src/webMain/kotlin/types/Vector.web.kt | 26 ++++ 9 files changed, 461 insertions(+), 8 deletions(-) create mode 100644 bson-official/src/jvmMain/kotlin/types/Vector.jvm.kt create mode 100644 bson-official/src/jvmTest/kotlin/types/VectorJvmTest.kt create mode 100644 bson/src/jvmMain/kotlin/types/Vector.jvm.kt create mode 100644 bson/src/nativeMain/kotlin/types/Vector.native.kt create mode 100644 bson/src/wasmWasiMain/kotlin/types/Vector.wasmWasi.kt create mode 100644 bson/src/webMain/kotlin/types/Vector.web.kt diff --git a/bson-official/src/jvmMain/kotlin/BsonContext.jvm.kt b/bson-official/src/jvmMain/kotlin/BsonContext.jvm.kt index 2b92076d..e321ec50 100644 --- a/bson-official/src/jvmMain/kotlin/BsonContext.jvm.kt +++ b/bson-official/src/jvmMain/kotlin/BsonContext.jvm.kt @@ -109,6 +109,10 @@ private class JvmBsonFactoryImpl( KotlinTimestampCodec(), KotlinUuidCodec(), KotlinInstantCodec(), + KotlinVectorCodec(), + KotlinFloatVectorCodec(), + KotlinBooleanVectorCodec(), + KotlinByteVectorCodec(), ), codecRegistry, ) diff --git a/bson-official/src/jvmMain/kotlin/types/Vector.jvm.kt b/bson-official/src/jvmMain/kotlin/types/Vector.jvm.kt new file mode 100644 index 00000000..bcf2a2cf --- /dev/null +++ b/bson-official/src/jvmMain/kotlin/types/Vector.jvm.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2026, OpenSavvy and contributors. + * + * 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 opensavvy.ktmongo.bson.official.types + +import opensavvy.ktmongo.bson.types.BooleanVector +import opensavvy.ktmongo.bson.types.ByteVector +import opensavvy.ktmongo.bson.types.FloatVector +import opensavvy.ktmongo.bson.types.Vector +import opensavvy.ktmongo.dsl.LowLevelApi +import org.bson.* +import org.bson.codecs.Codec +import org.bson.codecs.DecoderContext +import org.bson.codecs.EncoderContext + +// region Conversions + +/** + * Converts a KtMongo [Vector] to an official [BsonBinary]. + */ +@OptIn(LowLevelApi::class) +fun Vector.toBinary(): BsonBinary = BsonBinary( + 0x9, + toBinaryData(), +) + +fun FloatVector.toOfficial(): Float32BinaryVector = + BinaryVector.floatVector(toArray()) + +@OptIn(LowLevelApi::class) +fun BooleanVector.toOfficial(): PackedBitBinaryVector = + BinaryVector.packedBitVector(this.raw, this.padding) + +fun ByteVector.toOfficial(): Int8BinaryVector = + BinaryVector.int8Vector(toArray()) + +/** + * Converts an official [BsonBinary] to a KtMongo [Vector]. + */ +@OptIn(LowLevelApi::class) +fun BsonBinary.toKtMongoVector(): Vector = + Vector.fromBinaryData(this.data) + +fun Float32BinaryVector.toKtMongo(): FloatVector = + FloatVector(data.asList()) + +@OptIn(LowLevelApi::class) +fun PackedBitBinaryVector.toKtMongo(): BooleanVector = + BooleanVector(this.data, this.padding) + +fun Int8BinaryVector.toKtMongo(): ByteVector = + ByteVector(data.asList()) + +// endregion +// region Codecs + +internal class KotlinVectorCodec : Codec { + + override fun getEncoderClass(): Class = + Vector::class.java + + override fun encode(writer: BsonWriter, value: Vector, context: EncoderContext) { + writer.writeBinaryData(value.toBinary()) + } + + override fun decode(reader: BsonReader, context: DecoderContext): Vector? { + return reader.readBinaryData()?.toKtMongoVector() + } +} + +internal class KotlinFloatVectorCodec : Codec { + + override fun getEncoderClass(): Class = + FloatVector::class.java + + override fun encode(writer: BsonWriter, value: FloatVector, context: EncoderContext) { + writer.writeBinaryData(value.toBinary()) + } + + override fun decode(reader: BsonReader, context: DecoderContext): FloatVector? { + val vector = reader.readBinaryData()?.toKtMongoVector() + ?: return null + + check(vector is FloatVector) { "Expected to decode a ${FloatVector::class}, but found a ${vector::class}: $vector" } + + return vector + } +} + +internal class KotlinBooleanVectorCodec : Codec { + + override fun getEncoderClass(): Class = + BooleanVector::class.java + + override fun encode(writer: BsonWriter, value: BooleanVector, context: EncoderContext) { + writer.writeBinaryData(value.toBinary()) + } + + override fun decode(reader: BsonReader, context: DecoderContext): BooleanVector? { + val vector = reader.readBinaryData()?.toKtMongoVector() + ?: return null + + check(vector is BooleanVector) { "Expected to decode a ${BooleanVector::class}, but found a ${vector::class}: $vector" } + + return vector + } +} + +internal class KotlinByteVectorCodec : Codec { + + override fun getEncoderClass(): Class = + ByteVector::class.java + + override fun encode(writer: BsonWriter, value: ByteVector, context: EncoderContext) { + writer.writeBinaryData(value.toBinary()) + } + + override fun decode(reader: BsonReader, context: DecoderContext): ByteVector? { + val vector = reader.readBinaryData()?.toKtMongoVector() + ?: return null + + check(vector is ByteVector) { "Expected to decode a ${ByteVector::class}, but found a ${vector::class}: $vector" } + + return vector + } +} + +// endregion diff --git a/bson-official/src/jvmTest/kotlin/SerializationOptionsCompatibility.kt b/bson-official/src/jvmTest/kotlin/SerializationOptionsCompatibility.kt index f38cf946..71d7f73c 100644 --- a/bson-official/src/jvmTest/kotlin/SerializationOptionsCompatibility.kt +++ b/bson-official/src/jvmTest/kotlin/SerializationOptionsCompatibility.kt @@ -23,11 +23,13 @@ import kotlinx.serialization.Serializable import opensavvy.ktmongo.bson.ExperimentalBsonDiffApi import opensavvy.ktmongo.bson.diff import opensavvy.ktmongo.bson.read -import opensavvy.ktmongo.bson.types.Timestamp +import opensavvy.ktmongo.bson.types.* +import opensavvy.ktmongo.bson.types.Vector import opensavvy.ktmongo.bson.write import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.prepared.runner.testballoon.preparedSuite import opensavvy.prepared.suite.prepared +import java.util.* import kotlin.time.ExperimentalTime import kotlin.time.Instant import kotlin.uuid.ExperimentalUuidApi @@ -36,8 +38,12 @@ import kotlin.uuid.ExperimentalUuidApi data class SerializableWithDataClass( val a: String, val b: org.bson.types.ObjectId, - val c: opensavvy.ktmongo.bson.types.ObjectId, + val c: ObjectId, val d: Timestamp, + val e: Vector, + val f: FloatVector, + val g: ByteVector, + val h: BooleanVector, ) // Not a data class: can only be serialized with :bson-kotlinx @@ -45,8 +51,12 @@ data class SerializableWithDataClass( class SerializableWithKxS( val a: String, val b: @Contextual org.bson.types.ObjectId, - val c: opensavvy.ktmongo.bson.types.ObjectId, + val c: ObjectId, val d: Timestamp, + val e: Vector, + val f: FloatVector, + val g: ByteVector, + val h: BooleanVector, ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -56,6 +66,10 @@ class SerializableWithKxS( if (b != other.b) return false if (c != other.c) return false if (d != other.d) return false + if (e != other.e) return false + if (f != other.f) return false + if (g != other.g) return false + if (h != other.h) return false return true } @@ -65,11 +79,15 @@ class SerializableWithKxS( result = 31 * result + b.hashCode() result = 31 * result + c.hashCode() result = 31 * result + d.hashCode() + result = 31 * result + e.hashCode() + result = 31 * result + f.hashCode() + result = 31 * result + g.hashCode() + result = 31 * result + h.hashCode() return result } override fun toString(): String { - return "SerializableWithKxS(a='$a', b=$b, c=$c, d=$d)" + return "SerializableWithKxS(a='$a', b=$b, c=$c, d=$d, e=$e, f=$f, g=$g, h=$h)" } } @@ -79,9 +97,13 @@ val SerializationOptionsCompatibility by preparedSuite { val expectedBson by prepared { testContext().buildDocument { writeString("a", "Bob") - writeObjectId("b", opensavvy.ktmongo.bson.types.ObjectId("640180000000000000000000")) - writeObjectId("c", opensavvy.ktmongo.bson.types.ObjectId("640180000000000000000000")) + writeObjectId("b", ObjectId("640180000000000000000000")) + writeObjectId("c", ObjectId("640180000000000000000000")) writeTimestamp("d", Timestamp(Instant.parse("2023-03-01T00:00:00Z"), 12u)) + writeVector("e", Vector.fromBinaryData(Base64.getDecoder().decode("EAA="))) + writeVector("f", FloatVector(127f, 7f)) + writeVector("g", ByteVector(127, 7)) + writeVector("h", BooleanVector(true, false)) } } @@ -89,8 +111,12 @@ val SerializationOptionsCompatibility by preparedSuite { SerializableWithDataClass( a = "Bob", b = org.bson.types.ObjectId("640180000000000000000000"), - c = opensavvy.ktmongo.bson.types.ObjectId("640180000000000000000000"), + c = ObjectId("640180000000000000000000"), d = Timestamp(Instant.parse("2023-03-01T00:00:00Z"), 12u), + e = Vector.fromBinaryData(Base64.getDecoder().decode("EAA=")), + f = FloatVector(127f, 7f), + g = ByteVector(127, 7), + h = BooleanVector(true, false), ) } @@ -98,8 +124,12 @@ val SerializationOptionsCompatibility by preparedSuite { SerializableWithKxS( a = "Bob", b = org.bson.types.ObjectId("640180000000000000000000"), - c = opensavvy.ktmongo.bson.types.ObjectId("640180000000000000000000"), + c = ObjectId("640180000000000000000000"), d = Timestamp(Instant.parse("2023-03-01T00:00:00Z"), 12u), + e = Vector.fromBinaryData(Base64.getDecoder().decode("EAA=")), + f = FloatVector(127f, 7f), + g = ByteVector(127, 7), + h = BooleanVector(true, false), ) } diff --git a/bson-official/src/jvmTest/kotlin/types/VectorJvmTest.kt b/bson-official/src/jvmTest/kotlin/types/VectorJvmTest.kt new file mode 100644 index 00000000..bf260a63 --- /dev/null +++ b/bson-official/src/jvmTest/kotlin/types/VectorJvmTest.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026, OpenSavvy and contributors. + * + * 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. + */ + +@file:OptIn(LowLevelApi::class) + +package opensavvy.ktmongo.bson.official.types + +import opensavvy.ktmongo.bson.types.BooleanVector +import opensavvy.ktmongo.bson.types.ByteVector +import opensavvy.ktmongo.bson.types.FloatVector +import opensavvy.ktmongo.bson.types.Vector +import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.prepared.runner.testballoon.preparedSuite +import kotlin.io.encoding.Base64 + +val OfficialVectorJvmSuite by preparedSuite { + + test("Vector binary-data round-trip") { + val vector = Vector.fromBinaryData(Base64.encodeToByteArray("EAA=".toByteArray())) + + check(vector.toBinary().toKtMongoVector() == vector) + } + + test("FloatVector round-trip") { + val vector = FloatVector(127.0f, 7.2f, -19.5f, Float.NaN) + + check(vector.toOfficial().toKtMongo() == vector) + } + + test("BooleanVector round-trip") { + val vector = BooleanVector(true, true, false, false, false, true, false, false, false, true) + + check(vector.toOfficial().toKtMongo() == vector) + } + + test("ByteVector round-trip") { + val vector = ByteVector(1, -1, 127, 13, 0, 99) + + check(vector.toOfficial().toKtMongo() == vector) + } + +} diff --git a/bson/src/commonMain/kotlin/types/Vector.kt b/bson/src/commonMain/kotlin/types/Vector.kt index f74bfca0..5722cf93 100644 --- a/bson/src/commonMain/kotlin/types/Vector.kt +++ b/bson/src/commonMain/kotlin/types/Vector.kt @@ -16,6 +16,13 @@ package opensavvy.ktmongo.bson.types +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +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 opensavvy.ktmongo.bson.BsonType import opensavvy.ktmongo.dsl.LowLevelApi import kotlin.experimental.or @@ -45,6 +52,7 @@ import kotlin.math.min * - [Atlas Vector Search](https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-overview/) * - [Specification](https://github.com/mongodb/specifications/blob/master/source/bson-binary-vector/bson-binary-vector.md) */ +@Serializable(with = Vector.Serializer::class) interface Vector { /** @@ -107,6 +115,55 @@ interface Vector { else -> UnknownVector(content) } } + + /** + * Default serializer for [Vector]. + * + * When serializing into BSON, this serializer uses the efficient [`Vector`](https://bsonspec.org/spec.html#more-vector) binary subtype. + * + * When serializing to other formats (e.g. JSON…), this serializer uses a base64-encoded string. + * + * Avoid interacting with this type directly. + */ + @LowLevelApi + object Serializer : KSerializer { + override val descriptor: SerialDescriptor + get() = PrimitiveSerialDescriptor("opensavvy.ktmongo.bson.types.Vector", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Vector) { + serializeVectorPlatformSpecific(encoder, value) + } + + override fun deserialize(decoder: Decoder): Vector = + deserializeVectorPlatformSpecific(decoder) + } +} + +/** + * On the JVM, when using KotlinX.Serialization with the official driver, we must hard-code a different behavior. + * + * All non-JVM platforms implement this function by calling [serializeVectorAsString]. + * This could be simplified with [KT-20427](https://youtrack.jetbrains.com/projects/KT/issues/KT-20427). + */ +internal expect fun serializeVectorPlatformSpecific(encoder: Encoder, value: Vector) + +/** + * On the JVM, when using KotlinX.Serialization with the official driver, we must hard-code a different behavior. + * + * All non-JVM platforms implement this function by calling [deserializeVectorAsString]. + * This could be simplified with [KT-20427](https://youtrack.jetbrains.com/projects/KT/issues/KT-20427). + */ +internal expect fun deserializeVectorPlatformSpecific(decoder: Decoder): Vector + +@OptIn(LowLevelApi::class) +internal fun serializeVectorAsString(encoder: Encoder, value: Vector) { + encoder.encodeString(Base64.encode(value.toBinaryData())) +} + +@OptIn(LowLevelApi::class) +internal fun deserializeVectorAsString(decoder: Decoder): Vector { + val binaryData = Base64.decode(decoder.decodeString()) + return Vector.fromBinaryData(binaryData) } private class UnknownVector( @@ -168,6 +225,7 @@ private class UnknownVector( * Alternatively, this class implements [List]. */ @OptIn(LowLevelApi::class) +@Serializable(with = ByteVector.Serializer::class) class ByteVector internal constructor( /** * The underlying byte storage. **Do not mutate this array!** @@ -261,6 +319,19 @@ class ByteVector internal constructor( override fun toString(): String = joinToString(separator = ", ", prefix = "ByteVector[", postfix = "]") + + @LowLevelApi + object Serializer : KSerializer { + override val descriptor: SerialDescriptor + get() = Vector.serializer().descriptor + + override fun serialize(encoder: Encoder, value: ByteVector) { + encoder.encodeSerializableValue(Vector.serializer(), value) + } + + override fun deserialize(decoder: Decoder): ByteVector = + decoder.decodeSerializableValue(Vector.serializer()) as ByteVector + } } private fun floatsToBytes(floats: Collection): ByteArray { @@ -284,6 +355,7 @@ private fun floatsToBytes(floats: Collection): ByteArray { * * Alternatively, this class implements [List]. */ +@Serializable(with = FloatVector.Serializer::class) class FloatVector internal constructor( /** * The underlying byte storage. **Do not mutate this array!** @@ -445,6 +517,19 @@ class FloatVector internal constructor( override fun toString(): String = joinToString(separator = ", ", prefix = "FloatVector[", postfix = "]") + + @LowLevelApi + object Serializer : KSerializer { + override val descriptor: SerialDescriptor + get() = Vector.serializer().descriptor + + override fun serialize(encoder: Encoder, value: FloatVector) { + encoder.encodeSerializableValue(Vector.serializer(), value) + } + + override fun deserialize(decoder: Decoder): FloatVector = + decoder.decodeSerializableValue(Vector.serializer()) as FloatVector + } } private fun booleansToBytes(booleans: Collection): ByteArray { @@ -466,6 +551,7 @@ private fun booleansToBytes(booleans: Collection): ByteArray { * * Alternatively, this class implements [List]. */ +@Serializable(with = BooleanVector.Serializer::class) class BooleanVector internal constructor( /** * The underlying byte storage. **Do not mutate this array!** @@ -649,4 +735,17 @@ class BooleanVector internal constructor( override fun toString(): String = joinToString(separator = ", ", prefix = "BooleanVector[", postfix = "]") + + @LowLevelApi + object Serializer : KSerializer { + override val descriptor: SerialDescriptor + get() = Vector.serializer().descriptor + + override fun serialize(encoder: Encoder, value: BooleanVector) { + encoder.encodeSerializableValue(Vector.serializer(), value) + } + + override fun deserialize(decoder: Decoder): BooleanVector = + decoder.decodeSerializableValue(Vector.serializer()) as BooleanVector + } } diff --git a/bson/src/jvmMain/kotlin/types/Vector.jvm.kt b/bson/src/jvmMain/kotlin/types/Vector.jvm.kt new file mode 100644 index 00000000..69312fba --- /dev/null +++ b/bson/src/jvmMain/kotlin/types/Vector.jvm.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026, OpenSavvy and contributors. + * + * 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 opensavvy.ktmongo.bson.types + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import opensavvy.ktmongo.dsl.LowLevelApi +import org.bson.BsonBinary +import org.bson.BsonBinarySubType +import org.bson.codecs.kotlinx.BsonDecoder +import org.bson.codecs.kotlinx.BsonEncoder + +private val isOfficialKotlinSerializationEnabled = + ClassLoader.getSystemClassLoader().loadClass("org.bson.codecs.kotlinx.BsonEncoder") != null + +@OptIn(ExperimentalSerializationApi::class, LowLevelApi::class) +internal actual fun serializeVectorPlatformSpecific(encoder: Encoder, value: Vector) { + if (isOfficialKotlinSerializationEnabled && encoder is BsonEncoder) { + encoder.encodeBsonValue(BsonBinary(BsonBinarySubType.VECTOR, value.toBinaryData())) + } else { + serializeVectorAsString(encoder, value) + } +} + +@OptIn(ExperimentalSerializationApi::class, LowLevelApi::class) +internal actual fun deserializeVectorPlatformSpecific(decoder: Decoder): Vector = + if (isOfficialKotlinSerializationEnabled && decoder is BsonDecoder) { + val bsonBinary = decoder.decodeBsonValue() as BsonBinary + Vector.fromBinaryData(bsonBinary.data) + } else { + deserializeVectorAsString(decoder) + } diff --git a/bson/src/nativeMain/kotlin/types/Vector.native.kt b/bson/src/nativeMain/kotlin/types/Vector.native.kt new file mode 100644 index 00000000..a681cc6f --- /dev/null +++ b/bson/src/nativeMain/kotlin/types/Vector.native.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, OpenSavvy and contributors. + * + * 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 opensavvy.ktmongo.bson.types + +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +internal actual fun serializeVectorPlatformSpecific(encoder: Encoder, value: Vector) { + serializeVectorAsString(encoder, value) +} + +internal actual fun deserializeVectorPlatformSpecific(decoder: Decoder): Vector = + deserializeVectorAsString(decoder) diff --git a/bson/src/wasmWasiMain/kotlin/types/Vector.wasmWasi.kt b/bson/src/wasmWasiMain/kotlin/types/Vector.wasmWasi.kt new file mode 100644 index 00000000..a681cc6f --- /dev/null +++ b/bson/src/wasmWasiMain/kotlin/types/Vector.wasmWasi.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, OpenSavvy and contributors. + * + * 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 opensavvy.ktmongo.bson.types + +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +internal actual fun serializeVectorPlatformSpecific(encoder: Encoder, value: Vector) { + serializeVectorAsString(encoder, value) +} + +internal actual fun deserializeVectorPlatformSpecific(decoder: Decoder): Vector = + deserializeVectorAsString(decoder) diff --git a/bson/src/webMain/kotlin/types/Vector.web.kt b/bson/src/webMain/kotlin/types/Vector.web.kt new file mode 100644 index 00000000..a681cc6f --- /dev/null +++ b/bson/src/webMain/kotlin/types/Vector.web.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, OpenSavvy and contributors. + * + * 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 opensavvy.ktmongo.bson.types + +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +internal actual fun serializeVectorPlatformSpecific(encoder: Encoder, value: Vector) { + serializeVectorAsString(encoder, value) +} + +internal actual fun deserializeVectorPlatformSpecific(decoder: Decoder): Vector = + deserializeVectorAsString(decoder) -- 2.51.2 From 3e74049fe95d4007ebc9f0a38c8e2085c6893b48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 8 Mar 2026 20:19:57 +0100 Subject: [PATCH 8/9] fix(bson): Fix ClassNotFoundException when :bson-kotlinx is not in the classpath --- bson/src/jvmMain/kotlin/types/Instant.jvm.kt | 3 --- .../kotlin/types/IsBsonKotlinXPresent.kt | 24 +++++++++++++++++++ bson/src/jvmMain/kotlin/types/ObjectId.jvm.kt | 3 --- .../src/jvmMain/kotlin/types/Timestamp.jvm.kt | 3 --- bson/src/jvmMain/kotlin/types/Uuid.jvm.kt | 3 --- bson/src/jvmMain/kotlin/types/Vector.jvm.kt | 3 --- 6 files changed, 24 insertions(+), 15 deletions(-) create mode 100644 bson/src/jvmMain/kotlin/types/IsBsonKotlinXPresent.kt diff --git a/bson/src/jvmMain/kotlin/types/Instant.jvm.kt b/bson/src/jvmMain/kotlin/types/Instant.jvm.kt index 1e79eea2..61cdf513 100644 --- a/bson/src/jvmMain/kotlin/types/Instant.jvm.kt +++ b/bson/src/jvmMain/kotlin/types/Instant.jvm.kt @@ -24,9 +24,6 @@ import org.bson.codecs.kotlinx.BsonDecoder import org.bson.codecs.kotlinx.BsonEncoder import kotlin.time.Instant -private val isOfficialKotlinSerializationEnabled = - ClassLoader.getSystemClassLoader().loadClass("org.bson.codecs.kotlinx.BsonEncoder") != null - @OptIn(ExperimentalSerializationApi::class) internal actual fun serializeInstantPlatformSpecific(encoder: Encoder, value: Instant) { if (isOfficialKotlinSerializationEnabled && encoder is BsonEncoder) { diff --git a/bson/src/jvmMain/kotlin/types/IsBsonKotlinXPresent.kt b/bson/src/jvmMain/kotlin/types/IsBsonKotlinXPresent.kt new file mode 100644 index 00000000..32ace96a --- /dev/null +++ b/bson/src/jvmMain/kotlin/types/IsBsonKotlinXPresent.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026, OpenSavvy and contributors. + * + * 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 opensavvy.ktmongo.bson.types + +internal val isOfficialKotlinSerializationEnabled: Boolean = try { + Class.forName("org.bson.codecs.kotlinx.BsonEncoder") + true +} catch (_: ClassNotFoundException) { + false +} diff --git a/bson/src/jvmMain/kotlin/types/ObjectId.jvm.kt b/bson/src/jvmMain/kotlin/types/ObjectId.jvm.kt index e9856d75..c7a7d14c 100644 --- a/bson/src/jvmMain/kotlin/types/ObjectId.jvm.kt +++ b/bson/src/jvmMain/kotlin/types/ObjectId.jvm.kt @@ -22,9 +22,6 @@ import kotlinx.serialization.encoding.Encoder import org.bson.codecs.kotlinx.BsonDecoder import org.bson.codecs.kotlinx.BsonEncoder -private val isOfficialKotlinSerializationEnabled = - ClassLoader.getSystemClassLoader().loadClass("org.bson.codecs.kotlinx.BsonEncoder") != null - @OptIn(ExperimentalSerializationApi::class) internal actual fun serializeObjectIdPlatformSpecific(encoder: Encoder, value: ObjectId) { if (isOfficialKotlinSerializationEnabled && encoder is BsonEncoder) { diff --git a/bson/src/jvmMain/kotlin/types/Timestamp.jvm.kt b/bson/src/jvmMain/kotlin/types/Timestamp.jvm.kt index 2e16da02..7841dc83 100644 --- a/bson/src/jvmMain/kotlin/types/Timestamp.jvm.kt +++ b/bson/src/jvmMain/kotlin/types/Timestamp.jvm.kt @@ -25,9 +25,6 @@ import org.bson.codecs.kotlinx.BsonEncoder import kotlin.time.ExperimentalTime import kotlin.time.Instant -private val isOfficialKotlinSerializationEnabled = - ClassLoader.getSystemClassLoader().loadClass("org.bson.codecs.kotlinx.BsonEncoder") != null - @OptIn(ExperimentalTime::class, ExperimentalSerializationApi::class) internal actual fun serializeTimestampPlatformSpecific(encoder: Encoder, value: Timestamp) { if (isOfficialKotlinSerializationEnabled && encoder is BsonEncoder) { diff --git a/bson/src/jvmMain/kotlin/types/Uuid.jvm.kt b/bson/src/jvmMain/kotlin/types/Uuid.jvm.kt index bc39e79a..abc3b0c3 100644 --- a/bson/src/jvmMain/kotlin/types/Uuid.jvm.kt +++ b/bson/src/jvmMain/kotlin/types/Uuid.jvm.kt @@ -27,9 +27,6 @@ import kotlin.uuid.Uuid import kotlin.uuid.toJavaUuid import kotlin.uuid.toKotlinUuid -private val isOfficialKotlinSerializationEnabled = - ClassLoader.getSystemClassLoader().loadClass("org.bson.codecs.kotlinx.BsonEncoder") != null - @OptIn(ExperimentalUuidApi::class, ExperimentalSerializationApi::class) internal actual fun serializeUuidPlatformSpecific(encoder: Encoder, value: Uuid) { if (isOfficialKotlinSerializationEnabled && encoder is BsonEncoder) { diff --git a/bson/src/jvmMain/kotlin/types/Vector.jvm.kt b/bson/src/jvmMain/kotlin/types/Vector.jvm.kt index 69312fba..80c50e7e 100644 --- a/bson/src/jvmMain/kotlin/types/Vector.jvm.kt +++ b/bson/src/jvmMain/kotlin/types/Vector.jvm.kt @@ -24,9 +24,6 @@ import org.bson.BsonBinarySubType import org.bson.codecs.kotlinx.BsonDecoder import org.bson.codecs.kotlinx.BsonEncoder -private val isOfficialKotlinSerializationEnabled = - ClassLoader.getSystemClassLoader().loadClass("org.bson.codecs.kotlinx.BsonEncoder") != null - @OptIn(ExperimentalSerializationApi::class, LowLevelApi::class) internal actual fun serializeVectorPlatformSpecific(encoder: Encoder, value: Vector) { if (isOfficialKotlinSerializationEnabled && encoder is BsonEncoder) { -- 2.51.2 From 764b16b44c59c40b5745ae89ef8c6a187d213146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 8 Mar 2026 20:20:40 +0100 Subject: [PATCH 9/9] feat(bson-multiplatform): Support serialization of Vector subclasses --- .../serialization/MultiplatformDecoder.kt | 10 ++++-- .../serialization/MultiplatformEncoder.kt | 10 ++++-- .../src/commonMain/kotlin/raw/BinaryTest.kt | 33 +++++++++++++++++++ 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformDecoder.kt b/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformDecoder.kt index d7793156..7ed1ad76 100644 --- a/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformDecoder.kt +++ b/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformDecoder.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, OpenSavvy and contributors. + * Copyright (c) 2025-2026, OpenSavvy and contributors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -36,8 +36,7 @@ import opensavvy.ktmongo.bson.multiplatform.BsonFactory import opensavvy.ktmongo.bson.multiplatform.Bytes import opensavvy.ktmongo.bson.multiplatform.impl.read.MultiplatformArrayReader import opensavvy.ktmongo.bson.multiplatform.impl.read.MultiplatformDocumentReader -import opensavvy.ktmongo.bson.types.ObjectId -import opensavvy.ktmongo.bson.types.Timestamp +import opensavvy.ktmongo.bson.types.* import opensavvy.ktmongo.dsl.LowLevelApi import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -148,6 +147,10 @@ internal class BsonDecoder( private val timestamp = Timestamp.Serializer.descriptor private val uuid = Uuid.serializer().descriptor private val instant = Instant.serializer().descriptor + private val vector = Vector.serializer().descriptor + private val floatVector = FloatVector.serializer().descriptor + private val booleanVector = BooleanVector.serializer().descriptor + private val byteVector = ByteVector.serializer().descriptor override fun decodeSerializableValue(deserializer: DeserializationStrategy): T { @Suppress("UNCHECKED_CAST") return when (deserializer.descriptor) { @@ -161,6 +164,7 @@ internal class BsonDecoder( Uuid.fromByteArray(source.readBinaryData()) as T } instant -> source.readInstant() as T + vector, floatVector, booleanVector, byteVector -> Vector.fromBinaryData(source.readBinaryData()) as T // General case: do what the serializer says else -> deserializer.deserialize(this) diff --git a/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformEncoder.kt b/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformEncoder.kt index d033184e..9e2a2654 100644 --- a/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformEncoder.kt +++ b/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformEncoder.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, OpenSavvy and contributors. + * Copyright (c) 2025-2026, OpenSavvy and contributors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,8 +32,7 @@ import opensavvy.ktmongo.bson.multiplatform.Bson import opensavvy.ktmongo.bson.multiplatform.BsonFactory import opensavvy.ktmongo.bson.multiplatform.impl.write.CompletableBsonFieldWriter import opensavvy.ktmongo.bson.multiplatform.impl.write.CompletableBsonValueWriter -import opensavvy.ktmongo.bson.types.ObjectId -import opensavvy.ktmongo.bson.types.Timestamp +import opensavvy.ktmongo.bson.types.* import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.LowLevelApi import kotlin.time.ExperimentalTime @@ -138,6 +137,10 @@ private class BsonEncoder(override val serializersModule: SerializersModule, val private val timestamp = Timestamp.Serializer.descriptor private val uuid = Uuid.serializer().descriptor private val instant = Instant.serializer().descriptor + private val vector = Vector.serializer().descriptor + private val floatVector = FloatVector.serializer().descriptor + private val booleanVector = BooleanVector.serializer().descriptor + private val byteVector = ByteVector.serializer().descriptor override fun encodeSerializableValue(serializer: SerializationStrategy, value: T) { when (serializer.descriptor) { // Special cases where we provide our own encoder @@ -146,6 +149,7 @@ private class BsonEncoder(override val serializersModule: SerializersModule, val timestamp -> out.writeTimestamp(value as Timestamp) uuid -> out.writeBinaryData(4u, (value as Uuid).toByteArray()) instant -> out.writeInstant(value as Instant) + vector, floatVector, booleanVector, byteVector -> out.writeBinaryData(0x09u, (value as Vector).toBinaryData()) // General case: do what the serializer says else -> serializer.serialize(this, value) diff --git a/bson-tests/src/commonMain/kotlin/raw/BinaryTest.kt b/bson-tests/src/commonMain/kotlin/raw/BinaryTest.kt index 1a64cc3e..b06676d9 100644 --- a/bson-tests/src/commonMain/kotlin/raw/BinaryTest.kt +++ b/bson-tests/src/commonMain/kotlin/raw/BinaryTest.kt @@ -254,6 +254,17 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { } ) + @Serializable + data class V( + val x: Vector, + ) + + @Serializable + data class VFloat( + val x: FloatVector, + ) + + testBson( context, "subtype 0x09 Vector FLOAT32", @@ -266,6 +277,8 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { document { writeVector("x", FloatVector(127f, 7f)) }, + serialize(V(FloatVector(127f, 7f))), + serialize(VFloat(FloatVector(127f, 7f))), hex("170000000578000A0000000927000000FE420000E04000"), json($$"""{"x": {"$binary": {"base64": "JwAAAP5CAADgQA==", "subType": "09"}}}"""), verify("Read type") { @@ -288,6 +301,11 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { }, ) + @Serializable + data class VByte( + val x: ByteVector, + ) + testBson( context, "subtype 0x09 Vector INT8", @@ -300,6 +318,8 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { document { writeVector("x", ByteVector(127, 7)) }, + serialize(V(ByteVector(127, 7))), + serialize(VByte(ByteVector(127, 7))), hex("11000000057800040000000903007F0700"), json($$"""{"x": {"$binary": {"base64": "AwB/Bw==", "subType": "09"}}}"""), verify("Read type") { @@ -322,6 +342,11 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { }, ) + @Serializable + data class VBoolean( + val x: BooleanVector, + ) + testBson( context, "subtype 0x09 Vector PACKED_BIT", @@ -334,6 +359,8 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { document { writeVector("x", BooleanVector(true, true, true, true, true, true, true, false, true, true, true, false, false, false, false, false)) }, + serialize(V(BooleanVector(true, true, true, true, true, true, true, false, true, true, true, false, false, false, false, false))), + serialize(VBoolean(BooleanVector(true, true, true, true, true, true, true, false, true, true, true, false, false, false, false, false))), hex("11000000057800040000000910007F0700"), json($$"""{"x": {"$binary": {"base64": "EAB/Bw==", "subType": "09"}}}"""), verify("Read type") { @@ -368,6 +395,8 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { document { writeVector("x", FloatVector()) }, + serialize(V(FloatVector())), + serialize(VFloat(FloatVector())), hex("0F0000000578000200000009270000"), json($$"""{"x": {"$binary": {"base64": "JwA=", "subType": "09"}}}"""), verify("Read type") { @@ -402,6 +431,8 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { document { writeVector("x", ByteVector()) }, + serialize(V(ByteVector())), + serialize(VByte(ByteVector())), hex("0F0000000578000200000009030000"), json($$"""{"x": {"$binary": {"base64": "AwA=", "subType": "09"}}}"""), verify("Read type") { @@ -436,6 +467,8 @@ fun SuiteDsl.binary(context: Prepared) = suite("Binary") { document { writeVector("x", BooleanVector()) }, + serialize(V(BooleanVector())), + serialize(VBoolean(BooleanVector())), hex("0F0000000578000200000009100000"), json($$"""{"x": {"$binary": {"base64": "EAA=", "subType": "09"}}}"""), verify("Read type") {