From 88ed66f66da457f80f6dc64a9afb9d8d0cdd0da1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 14 Jun 2025 15:54:42 +0200 Subject: [PATCH 1/4] feat(bson): Create the Timestamp type Future work: - replace Reader and Writer interfaces to use Timestamp instead of Long - implement Timestamp for :bson-multiplatform - ensure :bson-official can serialize and deserialize Timestamp --- bson/src/commonMain/kotlin/types/Timestamp.kt | 113 +++++++++++++++++ bson/src/commonTest/kotlin/Marker.kt | 17 +++ .../commonTest/kotlin/types/TimestampTest.kt | 119 ++++++++++++++++++ 3 files changed, 249 insertions(+) create mode 100644 bson/src/commonMain/kotlin/types/Timestamp.kt create mode 100644 bson/src/commonTest/kotlin/Marker.kt create mode 100644 bson/src/commonTest/kotlin/types/TimestampTest.kt diff --git a/bson/src/commonMain/kotlin/types/Timestamp.kt b/bson/src/commonMain/kotlin/types/Timestamp.kt new file mode 100644 index 00000000..02368590 --- /dev/null +++ b/bson/src/commonMain/kotlin/types/Timestamp.kt @@ -0,0 +1,113 @@ +/* + * 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 opensavvy.ktmongo.bson.types.Timestamp.Companion.MAX_COUNTER +import opensavvy.ktmongo.bson.types.Timestamp.Companion.MAX_INSTANT +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +/** + * Internal MongoDB timestamp used in the oplog. + * + * Use the [kotlin.time.Instant] type for operations involving dates. + */ +class Timestamp( + /** + * The raw value for this timestamp. + * + * The first four bytes are an increment, the last four are a timestamp. + * Note that this means sorting the timestamps by this value will not output a date-based sort. + * However, the [Comparable] implementation of this class does allow sorting with a date-based order. + */ + val value: Long, +) : Comparable { + + /** + * Constructs a timestamp from its [Timestamp.instant] and [Timestamp.counter] components. + */ + @ExperimentalTime + constructor(instant: Instant, counter: UInt) : this( + (counter.toULong() shl 32).toLong() + (instant.epochSeconds % (1L shl 32)) + ) + + /** + * Date and time represented by this [Timestamp], with a precision of one second. + * + * A [Timestamp] can represent seconds between the UNIX epoch (Jan 1st 1970) and [MAX_INSTANT]. + */ + @ExperimentalTime + val instant: Instant + get() = Instant.fromEpochSeconds(value and UInt.MAX_VALUE.toLong()) + + /** + * Incrementing counter. + */ + val counter: UInt + get() = (value.toULong() shr 32).toUInt() + + @OptIn(ExperimentalTime::class) + override fun compareTo(other: Timestamp): Int = + chronologicalComparator.compare(this, other) + + // region Identity + + override fun equals(other: Any?): Boolean = + other is Timestamp && value == other.value + + override fun hashCode(): Int = value.hashCode() + + @OptIn(ExperimentalTime::class) + override fun toString(): String = + "Timestamp($instant, #$counter)" + + // endregion + + companion object { + + /** + * The smallest possible [Timestamp] instance. + * + * This timestamp marks the UNIX epoch, Jan 1st 1970. + */ + val MIN get() = Timestamp(0) + + /** + * The largest possible [Timestamp] instance. + * + * It is composed using [MAX_INSTANT] and [MAX_COUNTER]. + */ + val MAX get() = Timestamp(-1) + + /** + * The maximum possible instant that can be represented by a [Timestamp], which will happen during the year 2106. + */ + @ExperimentalTime + val MAX_INSTANT get() = Instant.fromEpochSeconds(UInt.MAX_VALUE.toLong()) + + /** + * The maximum possible counter for a given instant. + */ + val MAX_COUNTER get() = UInt.MAX_VALUE + + @ExperimentalTime + val chronologicalComparator = compareBy( + { it.instant }, + { it.counter }, + ) + } +} diff --git a/bson/src/commonTest/kotlin/Marker.kt b/bson/src/commonTest/kotlin/Marker.kt new file mode 100644 index 00000000..1a724ed8 --- /dev/null +++ b/bson/src/commonTest/kotlin/Marker.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/src/commonTest/kotlin/types/TimestampTest.kt b/bson/src/commonTest/kotlin/types/TimestampTest.kt new file mode 100644 index 00000000..8580d981 --- /dev/null +++ b/bson/src/commonTest/kotlin/types/TimestampTest.kt @@ -0,0 +1,119 @@ +/* + * 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 opensavvy.prepared.runner.kotest.PreparedSpec +import opensavvy.prepared.suite.random.Random +import opensavvy.prepared.suite.random.nextLong +import opensavvy.prepared.suite.random.random +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +class TimestampTest : PreparedSpec({ + + suite("Creation and formatting") { + val examples = listOf( + 0L to (Instant.parse("1970-01-01T00:00:00Z") to 0.toUInt()), + 1L to (Instant.parse("1970-01-01T00:00:01Z") to 0.toUInt()), + 67L to (Instant.parse("1970-01-01T00:01:07Z") to 0.toUInt()), + (1L shl 32) to (Instant.parse("1970-01-01T00:00:00Z") to 1.toUInt()), + (4L shl 32) to (Instant.parse("1970-01-01T00:00:00Z") to 4.toUInt()), + 14588972749L to (Instant.parse("2024-01-01T01:01:01Z") to 3.toUInt()), + -1L to (Timestamp.MAX_INSTANT to Timestamp.MAX_COUNTER), + Timestamp.MAX.value to (Timestamp.MAX_INSTANT to Timestamp.MAX_COUNTER), + ) + + for ((value, fields) in examples) { + val (instant, counter) = fields + test("A timestamp at $instant + $counter can be round-tripped to itself") { + check(Timestamp(instant, counter).instant == instant) + check(Timestamp(instant, counter).counter == counter) + } + + test("A timestamp at $instant + $counter has value $value") { + check(Timestamp(instant, counter).value == value) + } + + test("A timestamp with value $value has the components $instant + $counter") { + check(Timestamp(value).instant == instant) + check(Timestamp(value).counter == counter) + } + } + } + + suite("Chronological order") { + test("The maximum timestamp is greater than all others") { + val random = random.accessUnsafe() + repeat(1000) { + val timestamp = generateSequence { Timestamp(random.nextLong()) } + .first { it != Timestamp.MAX } + + check(timestamp < Timestamp.MAX) + } + } + + test("The minimum timestamp is lesser than all others") { + val random = random.accessUnsafe() + repeat(1000) { + val timestamp = generateSequence { Timestamp(random.nextLong()) } + .first { it != Timestamp.MIN } + + check(timestamp > Timestamp.MIN) + } + } + + test("Timestamps are ordered by instant, ignoring counters") { + val instant = random.nextInstant(until = Timestamp.MAX_INSTANT.epochSeconds - 1) + + repeat(1000) { + val largerInstant = random.nextInstant(from = instant.epochSeconds + 1) + val counter1 = random.nextCounter() + val counter2 = random.nextCounter() + + check(Timestamp(instant, counter1) < Timestamp(largerInstant, counter2)) + } + } + + test("For a given instant, timestamps are ordered by counter") { + repeat(1000) { + val instant = random.nextInstant() + val counter = random.nextCounter(until = Timestamp.MAX_COUNTER - 1u) + val largerCounter = random.nextCounter(from = counter + 1u) + + check(Timestamp(instant, counter) < Timestamp(instant, largerCounter)) + } + } + + test("Total orders should be anti-symmetric") { + repeat(1000) { + val instant1 = Timestamp(random.nextInstant(), random.nextCounter()) + val instant2 = Timestamp(random.nextInstant(), random.nextCounter()) + + check(instant1.compareTo(instant2) == -instant2.compareTo(instant1)) + } + } + } + +}) + +private suspend fun Random.nextInstant(from: Long = 0, until: Long = Timestamp.MAX_INSTANT.epochSeconds) = + Instant.fromEpochSeconds(nextLong(from, until)) + +private suspend fun Random.nextCounter(from: UInt = 0u, until: UInt = Timestamp.MAX_COUNTER) = + nextLong(from.toULong().toLong(), until.toULong().toLong()).toUInt() -- 2.51.2 From f8109c1f6f4408842b55462729413da372641ed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 14 Jun 2025 19:31:18 +0200 Subject: [PATCH 2/4] feat(bson): Replace all Long timestamps by Timestamp instances --- .../MultiplatformBsonArrayFieldWriter.kt | 3 ++- .../kotlin/MultiplatformBsonFieldWriter.kt | 3 ++- .../MultiplatformBsonSingleFieldWriter.kt | 3 ++- .../kotlin/MultiplatformBsonValueReader.kt | 3 ++- .../src/jvmMain/kotlin/BsonContext.jvm.kt | 16 +++++++----- .../src/jvmMain/kotlin/BsonReader.jvm.kt | 6 +++-- .../src/jvmMain/kotlin/types/Timestamp.kt | 26 +++++++++++++++++++ bson/src/commonMain/kotlin/BsonReader.kt | 3 ++- bson/src/commonMain/kotlin/BsonWriter.kt | 7 +++-- 9 files changed, 55 insertions(+), 15 deletions(-) create mode 100644 bson-official/src/jvmMain/kotlin/types/Timestamp.kt diff --git a/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonArrayFieldWriter.kt b/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonArrayFieldWriter.kt index 41be0901..cad30287 100644 --- a/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonArrayFieldWriter.kt +++ b/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonArrayFieldWriter.kt @@ -19,6 +19,7 @@ package opensavvy.ktmongo.bson.multiplatform import opensavvy.ktmongo.bson.BsonFieldWriter import opensavvy.ktmongo.bson.BsonValueWriter import opensavvy.ktmongo.bson.DEPRECATED_IN_BSON_SPEC +import opensavvy.ktmongo.bson.types.Timestamp import opensavvy.ktmongo.dsl.LowLevelApi @LowLevelApi @@ -81,7 +82,7 @@ internal class MultiplatformBsonArrayFieldWriter( } @LowLevelApi - override fun writeTimestamp(value: Long) { + override fun writeTimestamp(value: Timestamp) { writer.writeTimestamp(nextIndex(), value) } diff --git a/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonFieldWriter.kt b/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonFieldWriter.kt index 64a79988..e5161215 100644 --- a/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonFieldWriter.kt +++ b/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonFieldWriter.kt @@ -22,6 +22,7 @@ import opensavvy.ktmongo.bson.BsonType import opensavvy.ktmongo.bson.BsonValueWriter import opensavvy.ktmongo.bson.DEPRECATED_IN_BSON_SPEC import opensavvy.ktmongo.dsl.DangerousMongoApi +import opensavvy.ktmongo.bson.types.Timestamp import opensavvy.ktmongo.dsl.LowLevelApi @LowLevelApi @@ -111,7 +112,7 @@ internal class MultiplatformBsonFieldWriter( } @LowLevelApi - override fun writeTimestamp(name: String, value: Long) { + override fun writeTimestamp(name: String, value: Timestamp) { TODO() } diff --git a/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonSingleFieldWriter.kt b/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonSingleFieldWriter.kt index 242303a9..56d252e6 100644 --- a/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonSingleFieldWriter.kt +++ b/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonSingleFieldWriter.kt @@ -20,6 +20,7 @@ import opensavvy.ktmongo.bson.BsonFieldWriter import opensavvy.ktmongo.bson.BsonValueReader import opensavvy.ktmongo.bson.BsonValueWriter import opensavvy.ktmongo.dsl.DangerousMongoApi +import opensavvy.ktmongo.bson.types.Timestamp import opensavvy.ktmongo.dsl.LowLevelApi @LowLevelApi @@ -79,7 +80,7 @@ internal class MultiplatformBsonSingleFieldWriter( } @LowLevelApi - override fun writeTimestamp(value: Long) { + override fun writeTimestamp(value: Timestamp) { writer.writeTimestamp(name, value) } diff --git a/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonValueReader.kt b/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonValueReader.kt index 8e392639..8e25a894 100644 --- a/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonValueReader.kt +++ b/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonValueReader.kt @@ -18,6 +18,7 @@ package opensavvy.ktmongo.bson.multiplatform import opensavvy.ktmongo.bson.* import opensavvy.ktmongo.dsl.DangerousMongoApi +import opensavvy.ktmongo.bson.types.Timestamp import opensavvy.ktmongo.dsl.LowLevelApi import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -108,7 +109,7 @@ internal class MultiplatformBsonValueReader( } @LowLevelApi - override fun readTimestamp(): Long { + override fun readTimestamp(): Timestamp { checkType(BsonType.Timestamp) TODO("Not yet implemented") } diff --git a/bson-official/src/jvmMain/kotlin/BsonContext.jvm.kt b/bson-official/src/jvmMain/kotlin/BsonContext.jvm.kt index 0d036b3e..06207e5b 100644 --- a/bson-official/src/jvmMain/kotlin/BsonContext.jvm.kt +++ b/bson-official/src/jvmMain/kotlin/BsonContext.jvm.kt @@ -21,7 +21,11 @@ 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.official.types.toOfficial import opensavvy.ktmongo.bson.types.ObjectIdGenerator +import opensavvy.ktmongo.bson.official.types.Jvm +import opensavvy.ktmongo.bson.official.types.toOfficial +import opensavvy.ktmongo.bson.types.Timestamp import opensavvy.ktmongo.dsl.LowLevelApi import org.bson.* import org.bson.BsonArray @@ -139,8 +143,8 @@ private class JavaBsonWriter( writer.writeString(name, value) } - override fun writeTimestamp(name: String, value: Long) { - writer.writeTimestamp(name, BsonTimestamp(value)) + override fun writeTimestamp(name: String, value: Timestamp) { + writer.writeTimestamp(name, value.toOfficial()) } @Deprecated(DEPRECATED_IN_BSON_SPEC) @@ -236,8 +240,8 @@ private class JavaBsonWriter( writer.writeString(value) } - override fun writeTimestamp(value: Long) { - writer.writeTimestamp(BsonTimestamp(value)) + override fun writeTimestamp(value: Timestamp) { + writer.writeTimestamp(value.toOfficial()) } @Deprecated(DEPRECATED_IN_BSON_SPEC) @@ -369,8 +373,8 @@ private class JavaRootArrayWriter( } @LowLevelApi - override fun writeTimestamp(value: Long) { - array.add(BsonTimestamp(value)) + override fun writeTimestamp(value: Timestamp) { + array.add(value.toOfficial()) } @LowLevelApi diff --git a/bson-official/src/jvmMain/kotlin/BsonReader.jvm.kt b/bson-official/src/jvmMain/kotlin/BsonReader.jvm.kt index 21bd270d..94a8bf0c 100644 --- a/bson-official/src/jvmMain/kotlin/BsonReader.jvm.kt +++ b/bson-official/src/jvmMain/kotlin/BsonReader.jvm.kt @@ -20,6 +20,8 @@ import opensavvy.ktmongo.bson.* import opensavvy.ktmongo.bson.BsonArrayReader import opensavvy.ktmongo.bson.BsonDocumentReader import opensavvy.ktmongo.bson.BsonValueReader +import opensavvy.ktmongo.bson.official.types.toKtMongo +import opensavvy.ktmongo.bson.types.Timestamp import opensavvy.ktmongo.dsl.LowLevelApi import org.bson.BsonDocument import org.bson.BsonValue @@ -166,9 +168,9 @@ private class BsonValueReader( } @LowLevelApi - override fun readTimestamp(): Long { + override fun readTimestamp(): Timestamp { ensureType(BsonType.Timestamp) { value.isTimestamp } - return value.asTimestamp().value + return value.asTimestamp().toKtMongo() } @Suppress("DEPRECATION") diff --git a/bson-official/src/jvmMain/kotlin/types/Timestamp.kt b/bson-official/src/jvmMain/kotlin/types/Timestamp.kt new file mode 100644 index 00000000..3232dd65 --- /dev/null +++ b/bson-official/src/jvmMain/kotlin/types/Timestamp.kt @@ -0,0 +1,26 @@ +/* + * 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.types + +import opensavvy.ktmongo.bson.types.Timestamp +import org.bson.BsonTimestamp + +fun Timestamp.toOfficial(): BsonTimestamp = + BsonTimestamp(value) + +fun BsonTimestamp.toKtMongo(): Timestamp = + Timestamp(value) diff --git a/bson/src/commonMain/kotlin/BsonReader.kt b/bson/src/commonMain/kotlin/BsonReader.kt index c8250d15..55597816 100644 --- a/bson/src/commonMain/kotlin/BsonReader.kt +++ b/bson/src/commonMain/kotlin/BsonReader.kt @@ -16,6 +16,7 @@ package opensavvy.ktmongo.bson +import opensavvy.ktmongo.bson.types.Timestamp import opensavvy.ktmongo.dsl.LowLevelApi import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -199,7 +200,7 @@ interface BsonValueReader { @LowLevelApi @Throws(BsonReaderException::class) - fun readTimestamp(): Long + fun readTimestamp(): Timestamp @Deprecated(DEPRECATED_IN_BSON_SPEC) @LowLevelApi diff --git a/bson/src/commonMain/kotlin/BsonWriter.kt b/bson/src/commonMain/kotlin/BsonWriter.kt index 65099e2f..dbad9bef 100644 --- a/bson/src/commonMain/kotlin/BsonWriter.kt +++ b/bson/src/commonMain/kotlin/BsonWriter.kt @@ -16,6 +16,7 @@ package opensavvy.ktmongo.bson +import opensavvy.ktmongo.bson.types.Timestamp import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.LowLevelApi import kotlin.experimental.and @@ -70,7 +71,8 @@ interface BsonValueWriter : AnyBsonWriter { @LowLevelApi fun writeObjectId(id: ByteArray) @LowLevelApi fun writeRegularExpression(pattern: String, options: String) @LowLevelApi fun writeString(value: String) - @LowLevelApi fun writeTimestamp(value: Long) + @LowLevelApi + fun writeTimestamp(value: Timestamp) @Deprecated(DEPRECATED_IN_BSON_SPEC) @LowLevelApi fun writeSymbol(value: String) @@ -200,7 +202,8 @@ interface BsonFieldWriter : AnyBsonWriter { @LowLevelApi fun writeObjectId(name: String, id: ByteArray) @LowLevelApi fun writeRegularExpression(name: String, pattern: String, options: String) @LowLevelApi fun writeString(name: String, value: String) - @LowLevelApi fun writeTimestamp(name: String, value: Long) + @LowLevelApi + fun writeTimestamp(name: String, value: Timestamp) @Deprecated(DEPRECATED_IN_BSON_SPEC) @LowLevelApi fun writeSymbol(name: String, value: String) -- 2.51.2 From 30eb61f4e5f4a3d1a1cefc049968109245164a2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 29 Jun 2025 17:19:14 +0200 Subject: [PATCH 3/4] feat(bson): Reorder Timestamp as big-endian to simplify implementation --- .../src/jvmMain/kotlin/types/Timestamp.kt | 4 +- .../kotlin/types/OfficialTimestampTest.kt | 39 +++++++++++++++++++ bson/src/commonMain/kotlin/types/Timestamp.kt | 30 +++++++------- .../commonTest/kotlin/types/TimestampTest.kt | 20 +++++----- 4 files changed, 65 insertions(+), 28 deletions(-) create mode 100644 bson-official/src/jvmTest/kotlin/types/OfficialTimestampTest.kt diff --git a/bson-official/src/jvmMain/kotlin/types/Timestamp.kt b/bson-official/src/jvmMain/kotlin/types/Timestamp.kt index 3232dd65..5afb527e 100644 --- a/bson-official/src/jvmMain/kotlin/types/Timestamp.kt +++ b/bson-official/src/jvmMain/kotlin/types/Timestamp.kt @@ -20,7 +20,7 @@ import opensavvy.ktmongo.bson.types.Timestamp import org.bson.BsonTimestamp fun Timestamp.toOfficial(): BsonTimestamp = - BsonTimestamp(value) + BsonTimestamp(value.toLong()) fun BsonTimestamp.toKtMongo(): Timestamp = - Timestamp(value) + Timestamp(value.toULong()) diff --git a/bson-official/src/jvmTest/kotlin/types/OfficialTimestampTest.kt b/bson-official/src/jvmTest/kotlin/types/OfficialTimestampTest.kt new file mode 100644 index 00000000..10e6b17a --- /dev/null +++ b/bson-official/src/jvmTest/kotlin/types/OfficialTimestampTest.kt @@ -0,0 +1,39 @@ +/* + * 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.official.types + +import opensavvy.ktmongo.bson.types.Timestamp +import opensavvy.prepared.runner.kotest.PreparedSpec +import opensavvy.prepared.suite.random.nextLong +import opensavvy.prepared.suite.random.random +import kotlin.time.ExperimentalTime + +class OfficialTimestampTest : PreparedSpec({ + + test("Round-trip between official Timestamp and KtMongo Timestamp") { + repeat(1000) { + val timestamp = Timestamp(random.nextLong().toULong()) + + check(timestamp.toOfficial().toKtMongo() == timestamp) + check(timestamp.toOfficial().time == timestamp.instant.epochSeconds.toInt()) + check(timestamp.toOfficial().inc == timestamp.counter.toInt()) + } + } + +}) diff --git a/bson/src/commonMain/kotlin/types/Timestamp.kt b/bson/src/commonMain/kotlin/types/Timestamp.kt index 02368590..8799c8e6 100644 --- a/bson/src/commonMain/kotlin/types/Timestamp.kt +++ b/bson/src/commonMain/kotlin/types/Timestamp.kt @@ -28,13 +28,15 @@ import kotlin.time.Instant */ class Timestamp( /** - * The raw value for this timestamp. + * The raw value for this [Timestamp]. * - * The first four bytes are an increment, the last four are a timestamp. - * Note that this means sorting the timestamps by this value will not output a date-based sort. - * However, the [Comparable] implementation of this class does allow sorting with a date-based order. + * The first four bytes are a [timestamp][instant], the last four as an [increment][counter]. + * + * **Note that this value is stored in big-endian representation, whereas the BSON specification + * represents timestamps in little-endian.** Drivers using this type are expected to invert the endianness of this + * number. This implementation choice allows better sorting performance. */ - val value: Long, + val value: ULong, ) : Comparable { /** @@ -42,7 +44,7 @@ class Timestamp( */ @ExperimentalTime constructor(instant: Instant, counter: UInt) : this( - (counter.toULong() shl 32).toLong() + (instant.epochSeconds % (1L shl 32)) + (instant.epochSeconds.toULong() shl 32) + (counter % (1L shl 32).toULong()) ) /** @@ -52,17 +54,17 @@ class Timestamp( */ @ExperimentalTime val instant: Instant - get() = Instant.fromEpochSeconds(value and UInt.MAX_VALUE.toLong()) + get() = Instant.fromEpochSeconds((value shr 32).toLong()) /** * Incrementing counter. */ val counter: UInt - get() = (value.toULong() shr 32).toUInt() + get() = (value and UInt.MAX_VALUE.toULong()).toUInt() @OptIn(ExperimentalTime::class) override fun compareTo(other: Timestamp): Int = - chronologicalComparator.compare(this, other) + value.compareTo(other.value) // region Identity @@ -84,14 +86,14 @@ class Timestamp( * * This timestamp marks the UNIX epoch, Jan 1st 1970. */ - val MIN get() = Timestamp(0) + val MIN get() = Timestamp(0u) /** * The largest possible [Timestamp] instance. * * It is composed using [MAX_INSTANT] and [MAX_COUNTER]. */ - val MAX get() = Timestamp(-1) + val MAX get() = Timestamp(ULong.MAX_VALUE) /** * The maximum possible instant that can be represented by a [Timestamp], which will happen during the year 2106. @@ -103,11 +105,5 @@ class Timestamp( * The maximum possible counter for a given instant. */ val MAX_COUNTER get() = UInt.MAX_VALUE - - @ExperimentalTime - val chronologicalComparator = compareBy( - { it.instant }, - { it.counter }, - ) } } diff --git a/bson/src/commonTest/kotlin/types/TimestampTest.kt b/bson/src/commonTest/kotlin/types/TimestampTest.kt index 8580d981..c28ecf6e 100644 --- a/bson/src/commonTest/kotlin/types/TimestampTest.kt +++ b/bson/src/commonTest/kotlin/types/TimestampTest.kt @@ -22,6 +22,7 @@ import opensavvy.prepared.runner.kotest.PreparedSpec import opensavvy.prepared.suite.random.Random import opensavvy.prepared.suite.random.nextLong import opensavvy.prepared.suite.random.random +import kotlin.random.nextULong import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -29,13 +30,14 @@ class TimestampTest : PreparedSpec({ suite("Creation and formatting") { val examples = listOf( - 0L to (Instant.parse("1970-01-01T00:00:00Z") to 0.toUInt()), - 1L to (Instant.parse("1970-01-01T00:00:01Z") to 0.toUInt()), - 67L to (Instant.parse("1970-01-01T00:01:07Z") to 0.toUInt()), - (1L shl 32) to (Instant.parse("1970-01-01T00:00:00Z") to 1.toUInt()), - (4L shl 32) to (Instant.parse("1970-01-01T00:00:00Z") to 4.toUInt()), - 14588972749L to (Instant.parse("2024-01-01T01:01:01Z") to 3.toUInt()), - -1L to (Timestamp.MAX_INSTANT to Timestamp.MAX_COUNTER), + 0uL to (Instant.parse("1970-01-01T00:00:00Z") to 0.toUInt()), + 1uL to (Instant.parse("1970-01-01T00:00:00Z") to 1.toUInt()), + 67uL to (Instant.parse("1970-01-01T00:00:00Z") to 67.toUInt()), + (1uL shl 32) to (Instant.parse("1970-01-01T00:00:01Z") to 0.toUInt()), + (4uL shl 32) to (Instant.parse("1970-01-01T00:00:04Z") to 0.toUInt()), + (67uL shl 32) to (Instant.parse("1970-01-01T00:01:07Z") to 0.toUInt()), + 7318928618061561859uL to (Instant.parse("2024-01-01T01:01:01Z") to 3.toUInt()), + ULong.MAX_VALUE to (Timestamp.MAX_INSTANT to Timestamp.MAX_COUNTER), Timestamp.MAX.value to (Timestamp.MAX_INSTANT to Timestamp.MAX_COUNTER), ) @@ -61,7 +63,7 @@ class TimestampTest : PreparedSpec({ test("The maximum timestamp is greater than all others") { val random = random.accessUnsafe() repeat(1000) { - val timestamp = generateSequence { Timestamp(random.nextLong()) } + val timestamp = generateSequence { Timestamp(random.nextULong()) } .first { it != Timestamp.MAX } check(timestamp < Timestamp.MAX) @@ -71,7 +73,7 @@ class TimestampTest : PreparedSpec({ test("The minimum timestamp is lesser than all others") { val random = random.accessUnsafe() repeat(1000) { - val timestamp = generateSequence { Timestamp(random.nextLong()) } + val timestamp = generateSequence { Timestamp(random.nextULong()) } .first { it != Timestamp.MIN } check(timestamp > Timestamp.MIN) -- 2.51.2 From aa3c9a9e89b5241054ab0bdde37d33ff03966c39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 29 Jun 2025 17:46:12 +0200 Subject: [PATCH 4/4] feat(bson-multiplatform): Read and write Timestamp --- .../kotlin/MultiplatformBsonFieldWriter.kt | 6 +- .../kotlin/MultiplatformBsonValueReader.kt | 9 +- .../src/commonMain/kotlin/RawBsonWriter.kt | 2 +- .../src/commonTest/kotlin/Bson.kt | 1 + .../src/commonMain/kotlin/BsonWriterTest.kt | 1 + .../commonMain/kotlin/raw/TimestampTest.kt | 89 +++++++++++++++++++ 6 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 bson-tests/src/commonMain/kotlin/raw/TimestampTest.kt diff --git a/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonFieldWriter.kt b/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonFieldWriter.kt index e5161215..5257fd6e 100644 --- a/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonFieldWriter.kt +++ b/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonFieldWriter.kt @@ -21,8 +21,8 @@ import opensavvy.ktmongo.bson.BsonFieldWriter import opensavvy.ktmongo.bson.BsonType import opensavvy.ktmongo.bson.BsonValueWriter import opensavvy.ktmongo.bson.DEPRECATED_IN_BSON_SPEC -import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.bson.types.Timestamp +import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.LowLevelApi @LowLevelApi @@ -113,7 +113,9 @@ internal class MultiplatformBsonFieldWriter( @LowLevelApi override fun writeTimestamp(name: String, value: Timestamp) { - TODO() + writeType(BsonType.Timestamp) + writeName(name) + writer.writeUInt64(value.value) } @Suppress("DEPRECATION") diff --git a/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonValueReader.kt b/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonValueReader.kt index 8e25a894..a991415d 100644 --- a/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonValueReader.kt +++ b/bson-multiplatform/src/commonMain/kotlin/MultiplatformBsonValueReader.kt @@ -17,8 +17,8 @@ package opensavvy.ktmongo.bson.multiplatform import opensavvy.ktmongo.bson.* -import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.bson.types.Timestamp +import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.LowLevelApi import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -111,7 +111,7 @@ internal class MultiplatformBsonValueReader( @LowLevelApi override fun readTimestamp(): Timestamp { checkType(BsonType.Timestamp) - TODO("Not yet implemented") + return Timestamp(bytes.reader.readUInt64()) } @LowLevelApi @@ -236,6 +236,11 @@ internal class MultiplatformBsonValueReader( .replace("\"", "\\\"") """{"${'$'}regularExpression": {"pattern": "$escapedPattern", "options": "$options"}}""" } + BsonType.Timestamp -> { + val timestamp = readTimestamp() + + """{"${'$'}timestamp": {"t": ${timestamp.instant.epochSeconds}, "i": ${timestamp.counter}}}""" + } BsonType.MinKey -> """{"${'$'}minKey": 1}""" BsonType.MaxKey -> """{"${'$'}maxKey": 1}""" else -> "{$type}: $bytes" // TODO diff --git a/bson-multiplatform/src/commonMain/kotlin/RawBsonWriter.kt b/bson-multiplatform/src/commonMain/kotlin/RawBsonWriter.kt index 4c4ae46c..f9636497 100644 --- a/bson-multiplatform/src/commonMain/kotlin/RawBsonWriter.kt +++ b/bson-multiplatform/src/commonMain/kotlin/RawBsonWriter.kt @@ -40,7 +40,7 @@ internal class RawBsonWriter( sink.writeLongLe(value) } - fun writeUInt54(value: ULong) { + fun writeUInt64(value: ULong) { sink.writeULongLe(value) } diff --git a/bson-multiplatform/src/commonTest/kotlin/Bson.kt b/bson-multiplatform/src/commonTest/kotlin/Bson.kt index 5ca056b5..fe456f0a 100644 --- a/bson-multiplatform/src/commonTest/kotlin/Bson.kt +++ b/bson-multiplatform/src/commonTest/kotlin/Bson.kt @@ -42,6 +42,7 @@ class MultiplatformBsonWriterTest : PreparedSpec({ datetime(context) minMaxKey(context) regex(context) + timestamp(context) @OptIn(DangerousMongoApi::class, LowLevelApi::class) test("Pipe objects") { diff --git a/bson-tests/src/commonMain/kotlin/BsonWriterTest.kt b/bson-tests/src/commonMain/kotlin/BsonWriterTest.kt index 67cd06f0..6b029557 100644 --- a/bson-tests/src/commonMain/kotlin/BsonWriterTest.kt +++ b/bson-tests/src/commonMain/kotlin/BsonWriterTest.kt @@ -95,6 +95,7 @@ fun SuiteDsl.writerTests( datetime(prepareContext) minMaxKey(prepareContext) regex(prepareContext) + timestamp(prepareContext) } @OptIn(DangerousMongoApi::class) diff --git a/bson-tests/src/commonMain/kotlin/raw/TimestampTest.kt b/bson-tests/src/commonMain/kotlin/raw/TimestampTest.kt new file mode 100644 index 00000000..ccea3907 --- /dev/null +++ b/bson-tests/src/commonMain/kotlin/raw/TimestampTest.kt @@ -0,0 +1,89 @@ +/* + * 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(LowLevelApi::class, ExperimentalTime::class) + +package opensavvy.ktmongo.bson.raw + +import io.kotest.matchers.shouldBe +import opensavvy.ktmongo.bson.BsonContext +import opensavvy.ktmongo.bson.raw.BsonDeclaration.Companion.document +import opensavvy.ktmongo.bson.raw.BsonDeclaration.Companion.hex +import opensavvy.ktmongo.bson.raw.BsonDeclaration.Companion.json +import opensavvy.ktmongo.bson.raw.BsonDeclaration.Companion.verify +import opensavvy.ktmongo.bson.types.Timestamp +import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.prepared.suite.Prepared +import opensavvy.prepared.suite.SuiteDsl +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +/** + * Test timestamp representations. + * + * Adapted from https://github.com/mongodb/specifications/blob/master/source/bson-corpus/tests/timestamp.json. + */ +@OptIn(ExperimentalEncodingApi::class) +fun SuiteDsl.timestamp(context: Prepared) = suite("Timestamp") { + testBson( + context, + "Timestamp: (123456789, 42)", + document { + writeTimestamp("a", Timestamp(Instant.fromEpochSeconds(123456789), 42u)) + }, + hex("100000001161002A00000015CD5B0700"), + json($$"""{"a": {"$timestamp": {"t": 123456789, "i": 42}}}"""), + verify("Read the timestamp") { + read("a")?.readTimestamp()?.instant?.epochSeconds shouldBe 123456789L + }, + verify("Read the counter") { + read("a")?.readTimestamp()?.counter shouldBe 42u + } + ) + + testBson( + context, + "Timestamp with high-order bit set on both seconds and increment", + document { + writeTimestamp("a", Timestamp(Instant.fromEpochSeconds(4294967295), 4294967295u)) + }, + hex("10000000116100FFFFFFFFFFFFFFFF00"), + json($$"""{"a": {"$timestamp": {"t": 4294967295, "i": 4294967295}}}"""), + verify("Read the timestamp") { + read("a")?.readTimestamp()?.instant?.epochSeconds shouldBe 4294967295L + }, + verify("Read the counter") { + read("a")?.readTimestamp()?.counter shouldBe 4294967295u + } + ) + + testBson( + context, + "Timestamp with high-order bit set on both seconds and increment (not UINT32_MAX)", + document { + writeTimestamp("a", Timestamp(Instant.fromEpochSeconds(4000000000), 4000000000u)) + }, + hex("1000000011610000286BEE00286BEE00"), + json($$"""{"a": {"$timestamp": {"t": 4000000000, "i": 4000000000}}}"""), + verify("Read the timestamp") { + read("a")?.readTimestamp()?.instant?.epochSeconds shouldBe 4000000000L + }, + verify("Read the counter") { + read("a")?.readTimestamp()?.counter shouldBe 4000000000u + } + ) +} -- 2.51.2