From 132975a70a8093eeb1c2dedbb951083645b1b325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 6 Jun 2025 20:53:49 +0200 Subject: [PATCH 01/11] build(bson): Replace Kotlin-test by Kotest --- bson/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bson/build.gradle.kts b/bson/build.gradle.kts index 580dff6..6d6fc61 100644 --- a/bson/build.gradle.kts +++ b/bson/build.gradle.kts @@ -17,6 +17,7 @@ plugins { alias(opensavvyConventions.plugins.base) alias(opensavvyConventions.plugins.kotlin.library) + alias(libsCommon.plugins.kotest) } kotlin { @@ -52,7 +53,6 @@ kotlin { sourceSets.commonTest.dependencies { implementation(projects.bsonTests) implementation(libs.prepared.kotest) - implementation(libsCommon.kotlin.test) } } -- GitLab From 6e4556832d6e9299e2d3a0730d6b3e7027dfdbcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 6 Jun 2025 20:58:27 +0200 Subject: [PATCH 02/11] feat(bson): Move ObjectId from the multiplatform BSON to the common BSON --- .../src/commonMain/kotlin/types/ObjectId.kt | 10 +++++----- bson/src/commonTest/kotlin/Bson.kt | 17 +++++++++++++++++ .../src/commonTest/kotlin/types/ObjectIdTest.kt | 2 +- 3 files changed, 23 insertions(+), 6 deletions(-) rename {bson-multiplatform => bson}/src/commonMain/kotlin/types/ObjectId.kt (95%) create mode 100644 bson/src/commonTest/kotlin/Bson.kt rename {bson-multiplatform => bson}/src/commonTest/kotlin/types/ObjectIdTest.kt (99%) diff --git a/bson-multiplatform/src/commonMain/kotlin/types/ObjectId.kt b/bson/src/commonMain/kotlin/types/ObjectId.kt similarity index 95% rename from bson-multiplatform/src/commonMain/kotlin/types/ObjectId.kt rename to bson/src/commonMain/kotlin/types/ObjectId.kt index 5e13728..004808a 100644 --- a/bson-multiplatform/src/commonMain/kotlin/types/ObjectId.kt +++ b/bson/src/commonMain/kotlin/types/ObjectId.kt @@ -14,12 +14,12 @@ * limitations under the License. */ -package opensavvy.ktmongo.bson.multiplatform.types +package opensavvy.ktmongo.bson.types -import opensavvy.ktmongo.bson.multiplatform.types.ObjectId.Companion.counterMax -import opensavvy.ktmongo.bson.multiplatform.types.ObjectId.Companion.maxAt -import opensavvy.ktmongo.bson.multiplatform.types.ObjectId.Companion.minAt -import opensavvy.ktmongo.bson.multiplatform.types.ObjectId.Companion.processIdMax +import opensavvy.ktmongo.bson.types.ObjectId.Companion.counterMax +import opensavvy.ktmongo.bson.types.ObjectId.Companion.maxAt +import opensavvy.ktmongo.bson.types.ObjectId.Companion.minAt +import opensavvy.ktmongo.bson.types.ObjectId.Companion.processIdMax import kotlin.experimental.and import kotlin.time.ExperimentalTime import kotlin.time.Instant diff --git a/bson/src/commonTest/kotlin/Bson.kt b/bson/src/commonTest/kotlin/Bson.kt new file mode 100644 index 0000000..1a724ed --- /dev/null +++ b/bson/src/commonTest/kotlin/Bson.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025, 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 diff --git a/bson-multiplatform/src/commonTest/kotlin/types/ObjectIdTest.kt b/bson/src/commonTest/kotlin/types/ObjectIdTest.kt similarity index 99% rename from bson-multiplatform/src/commonTest/kotlin/types/ObjectIdTest.kt rename to bson/src/commonTest/kotlin/types/ObjectIdTest.kt index 78ef5dd..91ba111 100644 --- a/bson-multiplatform/src/commonTest/kotlin/types/ObjectIdTest.kt +++ b/bson/src/commonTest/kotlin/types/ObjectIdTest.kt @@ -16,7 +16,7 @@ @file:OptIn(ExperimentalTime::class) -package opensavvy.ktmongo.bson.multiplatform.types +package opensavvy.ktmongo.bson.types import opensavvy.prepared.runner.kotest.PreparedSpec import kotlin.time.ExperimentalTime -- GitLab From 8387209ce5f752be3bc7a575c0c71cd3ea9b4c36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 7 Jun 2025 11:39:45 +0200 Subject: [PATCH 03/11] perf(bson): Remove useless check in the ObjectId constructor 'bytes' is a constructed field from the other three. If the other three fields are correct, then 'bytes' can't be incorrect. --- bson/src/commonMain/kotlin/types/ObjectId.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/bson/src/commonMain/kotlin/types/ObjectId.kt b/bson/src/commonMain/kotlin/types/ObjectId.kt index 004808a..cdc2187 100644 --- a/bson/src/commonMain/kotlin/types/ObjectId.kt +++ b/bson/src/commonMain/kotlin/types/ObjectId.kt @@ -55,7 +55,6 @@ class ObjectId( ) : Comparable { init { - require(bytes.size == 12) { "ObjectId must be 12 bytes long, found ${bytes.size}" } require(processId < processIdMax) { "The process identifier part of an ObjectId must fit in 5 bytes ($processIdMax), but found: $processId" } require(counter >= 0) { "The counter part of an ObjectId must be positive, but found: $counter" } require(counter < counterMax) { "The counter part of an ObjectId must fit in 3 bytes ($counterMax), but found: $counter" } -- GitLab From 6e45abcf16034b4cd291bf7b1f164e529a3915dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 7 Jun 2025 11:53:06 +0200 Subject: [PATCH 04/11] feat(bson): Introduce ObjectId.fromHex --- bson/src/commonMain/kotlin/types/ObjectId.kt | 21 ++++++++++++++++++- .../commonTest/kotlin/types/ObjectIdTest.kt | 5 +++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/bson/src/commonMain/kotlin/types/ObjectId.kt b/bson/src/commonMain/kotlin/types/ObjectId.kt index cdc2187..5929c42 100644 --- a/bson/src/commonMain/kotlin/types/ObjectId.kt +++ b/bson/src/commonMain/kotlin/types/ObjectId.kt @@ -70,9 +70,17 @@ class ObjectId( val bytes: ByteArray get() = partsToArray(timestamp, processId, counter) + /** + * Generates a hex representation of this [ObjectId] instance. + * + * The output string can be passed to [ObjectId.fromHex] to obtain a new identical [ObjectId] instance. + */ + @OptIn(ExperimentalStdlibApi::class) + val hex: String by lazy(LazyThreadSafetyMode.PUBLICATION) { bytes.toHexString(HexFormat.Default) } + @OptIn(ExperimentalStdlibApi::class) override fun toString(): String = - "ObjectId(${bytes.toHexString(HexFormat.Default)})" + "ObjectId($hex)" override fun compareTo(other: ObjectId): Int { if (timestamp.epochSeconds != other.timestamp.epochSeconds) @@ -134,6 +142,17 @@ class ObjectId( */ fun fromBytes(bytes: ByteArray): ObjectId = arrayToParts(bytes) + /** + * Reads 24 characters in hexadecimal format into an [ObjectId]. + * + * The symmetric operation is [ObjectId.hex]. + */ + @OptIn(ExperimentalStdlibApi::class) + fun fromHex(hex: String): ObjectId { + require(hex.length == 24) { "An ObjectId must be 24-characters long, found ${hex.length} characters: '$hex'" } + return fromBytes(hex.hexToByteArray()) + } + /** * The minimum [ObjectId] created at [timestamp]. * diff --git a/bson/src/commonTest/kotlin/types/ObjectIdTest.kt b/bson/src/commonTest/kotlin/types/ObjectIdTest.kt index 91ba111..274a21a 100644 --- a/bson/src/commonTest/kotlin/types/ObjectIdTest.kt +++ b/bson/src/commonTest/kotlin/types/ObjectIdTest.kt @@ -36,6 +36,11 @@ class ObjectIdTest : PreparedSpec({ check(id.counter == counter) } + test("Construct from string") { + val id = ObjectId.fromHex("5fee66001cbe991a1400007b") + check(id.toString() == "ObjectId(5fee66001cbe991a1400007b)") + } + suite("Equality") { test("Two ObjectId with the same data are equal") { val timestamp = Instant.parse("2021-01-01T00:00:00Z") -- GitLab From ada3fe62a763462f8250d55deb1fce8891a38ff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 7 Jun 2025 12:04:29 +0200 Subject: [PATCH 05/11] feat(bson): Create ObjectIdGenerator and its hardcoded variant --- .../kotlin/types/ObjectIdGenerator.kt | 61 +++++++++++++++++++ .../kotlin/types/ObjectIdGeneratorTest.kt | 45 ++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 bson/src/commonMain/kotlin/types/ObjectIdGenerator.kt create mode 100644 bson/src/commonTest/kotlin/types/ObjectIdGeneratorTest.kt diff --git a/bson/src/commonMain/kotlin/types/ObjectIdGenerator.kt b/bson/src/commonMain/kotlin/types/ObjectIdGenerator.kt new file mode 100644 index 0000000..45939d9 --- /dev/null +++ b/bson/src/commonMain/kotlin/types/ObjectIdGenerator.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025, 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 kotlin.time.ExperimentalTime + +/** + * An object responsible for generating new [ObjectId] instances. + * + * This object is similar to [kotlin.time.Clock]: a `Clock` is a way to generate `Instant` instances, and services + * that need to generate instants should receive a clock via dependency injection. + * Similarly, services that generate IDs should get a generator via dependency injection. + * + * To get the real generator instance, see the database's [opensavvy.ktmongo.bson.BsonContext]. + */ +interface ObjectIdGenerator { + + /** + * Creates a new instance of an [ObjectId]. + * + * @throws NoSuchElementException If the generator is not able to generate more IDs. + */ + @ExperimentalTime + fun newId(): ObjectId + + /** + * Hardcoded [ObjectId] generator with a deterministic input sequence. + * + * Mostly useful as a fake when testing algorithms that must generate IDs. + */ + @ExperimentalTime + class Hardcoded( + private val ids: Iterator, + ) : ObjectIdGenerator { + + constructor(ids: Iterable) : this(ids.iterator()) + + constructor(vararg ids: ObjectId) : this(ids.asIterable()) + + override fun newId(): ObjectId { + if (!ids.hasNext()) + throw NoSuchElementException("This ObjectIdGenerator has finished generating all of its instances, but `newId()` was called once more.") + + return ids.next() + } + } +} diff --git a/bson/src/commonTest/kotlin/types/ObjectIdGeneratorTest.kt b/bson/src/commonTest/kotlin/types/ObjectIdGeneratorTest.kt new file mode 100644 index 0000000..77dc046 --- /dev/null +++ b/bson/src/commonTest/kotlin/types/ObjectIdGeneratorTest.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025, 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(ExperimentalTime::class) + +package opensavvy.ktmongo.bson.types + +import io.kotest.assertions.throwables.shouldThrow +import opensavvy.prepared.runner.kotest.PreparedSpec +import kotlin.time.ExperimentalTime + +class ObjectIdGeneratorTest : PreparedSpec({ + + suite("Hardcoded generator") { + + test("Generate multiple elements") { + val generator = ObjectIdGenerator.Hardcoded( + ObjectId.fromHex("68440c96dd675c605ee0e275"), + ObjectId.fromHex("68440cac8fd4c1c0ed08ebe2"), + ObjectId.fromHex("68440caf38bc1eeca63ee5a0"), + ) + + check(generator.newId() == ObjectId.fromHex("68440c96dd675c605ee0e275")) + check(generator.newId() == ObjectId.fromHex("68440cac8fd4c1c0ed08ebe2")) + check(generator.newId() == ObjectId.fromHex("68440caf38bc1eeca63ee5a0")) + + shouldThrow { generator.newId() } + } + + } + +}) -- GitLab From 765f5f51b2aa0e75ce07362c54d7e459ee434ab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 8 Jun 2025 11:24:35 +0200 Subject: [PATCH 06/11] feat(bson): Create ObjectId.MIN and ObjectId.MAX, rename the max process and counter bounds --- bson/src/commonMain/kotlin/types/ObjectId.kt | 35 +++++++++++++------ .../commonTest/kotlin/types/ObjectIdTest.kt | 9 +++++ 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/bson/src/commonMain/kotlin/types/ObjectId.kt b/bson/src/commonMain/kotlin/types/ObjectId.kt index 5929c42..96cc11e 100644 --- a/bson/src/commonMain/kotlin/types/ObjectId.kt +++ b/bson/src/commonMain/kotlin/types/ObjectId.kt @@ -16,10 +16,10 @@ package opensavvy.ktmongo.bson.types -import opensavvy.ktmongo.bson.types.ObjectId.Companion.counterMax +import opensavvy.ktmongo.bson.types.ObjectId.Companion.COUNTER_BOUND +import opensavvy.ktmongo.bson.types.ObjectId.Companion.PROCESS_ID_BOUND import opensavvy.ktmongo.bson.types.ObjectId.Companion.maxAt import opensavvy.ktmongo.bson.types.ObjectId.Companion.minAt -import opensavvy.ktmongo.bson.types.ObjectId.Companion.processIdMax import kotlin.experimental.and import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -31,6 +31,9 @@ import kotlin.time.Instant class ObjectId( /** * The ObjectId creation timestamp, with a resolution of one second. + * + * This timestamp can represent time from the UNIX epoch (Jan 1 1970) and is stored as 32 unsigned bits + * (approximately Feb 2 2016, see [ObjectId.MAX]'s timestamp for an exact value). */ val timestamp: Instant, @@ -40,7 +43,7 @@ class ObjectId( * This random value is unique to the machine and process. * If the process restarts of the primary node of the process changes, this value is re-regenerated. * - * @see ObjectId.processIdMax + * @see ObjectId.PROCESS_ID_BOUND */ val processId: Long, @@ -49,15 +52,15 @@ class ObjectId( * * The counter resets when a process restarts. * - * @see ObjectId.counterMax + * @see ObjectId.COUNTER_BOUND */ val counter: Int, ) : Comparable { init { - require(processId < processIdMax) { "The process identifier part of an ObjectId must fit in 5 bytes ($processIdMax), but found: $processId" } + require(processId < PROCESS_ID_BOUND) { "The process identifier part of an ObjectId must fit in 5 bytes ($PROCESS_ID_BOUND), but found: $processId" } require(counter >= 0) { "The counter part of an ObjectId must be positive, but found: $counter" } - require(counter < counterMax) { "The counter part of an ObjectId must fit in 3 bytes ($counterMax), but found: $counter" } + require(counter < COUNTER_BOUND) { "The counter part of an ObjectId must fit in 3 bytes ($COUNTER_BOUND), but found: $counter" } } /** @@ -126,14 +129,24 @@ class ObjectId( * * The minimum allowed value is 0. */ - const val processIdMax = 1.toLong() shl (5 * 8) + const val PROCESS_ID_BOUND = 1.toLong() shl (5 * 8) /** * The smallest integer that is not allowed in [ObjectId.counter]. * * The minimum allowed value is 0. */ - const val counterMax = 1 shl (3 * 8) + const val COUNTER_BOUND = 1 shl (3 * 8) + + /** + * The minimum possible [ObjectId]: the one that is lesser or equal to all possible [ObjectId] instances. + */ + val MIN = ObjectId(Instant.fromEpochSeconds(0), 0, 0) + + /** + * The maximum possible [ObjectId]: the one that is greater or equal to all possible [ObjectId] instances. + */ + val MAX = fromHex("FFFFFFFFFFFFFFFFFFFFFFFF") /** * Reads 12 [bytes] into an [ObjectId]. @@ -183,7 +196,7 @@ class ObjectId( * @see toObjectIdRange */ fun maxAt(timestamp: Instant): ObjectId = - ObjectId(timestamp, processIdMax - 1, counterMax - 1) + ObjectId(timestamp, PROCESS_ID_BOUND - 1, COUNTER_BOUND - 1) } } @@ -225,7 +238,7 @@ private fun partsToArray( bytes[2] = (timestamp shr 8).toByte() and 0xFF.toByte() bytes[3] = timestamp.toByte() and 0xFF.toByte() - require(processId < processIdMax) { "The process identifier part of an ObjectId must fit in 5 bytes ($processIdMax), but found: $processId" } + require(processId < PROCESS_ID_BOUND) { "The process identifier part of an ObjectId must fit in 5 bytes ($PROCESS_ID_BOUND), but found: $processId" } bytes[4] = (processId shr 32).toByte() and 0xFF.toByte() bytes[5] = (processId shr 24).toByte() and 0xFF.toByte() bytes[6] = (processId shr 16).toByte() and 0xFF.toByte() @@ -233,7 +246,7 @@ private fun partsToArray( bytes[8] = processId.toByte() and 0xFF.toByte() require(counter >= 0) { "The counter part of an ObjectId must be positive, but found: $counter" } - require(counter < counterMax) { "The counter part of an ObjectId must fit in 3 bytes ($counterMax), but found: $counter" } + require(counter < COUNTER_BOUND) { "The counter part of an ObjectId must fit in 3 bytes ($COUNTER_BOUND), but found: $counter" } bytes[9] = (counter shr 16).toByte() and 0xFF.toByte() bytes[10] = (counter shr 8).toByte() and 0xFF.toByte() bytes[11] = counter.toByte() and 0xFF.toByte() diff --git a/bson/src/commonTest/kotlin/types/ObjectIdTest.kt b/bson/src/commonTest/kotlin/types/ObjectIdTest.kt index 274a21a..1125355 100644 --- a/bson/src/commonTest/kotlin/types/ObjectIdTest.kt +++ b/bson/src/commonTest/kotlin/types/ObjectIdTest.kt @@ -41,6 +41,15 @@ class ObjectIdTest : PreparedSpec({ check(id.toString() == "ObjectId(5fee66001cbe991a1400007b)") } + test("Minimum ObjectId") { + check(ObjectId.MIN.toString() == "ObjectId(000000000000000000000000)") + } + + test("Maximum ObjectId") { + check(ObjectId.MAX.toString() == "ObjectId(ffffffffffffffffffffffff)") + check(ObjectId.MAX.timestamp == Instant.parse("2106-02-07T06:28:15Z")) + } + suite("Equality") { test("Two ObjectId with the same data are equal") { val timestamp = Instant.parse("2021-01-01T00:00:00Z") -- GitLab From 342dbe2f8d404a3bd1535b0acb163095784821e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 15 Jun 2025 20:55:31 +0200 Subject: [PATCH 07/11] feat(bson): Create ObjectIdGenerator.Default --- .../kotlin/types/ObjectIdGenerator.kt | 44 +++++++++++++++++++ .../kotlin/types/ObjectIdGeneratorTest.kt | 36 ++++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/bson/src/commonMain/kotlin/types/ObjectIdGenerator.kt b/bson/src/commonMain/kotlin/types/ObjectIdGenerator.kt index 45939d9..5bc01be 100644 --- a/bson/src/commonMain/kotlin/types/ObjectIdGenerator.kt +++ b/bson/src/commonMain/kotlin/types/ObjectIdGenerator.kt @@ -16,6 +16,9 @@ package opensavvy.ktmongo.bson.types +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.random.Random +import kotlin.time.Clock import kotlin.time.ExperimentalTime /** @@ -37,6 +40,47 @@ interface ObjectIdGenerator { @ExperimentalTime fun newId(): ObjectId + /** + * The default [ObjectIdGenerator], implementing the MongoDB ObjectId generation algorithm. + * + * Note that it isn't guaranteed that IDs are generated monotonically: it is possible that an ID is lesser + * (according to [ObjectId.compareTo]) than one generated previously, but only if both were generated + * during the same second. + * + * However, it is guaranteed until [the year 2016][ObjectId.timestamp] that an ID + * is always strictly greater than one generated in a previous second. + */ + @ExperimentalAtomicApi + @ExperimentalTime + class Default( + private val clock: Clock = Clock.System, + private val random: Random = Random, + private var processId: Long = random.nextLong(0, ObjectId.PROCESS_ID_BOUND), + ) : ObjectIdGenerator { + + private var counter = 0 + private var counterOffset = random.nextInt(0, ObjectId.COUNTER_BOUND) + + override fun newId(): ObjectId { + // TODO in #71: Make this algorithm thread-safe + + val now = clock.now() + + val myCounter = counter++ + if (counter >= ObjectId.COUNTER_BOUND) { + counter = 0 + processId++ + processId %= ObjectId.PROCESS_ID_BOUND + } + + return ObjectId( + timestamp = now, + processId = processId, + counter = (myCounter + counterOffset) % ObjectId.COUNTER_BOUND, + ) + } + } + /** * Hardcoded [ObjectId] generator with a deterministic input sequence. * diff --git a/bson/src/commonTest/kotlin/types/ObjectIdGeneratorTest.kt b/bson/src/commonTest/kotlin/types/ObjectIdGeneratorTest.kt index 77dc046..7c626aa 100644 --- a/bson/src/commonTest/kotlin/types/ObjectIdGeneratorTest.kt +++ b/bson/src/commonTest/kotlin/types/ObjectIdGeneratorTest.kt @@ -14,12 +14,19 @@ * limitations under the License. */ -@file:OptIn(ExperimentalTime::class) +@file:OptIn(ExperimentalTime::class, ExperimentalCoroutinesApi::class, ExperimentalAtomicApi::class) package opensavvy.ktmongo.bson.types import io.kotest.assertions.throwables.shouldThrow +import kotlinx.coroutines.ExperimentalCoroutinesApi +import opensavvy.ktmongo.bson.types.ObjectId.Companion.COUNTER_BOUND +import opensavvy.ktmongo.bson.types.ObjectId.Companion.PROCESS_ID_BOUND import opensavvy.prepared.runner.kotest.PreparedSpec +import opensavvy.prepared.suite.clock +import opensavvy.prepared.suite.random.random +import opensavvy.prepared.suite.time +import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.time.ExperimentalTime class ObjectIdGeneratorTest : PreparedSpec({ @@ -42,4 +49,31 @@ class ObjectIdGeneratorTest : PreparedSpec({ } + suite("Default generator") { + test("Hunt for duplicates generated for a given instant") { + val generator = ObjectIdGenerator.Default( + clock = time.clock, + random = random.accessUnsafe(), + ) + + val previous = HashSet() + + // In theory, we should be able to generate COUNTER_BOUND * PROCESS_ID_BOUND IDs in a single instant. + // However, that is *way* more than what can fit in RAM via the 'previous' HashSet. + // Since it's not possible to actually test the algorithm completely, we settle with testing + // a large number of generations (still in a single instant). Since each run + // starts from a different offset, hopefully this should be strong enough to detect anomalies. + repeat(1_000_000) { index -> + try { + val id = generator.newId() + if (id in previous) throw AssertionError("Duplicate ID $id after generating $index IDs (counter bound: $COUNTER_BOUND, process ID bound: $PROCESS_ID_BOUND, ${index * 1.0 / COUNTER_BOUND} rounds)") + previous += id + } catch (e: Error) { + previous.clear() // Free the memory to ensure the next line can succeed + println("Failed after $index generations (counter bound: $COUNTER_BOUND, process ID bound: $PROCESS_ID_BOUND, ${index * 1.0 / COUNTER_BOUND} rounds)") + throw e + } + } + } + } }) -- GitLab From 617eeaced9e4b2c6d09fb79c345ad0875f7fb0d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 15 Jun 2025 21:26:01 +0200 Subject: [PATCH 08/11] feat(bson): Introduce BsonContext.newId --- .../src/commonMain/kotlin/BsonContext.kt | 8 ++- .../src/jvmMain/kotlin/BsonContext.jvm.kt | 4 +- .../jvmMain/kotlin/JvmObjectIdGenerator.kt | 50 +++++++++++++++++++ bson/src/commonMain/kotlin/BsonContext.kt | 3 +- bson/src/commonMain/kotlin/types/ObjectId.kt | 4 ++ .../kotlin/types/ObjectIdGenerator.kt | 2 + 6 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 bson-official/src/jvmMain/kotlin/JvmObjectIdGenerator.kt diff --git a/bson-multiplatform/src/commonMain/kotlin/BsonContext.kt b/bson-multiplatform/src/commonMain/kotlin/BsonContext.kt index 94c0e13..e648ae9 100644 --- a/bson-multiplatform/src/commonMain/kotlin/BsonContext.kt +++ b/bson-multiplatform/src/commonMain/kotlin/BsonContext.kt @@ -22,9 +22,15 @@ import opensavvy.ktmongo.bson.* import opensavvy.ktmongo.bson.Bson import opensavvy.ktmongo.bson.BsonArray import opensavvy.ktmongo.bson.BsonContext +import opensavvy.ktmongo.bson.types.ObjectIdGenerator import opensavvy.ktmongo.dsl.LowLevelApi +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.time.ExperimentalTime -class BsonContext : BsonContext { +@OptIn(ExperimentalTime::class) +class BsonContext @OptIn(ExperimentalAtomicApi::class) constructor( + objectIdGenerator: ObjectIdGenerator = ObjectIdGenerator.Default(), +) : BsonContext, ObjectIdGenerator by objectIdGenerator { @LowLevelApi private inline fun buildArbitraryTopLevel( diff --git a/bson-official/src/jvmMain/kotlin/BsonContext.jvm.kt b/bson-official/src/jvmMain/kotlin/BsonContext.jvm.kt index 4ae3885..88385be 100644 --- a/bson-official/src/jvmMain/kotlin/BsonContext.jvm.kt +++ b/bson-official/src/jvmMain/kotlin/BsonContext.jvm.kt @@ -19,6 +19,7 @@ package opensavvy.ktmongo.bson.official import opensavvy.ktmongo.bson.BsonFieldWriter import opensavvy.ktmongo.bson.BsonValueWriter import opensavvy.ktmongo.bson.DEPRECATED_IN_BSON_SPEC +import opensavvy.ktmongo.bson.types.ObjectIdGenerator import opensavvy.ktmongo.dsl.LowLevelApi import org.bson.* import org.bson.BsonArray @@ -36,7 +37,8 @@ import java.nio.ByteBuffer */ class JvmBsonContext( codecRegistry: CodecRegistry, -) : BsonContext { + objectIdGenerator: ObjectIdGenerator = ObjectIdGenerator.Jvm(), +) : BsonContext, ObjectIdGenerator by objectIdGenerator { @LowLevelApi val codecRegistry: CodecRegistry = CodecRegistries.fromRegistries( diff --git a/bson-official/src/jvmMain/kotlin/JvmObjectIdGenerator.kt b/bson-official/src/jvmMain/kotlin/JvmObjectIdGenerator.kt new file mode 100644 index 0000000..7392bd0 --- /dev/null +++ b/bson-official/src/jvmMain/kotlin/JvmObjectIdGenerator.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025, 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 + +import opensavvy.ktmongo.bson.types.ObjectId +import opensavvy.ktmongo.bson.types.ObjectIdGenerator +import kotlin.time.ExperimentalTime + +private object JvmObjectIdGenerator : ObjectIdGenerator { + @ExperimentalTime + override fun newId(): ObjectId { + val id = org.bson.types.ObjectId() + return ObjectId.fromBytes( + // Yes, this byte array is wasted memory and GC pressure. + // It is necessary because the Java driver doesn't provide a way to access the nonce + // more efficiently. + id.toByteArray() + ) + } +} + +/** + * An [ObjectIdGenerator] instance that uses the Java driver's [org.bson.types.ObjectId]'s algorithm. + * + * This generation algorithm is slightly different from [ObjectIdGenerator.Default]. + * Here are a few differences: + * + * | | ObjectIdGenerator.Jvm | ObjectIdGenerator.Default | + * |---------------------------------------|------------------------------|----| + * | Maximum number of ObjectId per second | ≈16 million | ≈1 billion billion | + * | Random source | [java.security.SecureRandom] | [kotlin.random.Random] | + * | Testability | None | Can inject a clock and a random source to deterministically generate tests | + */ +@Suppress("FunctionName", "GrazieInspection") +fun ObjectIdGenerator.Companion.Jvm(): ObjectIdGenerator = + JvmObjectIdGenerator diff --git a/bson/src/commonMain/kotlin/BsonContext.kt b/bson/src/commonMain/kotlin/BsonContext.kt index dfbbd42..5422578 100644 --- a/bson/src/commonMain/kotlin/BsonContext.kt +++ b/bson/src/commonMain/kotlin/BsonContext.kt @@ -16,6 +16,7 @@ package opensavvy.ktmongo.bson +import opensavvy.ktmongo.bson.types.ObjectIdGenerator import opensavvy.ktmongo.dsl.LowLevelApi /** @@ -26,7 +27,7 @@ import opensavvy.ktmongo.dsl.LowLevelApi * * For example, a platform may store its serialization configuration in this class. */ -interface BsonContext { +interface BsonContext : ObjectIdGenerator { /** * Instantiates a new [BSON document][Bson]. diff --git a/bson/src/commonMain/kotlin/types/ObjectId.kt b/bson/src/commonMain/kotlin/types/ObjectId.kt index 96cc11e..84a1957 100644 --- a/bson/src/commonMain/kotlin/types/ObjectId.kt +++ b/bson/src/commonMain/kotlin/types/ObjectId.kt @@ -26,6 +26,10 @@ import kotlin.time.Instant /** * A 12-bytes identifier for MongoDB objects. + * + * This class allows accessing all fields of an ObjectId as well as constructing instances from existing data. + * However, it doesn't provide a way to generate new randomized ObjectId instances (as that depends on the database configuration). + * To do so, see [opensavvy.ktmongo.bson.BsonContext.newId]. */ @ExperimentalTime class ObjectId( diff --git a/bson/src/commonMain/kotlin/types/ObjectIdGenerator.kt b/bson/src/commonMain/kotlin/types/ObjectIdGenerator.kt index 5bc01be..bb71563 100644 --- a/bson/src/commonMain/kotlin/types/ObjectIdGenerator.kt +++ b/bson/src/commonMain/kotlin/types/ObjectIdGenerator.kt @@ -102,4 +102,6 @@ interface ObjectIdGenerator { return ids.next() } } + + companion object } -- GitLab From c951af3674ceaf24b298b8fe0f3b9952f2e29b89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 21 Jun 2025 20:20:59 +0200 Subject: [PATCH 09/11] feat(bson): Replace ObjectId.fromHex and ObjectId.fromBytes by constructors --- .../jvmMain/kotlin/JvmObjectIdGenerator.kt | 2 +- bson/src/commonMain/kotlin/types/ObjectId.kt | 166 +++++++++--------- .../kotlin/types/ObjectIdGeneratorTest.kt | 12 +- .../commonTest/kotlin/types/ObjectIdTest.kt | 2 +- 4 files changed, 92 insertions(+), 90 deletions(-) diff --git a/bson-official/src/jvmMain/kotlin/JvmObjectIdGenerator.kt b/bson-official/src/jvmMain/kotlin/JvmObjectIdGenerator.kt index 7392bd0..c321e9e 100644 --- a/bson-official/src/jvmMain/kotlin/JvmObjectIdGenerator.kt +++ b/bson-official/src/jvmMain/kotlin/JvmObjectIdGenerator.kt @@ -24,7 +24,7 @@ private object JvmObjectIdGenerator : ObjectIdGenerator { @ExperimentalTime override fun newId(): ObjectId { val id = org.bson.types.ObjectId() - return ObjectId.fromBytes( + return ObjectId( // Yes, this byte array is wasted memory and GC pressure. // It is necessary because the Java driver doesn't provide a way to access the nonce // more efficiently. diff --git a/bson/src/commonMain/kotlin/types/ObjectId.kt b/bson/src/commonMain/kotlin/types/ObjectId.kt index 84a1957..0a76366 100644 --- a/bson/src/commonMain/kotlin/types/ObjectId.kt +++ b/bson/src/commonMain/kotlin/types/ObjectId.kt @@ -16,8 +16,6 @@ package opensavvy.ktmongo.bson.types -import opensavvy.ktmongo.bson.types.ObjectId.Companion.COUNTER_BOUND -import opensavvy.ktmongo.bson.types.ObjectId.Companion.PROCESS_ID_BOUND import opensavvy.ktmongo.bson.types.ObjectId.Companion.maxAt import opensavvy.ktmongo.bson.types.ObjectId.Companion.minAt import kotlin.experimental.and @@ -32,14 +30,15 @@ import kotlin.time.Instant * To do so, see [opensavvy.ktmongo.bson.BsonContext.newId]. */ @ExperimentalTime -class ObjectId( +class ObjectId : Comparable { + /** * The ObjectId creation timestamp, with a resolution of one second. * * This timestamp can represent time from the UNIX epoch (Jan 1 1970) and is stored as 32 unsigned bits * (approximately Feb 2 2016, see [ObjectId.MAX]'s timestamp for an exact value). */ - val timestamp: Instant, + val timestamp: Instant /** * 5-byte random value generated per client-side process. @@ -49,7 +48,7 @@ class ObjectId( * * @see ObjectId.PROCESS_ID_BOUND */ - val processId: Long, + val processId: Long /** * A 3-byte incrementing counter per client-side process, initialized to a random value. Always positive. @@ -58,29 +57,100 @@ class ObjectId( * * @see ObjectId.COUNTER_BOUND */ - val counter: Int, -) : Comparable { + val counter: Int - init { + /** + * Constructs a new [ObjectId] from its different components. + */ + constructor( + timestamp: Instant, + processId: Long, + counter: Int, + ) { require(processId < PROCESS_ID_BOUND) { "The process identifier part of an ObjectId must fit in 5 bytes ($PROCESS_ID_BOUND), but found: $processId" } require(counter >= 0) { "The counter part of an ObjectId must be positive, but found: $counter" } require(counter < COUNTER_BOUND) { "The counter part of an ObjectId must fit in 3 bytes ($COUNTER_BOUND), but found: $counter" } + + this.timestamp = timestamp + this.processId = processId + this.counter = counter } + /** + * Constructs a new [ObjectId] by reading a byte array. + * + * [bytes] should be exactly 12-bytes long. + * + * To access the bytes of an existing ObjectId, see [ObjectId.bytes]. + */ + constructor(bytes: ByteArray) { + require(bytes.size == 12) { "ObjectId must be 12 bytes long, found ${bytes.size}" } + + val timestampPart = (bytes[0].toUByte().toUInt() shl 24) + + (bytes[1].toUByte().toUInt() shl 16) + + (bytes[2].toUByte().toUInt() shl 8) + + (bytes[3].toUByte().toUInt()) + + timestamp = Instant.fromEpochSeconds(timestampPart.toLong()) + + processId = (bytes[4].toUByte().toLong() shl 32) + + (bytes[5].toUByte().toLong() shl 24) + + (bytes[6].toUByte().toLong() shl 16) + + (bytes[7].toUByte().toLong() shl 8) + + (bytes[8].toUByte().toLong()) + + counter = (bytes[9].toUByte().toInt() shl 16) + + (bytes[10].toUByte().toInt() shl 8) + + (bytes[11].toUByte().toInt()) + } + + /** + * Constructs a new [ObjectId] by reading a hexadecimal representation. + * + * [hex] should be exactly 24 characters long (12 bytes). + * + * To access the hexadecimal representation of an existing ObjectId, see [ObjectId.hex]. + */ + @OptIn(ExperimentalStdlibApi::class) + constructor(hex: String) : this(hexToBytes(hex)) + /** * Generates a byte representation of this [ObjectId] instance. * * Because [ByteArray] is mutable, each access will generate a new array. * - * The output array can be passed to [ObjectId.fromBytes] to obtain a new identical [ObjectId] instance. + * The output array can be passed to the [ObjectId] constructor to obtain a new identical [ObjectId] instance. */ val bytes: ByteArray - get() = partsToArray(timestamp, processId, counter) + get() { + val bytes = ByteArray(12) + + val timestamp = timestamp.epochSeconds.toUInt() + bytes[0] = (timestamp shr 24).toByte() and 0xFF.toByte() + bytes[1] = (timestamp shr 16).toByte() and 0xFF.toByte() + bytes[2] = (timestamp shr 8).toByte() and 0xFF.toByte() + bytes[3] = timestamp.toByte() and 0xFF.toByte() + + require(processId < PROCESS_ID_BOUND) { "The process identifier part of an ObjectId must fit in 5 bytes ($PROCESS_ID_BOUND), but found: $processId" } + bytes[4] = (processId shr 32).toByte() and 0xFF.toByte() + bytes[5] = (processId shr 24).toByte() and 0xFF.toByte() + bytes[6] = (processId shr 16).toByte() and 0xFF.toByte() + bytes[7] = (processId shr 8).toByte() and 0xFF.toByte() + bytes[8] = processId.toByte() and 0xFF.toByte() + + require(counter >= 0) { "The counter part of an ObjectId must be positive, but found: $counter" } + require(counter < COUNTER_BOUND) { "The counter part of an ObjectId must fit in 3 bytes ($COUNTER_BOUND), but found: $counter" } + bytes[9] = (counter shr 16).toByte() and 0xFF.toByte() + bytes[10] = (counter shr 8).toByte() and 0xFF.toByte() + bytes[11] = counter.toByte() and 0xFF.toByte() + + return bytes + } /** * Generates a hex representation of this [ObjectId] instance. * - * The output string can be passed to [ObjectId.fromHex] to obtain a new identical [ObjectId] instance. + * The output string can be passed to [ObjectId] constructor to obtain a new identical [ObjectId] instance. */ @OptIn(ExperimentalStdlibApi::class) val hex: String by lazy(LazyThreadSafetyMode.PUBLICATION) { bytes.toHexString(HexFormat.Default) } @@ -150,24 +220,12 @@ class ObjectId( /** * The maximum possible [ObjectId]: the one that is greater or equal to all possible [ObjectId] instances. */ - val MAX = fromHex("FFFFFFFFFFFFFFFFFFFFFFFF") - - /** - * Reads 12 [bytes] into an [ObjectId]. - * - * The symmetric operation is [ObjectId.bytes]. - */ - fun fromBytes(bytes: ByteArray): ObjectId = arrayToParts(bytes) + val MAX = ObjectId("FFFFFFFFFFFFFFFFFFFFFFFF") - /** - * Reads 24 characters in hexadecimal format into an [ObjectId]. - * - * The symmetric operation is [ObjectId.hex]. - */ @OptIn(ExperimentalStdlibApi::class) - fun fromHex(hex: String): ObjectId { + private fun hexToBytes(hex: String): ByteArray { require(hex.length == 24) { "An ObjectId must be 24-characters long, found ${hex.length} characters: '$hex'" } - return fromBytes(hex.hexToByteArray()) + return hex.hexToByteArray() } /** @@ -227,59 +285,3 @@ fun ClosedRange.toObjectIdRange(): ClosedRange = @ExperimentalTime fun OpenEndRange.toObjectIdRange(): OpenEndRange = minAt(start)..= 0) { "The counter part of an ObjectId must be positive, but found: $counter" } - require(counter < COUNTER_BOUND) { "The counter part of an ObjectId must fit in 3 bytes ($COUNTER_BOUND), but found: $counter" } - bytes[9] = (counter shr 16).toByte() and 0xFF.toByte() - bytes[10] = (counter shr 8).toByte() and 0xFF.toByte() - bytes[11] = counter.toByte() and 0xFF.toByte() - - return bytes -} - -@OptIn(ExperimentalTime::class) -private fun arrayToParts( - bytes: ByteArray, -): ObjectId { - require(bytes.size == 12) { "ObjectId must be 12 bytes long, found ${bytes.size}" } - - val timestampPart = (bytes[0].toUByte().toUInt() shl 24) + - (bytes[1].toUByte().toUInt() shl 16) + - (bytes[2].toUByte().toUInt() shl 8) + - (bytes[3].toUByte().toUInt()) - - val timestamp = Instant.fromEpochSeconds(timestampPart.toLong()) - - val processId = (bytes[4].toUByte().toLong() shl 32) + - (bytes[5].toUByte().toLong() shl 24) + - (bytes[6].toUByte().toLong() shl 16) + - (bytes[7].toUByte().toLong() shl 8) + - (bytes[8].toUByte().toLong()) - - val counter = (bytes[9].toUByte().toInt() shl 16) + - (bytes[10].toUByte().toInt() shl 8) + - (bytes[11].toUByte().toInt()) - - return ObjectId(timestamp, processId, counter) -} diff --git a/bson/src/commonTest/kotlin/types/ObjectIdGeneratorTest.kt b/bson/src/commonTest/kotlin/types/ObjectIdGeneratorTest.kt index 7c626aa..2dc80f5 100644 --- a/bson/src/commonTest/kotlin/types/ObjectIdGeneratorTest.kt +++ b/bson/src/commonTest/kotlin/types/ObjectIdGeneratorTest.kt @@ -35,14 +35,14 @@ class ObjectIdGeneratorTest : PreparedSpec({ test("Generate multiple elements") { val generator = ObjectIdGenerator.Hardcoded( - ObjectId.fromHex("68440c96dd675c605ee0e275"), - ObjectId.fromHex("68440cac8fd4c1c0ed08ebe2"), - ObjectId.fromHex("68440caf38bc1eeca63ee5a0"), + ObjectId("68440c96dd675c605ee0e275"), + ObjectId("68440cac8fd4c1c0ed08ebe2"), + ObjectId("68440caf38bc1eeca63ee5a0"), ) - check(generator.newId() == ObjectId.fromHex("68440c96dd675c605ee0e275")) - check(generator.newId() == ObjectId.fromHex("68440cac8fd4c1c0ed08ebe2")) - check(generator.newId() == ObjectId.fromHex("68440caf38bc1eeca63ee5a0")) + check(generator.newId() == ObjectId("68440c96dd675c605ee0e275")) + check(generator.newId() == ObjectId("68440cac8fd4c1c0ed08ebe2")) + check(generator.newId() == ObjectId("68440caf38bc1eeca63ee5a0")) shouldThrow { generator.newId() } } diff --git a/bson/src/commonTest/kotlin/types/ObjectIdTest.kt b/bson/src/commonTest/kotlin/types/ObjectIdTest.kt index 1125355..7ed3e21 100644 --- a/bson/src/commonTest/kotlin/types/ObjectIdTest.kt +++ b/bson/src/commonTest/kotlin/types/ObjectIdTest.kt @@ -37,7 +37,7 @@ class ObjectIdTest : PreparedSpec({ } test("Construct from string") { - val id = ObjectId.fromHex("5fee66001cbe991a1400007b") + val id = ObjectId("5fee66001cbe991a1400007b") check(id.toString() == "ObjectId(5fee66001cbe991a1400007b)") } -- GitLab From 39a53fb399082b48c8814da20c83ab44a4103397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 21 Jun 2025 20:27:17 +0200 Subject: [PATCH 10/11] feat(bson-official): Replace the :bson-official ObjectId (alias to each driver) by the :bson ObjectId (common code) --- .../src/commonMain/kotlin/types/ObjectId.kt | 44 ---------------- .../src/jvmMain/kotlin/BsonContext.jvm.kt | 1 + .../jvmMain/kotlin/JvmObjectIdGenerator.kt | 50 ------------------- .../src/jvmMain/kotlin/types/ObjectId.jvm.kt | 35 ++++++++++++- .../kotlin/options/options/SortTest.kt | 2 +- .../kotlin/query/filter/FilterUtils.kt | 2 +- .../kotlin/query/filter/LogicalFilterTest.kt | 2 +- .../kotlin/query/update/UpdateUtils.kt | 2 +- 8 files changed, 38 insertions(+), 100 deletions(-) delete mode 100644 bson-official/src/commonMain/kotlin/types/ObjectId.kt delete mode 100644 bson-official/src/jvmMain/kotlin/JvmObjectIdGenerator.kt diff --git a/bson-official/src/commonMain/kotlin/types/ObjectId.kt b/bson-official/src/commonMain/kotlin/types/ObjectId.kt deleted file mode 100644 index d6ab787..0000000 --- a/bson-official/src/commonMain/kotlin/types/ObjectId.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (c) 2024-2025, 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 - -/** - * MongoDB native identifier. - * - * ObjectIds are 12 bytes and can be generated safely in a distributed manner with very low probability of collision. - * - * ### External resources - * - * - [Official documentation](https://www.mongodb.com/docs/manual/reference/bson-types/#std-label-objectid) - */ -expect class ObjectId : Comparable { - - constructor(bytes: ByteArray) - - constructor(hexString: String) - - /** - * Generates a new random ObjectId. - */ - constructor() - - fun toHexString(): String - - fun toByteArray(): ByteArray - - override fun compareTo(other: ObjectId): Int -} diff --git a/bson-official/src/jvmMain/kotlin/BsonContext.jvm.kt b/bson-official/src/jvmMain/kotlin/BsonContext.jvm.kt index 88385be..f318c70 100644 --- a/bson-official/src/jvmMain/kotlin/BsonContext.jvm.kt +++ b/bson-official/src/jvmMain/kotlin/BsonContext.jvm.kt @@ -19,6 +19,7 @@ package opensavvy.ktmongo.bson.official import opensavvy.ktmongo.bson.BsonFieldWriter import opensavvy.ktmongo.bson.BsonValueWriter import opensavvy.ktmongo.bson.DEPRECATED_IN_BSON_SPEC +import opensavvy.ktmongo.bson.official.types.Jvm import opensavvy.ktmongo.bson.types.ObjectIdGenerator import opensavvy.ktmongo.dsl.LowLevelApi import org.bson.* diff --git a/bson-official/src/jvmMain/kotlin/JvmObjectIdGenerator.kt b/bson-official/src/jvmMain/kotlin/JvmObjectIdGenerator.kt deleted file mode 100644 index c321e9e..0000000 --- a/bson-official/src/jvmMain/kotlin/JvmObjectIdGenerator.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright (c) 2025, 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 - -import opensavvy.ktmongo.bson.types.ObjectId -import opensavvy.ktmongo.bson.types.ObjectIdGenerator -import kotlin.time.ExperimentalTime - -private object JvmObjectIdGenerator : ObjectIdGenerator { - @ExperimentalTime - override fun newId(): ObjectId { - val id = org.bson.types.ObjectId() - return ObjectId( - // Yes, this byte array is wasted memory and GC pressure. - // It is necessary because the Java driver doesn't provide a way to access the nonce - // more efficiently. - id.toByteArray() - ) - } -} - -/** - * An [ObjectIdGenerator] instance that uses the Java driver's [org.bson.types.ObjectId]'s algorithm. - * - * This generation algorithm is slightly different from [ObjectIdGenerator.Default]. - * Here are a few differences: - * - * | | ObjectIdGenerator.Jvm | ObjectIdGenerator.Default | - * |---------------------------------------|------------------------------|----| - * | Maximum number of ObjectId per second | ≈16 million | ≈1 billion billion | - * | Random source | [java.security.SecureRandom] | [kotlin.random.Random] | - * | Testability | None | Can inject a clock and a random source to deterministically generate tests | - */ -@Suppress("FunctionName", "GrazieInspection") -fun ObjectIdGenerator.Companion.Jvm(): ObjectIdGenerator = - JvmObjectIdGenerator diff --git a/bson-official/src/jvmMain/kotlin/types/ObjectId.jvm.kt b/bson-official/src/jvmMain/kotlin/types/ObjectId.jvm.kt index 4871299..701382c 100644 --- a/bson-official/src/jvmMain/kotlin/types/ObjectId.jvm.kt +++ b/bson-official/src/jvmMain/kotlin/types/ObjectId.jvm.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024-2025, OpenSavvy and contributors. + * Copyright (c) 2025, 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. @@ -16,4 +16,35 @@ package opensavvy.ktmongo.bson.official.types -actual typealias ObjectId = org.bson.types.ObjectId +import opensavvy.ktmongo.bson.types.ObjectId +import opensavvy.ktmongo.bson.types.ObjectIdGenerator +import kotlin.time.ExperimentalTime + +private object JvmObjectIdGenerator : ObjectIdGenerator { + @ExperimentalTime + override fun newId(): ObjectId { + val id = org.bson.types.ObjectId() + return ObjectId( + // Yes, this byte array is wasted memory and GC pressure. + // It is necessary because the Java driver doesn't provide a way to access the nonce + // more efficiently. + id.toByteArray() + ) + } +} + +/** + * An [ObjectIdGenerator] instance that uses the Java driver's [org.bson.types.ObjectId]'s algorithm. + * + * This generation algorithm is slightly different from [ObjectIdGenerator.Default]. + * Here are a few differences: + * + * | | ObjectIdGenerator.Jvm | ObjectIdGenerator.Default | + * |---------------------------------------|------------------------------|----| + * | Maximum number of ObjectId per second | ≈16 million | ≈1 billion billion | + * | Random source | [java.security.SecureRandom] | [kotlin.random.Random] | + * | Testability | None | Can inject a clock and a random source to deterministically generate tests | + */ +@Suppress("FunctionName", "GrazieInspection") +fun ObjectIdGenerator.Companion.Jvm(): ObjectIdGenerator = + JvmObjectIdGenerator diff --git a/dsl/src/commonTest/kotlin/options/options/SortTest.kt b/dsl/src/commonTest/kotlin/options/options/SortTest.kt index 19cb781..c4fe39c 100644 --- a/dsl/src/commonTest/kotlin/options/options/SortTest.kt +++ b/dsl/src/commonTest/kotlin/options/options/SortTest.kt @@ -16,12 +16,12 @@ package opensavvy.ktmongo.dsl.options.options -import opensavvy.ktmongo.bson.official.types.ObjectId import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.command.FindOptions import opensavvy.ktmongo.dsl.query.shouldBeBson import opensavvy.ktmongo.dsl.query.testContext import opensavvy.prepared.runner.kotest.PreparedSpec +import org.bson.types.ObjectId @LowLevelApi class SortTest : PreparedSpec({ diff --git a/dsl/src/commonTest/kotlin/query/filter/FilterUtils.kt b/dsl/src/commonTest/kotlin/query/filter/FilterUtils.kt index 4c0a5d0..026c857 100644 --- a/dsl/src/commonTest/kotlin/query/filter/FilterUtils.kt +++ b/dsl/src/commonTest/kotlin/query/filter/FilterUtils.kt @@ -16,11 +16,11 @@ package opensavvy.ktmongo.dsl.query.filter -import opensavvy.ktmongo.bson.official.types.ObjectId import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.query.FilterQuery import opensavvy.ktmongo.dsl.query.testContext +import org.bson.types.ObjectId val eq = "\$eq" val ne = "\$ne" diff --git a/dsl/src/commonTest/kotlin/query/filter/LogicalFilterTest.kt b/dsl/src/commonTest/kotlin/query/filter/LogicalFilterTest.kt index e25490c..15f46a6 100644 --- a/dsl/src/commonTest/kotlin/query/filter/LogicalFilterTest.kt +++ b/dsl/src/commonTest/kotlin/query/filter/LogicalFilterTest.kt @@ -16,9 +16,9 @@ package opensavvy.ktmongo.dsl.query.filter -import opensavvy.ktmongo.bson.official.types.ObjectId import opensavvy.ktmongo.dsl.query.shouldBeBson import opensavvy.prepared.runner.kotest.PreparedSpec +import org.bson.types.ObjectId class LogicalFilterTest : PreparedSpec({ suite("Operators $and, $or, and $nor") { diff --git a/dsl/src/commonTest/kotlin/query/update/UpdateUtils.kt b/dsl/src/commonTest/kotlin/query/update/UpdateUtils.kt index 7b611b8..472649a 100644 --- a/dsl/src/commonTest/kotlin/query/update/UpdateUtils.kt +++ b/dsl/src/commonTest/kotlin/query/update/UpdateUtils.kt @@ -16,7 +16,6 @@ package opensavvy.ktmongo.dsl.query.update -import opensavvy.ktmongo.bson.official.types.ObjectId import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.query.UpdateQuery @@ -24,6 +23,7 @@ import opensavvy.ktmongo.dsl.query.UpsertQuery import opensavvy.ktmongo.dsl.query.shouldBeBson import opensavvy.ktmongo.dsl.query.testContext import opensavvy.prepared.runner.kotest.PreparedSpec +import org.bson.types.ObjectId val set = "\$set" val setOnInsert = "\$setOnInsert" -- GitLab From 255f36f4a4682c22340d7299387d8f651b41ea69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 21 Jun 2025 20:37:25 +0200 Subject: [PATCH 11/11] feat(bson-official): Create a codec for the multiplatform ObjectId --- .../src/jvmMain/kotlin/BsonContext.jvm.kt | 2 + .../src/jvmMain/kotlin/types/ObjectId.jvm.kt | 60 ++++++++++++++++--- .../kotlin/options/options/SortTest.kt | 5 +- 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/bson-official/src/jvmMain/kotlin/BsonContext.jvm.kt b/bson-official/src/jvmMain/kotlin/BsonContext.jvm.kt index f318c70..0d036b3 100644 --- a/bson-official/src/jvmMain/kotlin/BsonContext.jvm.kt +++ b/bson-official/src/jvmMain/kotlin/BsonContext.jvm.kt @@ -20,6 +20,7 @@ import opensavvy.ktmongo.bson.BsonFieldWriter import opensavvy.ktmongo.bson.BsonValueWriter import opensavvy.ktmongo.bson.DEPRECATED_IN_BSON_SPEC import opensavvy.ktmongo.bson.official.types.Jvm +import opensavvy.ktmongo.bson.official.types.KotlinObjectIdCodec import opensavvy.ktmongo.bson.types.ObjectIdGenerator import opensavvy.ktmongo.dsl.LowLevelApi import org.bson.* @@ -47,6 +48,7 @@ class JvmBsonContext( CodecRegistries.fromCodecs( KotlinBsonCodec(this), KotlinBsonArrayCodec(this), + KotlinObjectIdCodec() ) ) diff --git a/bson-official/src/jvmMain/kotlin/types/ObjectId.jvm.kt b/bson-official/src/jvmMain/kotlin/types/ObjectId.jvm.kt index 701382c..316eb71 100644 --- a/bson-official/src/jvmMain/kotlin/types/ObjectId.jvm.kt +++ b/bson-official/src/jvmMain/kotlin/types/ObjectId.jvm.kt @@ -18,19 +18,40 @@ package opensavvy.ktmongo.bson.official.types import opensavvy.ktmongo.bson.types.ObjectId import opensavvy.ktmongo.bson.types.ObjectIdGenerator +import org.bson.BsonReader +import org.bson.BsonWriter +import org.bson.codecs.Codec +import org.bson.codecs.DecoderContext +import org.bson.codecs.EncoderContext +import org.bson.codecs.ObjectIdCodec import kotlin.time.ExperimentalTime +// region Conversions + +// Yes, this byte array is wasted memory and GC pressure. +// It is necessary because the Java driver doesn't provide a way to access the nonce +// more efficiently. + +/** + * Converts an ObjectId from the Java driver into a KtMongo ObjectId. + */ +@ExperimentalTime +fun ObjectId.toOfficial(): org.bson.types.ObjectId = + org.bson.types.ObjectId(bytes) + +/** + * Converts an ObjectId from KtMongo into one from the Java driver. + */ +@ExperimentalTime +fun org.bson.types.ObjectId.toKtMongo(): ObjectId = + ObjectId(toByteArray()) + +// endregion +// region Generator + private object JvmObjectIdGenerator : ObjectIdGenerator { @ExperimentalTime - override fun newId(): ObjectId { - val id = org.bson.types.ObjectId() - return ObjectId( - // Yes, this byte array is wasted memory and GC pressure. - // It is necessary because the Java driver doesn't provide a way to access the nonce - // more efficiently. - id.toByteArray() - ) - } + override fun newId(): ObjectId = org.bson.types.ObjectId().toKtMongo() } /** @@ -48,3 +69,24 @@ private object JvmObjectIdGenerator : ObjectIdGenerator { @Suppress("FunctionName", "GrazieInspection") fun ObjectIdGenerator.Companion.Jvm(): ObjectIdGenerator = JvmObjectIdGenerator + +// endregion +// region Codec + +@OptIn(ExperimentalTime::class) +internal class KotlinObjectIdCodec : Codec { + private val objCodec = ObjectIdCodec() + + override fun encode(writer: BsonWriter?, value: ObjectId, encoderContext: EncoderContext?) { + objCodec.encode(writer, value.toOfficial(), encoderContext) + } + + override fun getEncoderClass(): Class = + ObjectId::class.java + + override fun decode(reader: BsonReader, decoderContext: DecoderContext): ObjectId? = + objCodec.decode(reader, decoderContext)?.toKtMongo() + +} + +// endregion diff --git a/dsl/src/commonTest/kotlin/options/options/SortTest.kt b/dsl/src/commonTest/kotlin/options/options/SortTest.kt index c4fe39c..6345f84 100644 --- a/dsl/src/commonTest/kotlin/options/options/SortTest.kt +++ b/dsl/src/commonTest/kotlin/options/options/SortTest.kt @@ -14,14 +14,17 @@ * limitations under the License. */ +@file:OptIn(ExperimentalTime::class) + package opensavvy.ktmongo.dsl.options.options +import opensavvy.ktmongo.bson.types.ObjectId import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.command.FindOptions import opensavvy.ktmongo.dsl.query.shouldBeBson import opensavvy.ktmongo.dsl.query.testContext import opensavvy.prepared.runner.kotest.PreparedSpec -import org.bson.types.ObjectId +import kotlin.time.ExperimentalTime @LowLevelApi class SortTest : PreparedSpec({ -- GitLab