Skip to content

Commit b07c0e4

Browse files
committed
add from-string decoding support
1 parent 26052b3 commit b07c0e4

4 files changed

Lines changed: 86 additions & 33 deletions

File tree

gradle/libs.versions.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ crypto-rand = { module = "org.kotlincrypto.random:crypto-rand", version = "0.5.1
1010
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version = "0.6.2" }
1111
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version = "1.8.1" }
1212
kotlinx-benchmark-runtime = { module = "org.jetbrains.kotlinx:kotlinx-benchmark-runtime", version.ref = "kotlinx-benchmark" }
13+
kotlinx-io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version = "0.8.0"}
1314

1415
[bundles]
1516

kulid/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ kotlin {
9090
// TODO we may eventually be able to replace this with a part of the standard library
9191
api(libs.kotlinx.datetime)
9292
api(libs.kotlinx.serialization.core)
93+
api(libs.kotlinx.io)
9394
}
9495
}
9596
commonTest {

kulid/src/commonMain/kotlin/dev/phillipslabs/kulid/ULID.kt

Lines changed: 68 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package dev.phillipslabs.kulid
22

33
import kotlinx.datetime.Clock
4-
import kotlinx.serialization.Serializable
4+
import kotlinx.io.Buffer
5+
import kotlinx.io.bytestring.ByteString
6+
import kotlinx.io.readByteString
57
import org.kotlincrypto.random.CryptoRand
68
import kotlin.jvm.JvmInline
79
import kotlin.math.absoluteValue
@@ -14,9 +16,8 @@ internal const val MAX_TIME = (1L shl TIMESTAMP_BIT_SIZE) - 1 // 2^48 - 1
1416
private const val RANDOM_BIT_SIZE = 80
1517
private const val ULID_BIT_SIZE = TIMESTAMP_BIT_SIZE + RANDOM_BIT_SIZE
1618

17-
private const val ULID_BYTE_SIZE = ULID_BIT_SIZE / 8
19+
internal const val ULID_BYTE_SIZE = ULID_BIT_SIZE / 8
1820
private const val RANDOM_BYTE_SIZE = RANDOM_BIT_SIZE / 8
19-
internal const val TIMESTAMP_BYTE_SIZE = TIMESTAMP_BIT_SIZE / 8
2021

2122
private const val ENCODED_ULID_BYTE_SIZE = 26
2223

@@ -27,48 +28,91 @@ private const val ULID_ENCODING_FRONT_PADDING = ENCODED_ULID_BYTE_SIZE * 5 - ULI
2728
// Crockford's base32 alphabet
2829
private val ENCODING_CHARS = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".toCharArray()
2930

30-
// TODO we will eventually move this to a byte array once we want to support the binary format
31-
@Serializable
31+
// @Serializable
3232
@JvmInline
3333
public value class ULID private constructor(
34-
public val value: String,
35-
) {
34+
public val value: ByteString,
35+
) : Comparable<ULID> {
36+
init {
37+
require(value.size == ULID_BYTE_SIZE) { "ULID should have $ULID_BYTE_SIZE bytes, but had ${value.size}" }
38+
}
39+
40+
override fun compareTo(other: ULID): Int = value.compareTo(other.value)
41+
42+
override fun toString(): String = encodeCrockfordBase32(value)
43+
3644
public companion object {
37-
public val MAX: ULID = ULID("7ZZZZZZZZZZZZZZZZZZZZZZZZZ")
38-
public val MIN: ULID = ULID("00000000000000000000000000")
45+
public val MAX: ULID = fromString("7ZZZZZZZZZZZZZZZZZZZZZZZZZ")
46+
public val MIN: ULID = fromString("00000000000000000000000000")
3947

4048
public fun generate(timestamp: Long = Clock.System.now().toEpochMilliseconds()): ULID {
4149
check(timestamp >= 0L) { "Time must be non-negative" }
4250
check(timestamp <= MAX_TIME) { "Time must be less than $MAX_TIME" }
4351

52+
val buf = Buffer()
53+
// the timestamp we use is 48 bits, so we need to drop the uppermost 16 bits of a Long
54+
// toShort() uses the least significant 16 bits of a number
55+
buf.writeShort((timestamp shr 32).toShort())
56+
buf.writeInt(timestamp.toInt())
57+
4458
val randomness = CryptoRand.Default.nextBytes(ByteArray(RANDOM_BYTE_SIZE))
59+
buf.write(randomness)
60+
61+
return ULID(buf.readByteString())
62+
}
4563

46-
val combined = ByteArray(ULID_BYTE_SIZE)
64+
public fun fromString(encoded: String): ULID {
65+
require(encoded.length == ENCODED_ULID_BYTE_SIZE) { "Encoded ULID must be $ENCODED_ULID_BYTE_SIZE characters long" }
66+
val buf = Buffer()
67+
var decodedByte = 0
4768

48-
for (i in 0 until TIMESTAMP_BYTE_SIZE) {
49-
// toByte() uses the LSBs, so we need to shift the byte we're storing to the end to get it in correctly
50-
combined[i] = (timestamp shr (8 * (TIMESTAMP_BYTE_SIZE - i - 1))).toByte()
51-
}
69+
for (i in 0 until (encoded.length * 5) - ULID_ENCODING_FRONT_PADDING) {
70+
val charIdx = (i + ULID_ENCODING_FRONT_PADDING) / 5
71+
val char = encoded[charIdx]
72+
val decodedChar = ENCODING_CHARS.indexOf(char.uppercaseChar())
73+
require(decodedChar != -1) { "Invalid character in ULID: $char" }
5274

53-
randomness.copyInto(combined, destinationOffset = TIMESTAMP_BYTE_SIZE)
75+
val bitIdxOfChar = 4 - ((i + ULID_ENCODING_FRONT_PADDING) % 5)
76+
val bitMask = 0b1 shl bitIdxOfChar
77+
val bit = decodedChar and bitMask
5478

55-
return ULID(encodeCrockfordBase32(combined))
79+
val bitIdxInDecodedByte = 7 - (i % 8)
80+
81+
// this is where we have to move the masked bit to in order to get it in the right spot for the lookup
82+
// positive is to the right, negative is to the left
83+
val encodingShiftAmount = bitIdxOfChar - bitIdxInDecodedByte
84+
85+
val bitForDecodedByte =
86+
if (encodingShiftAmount >= 0) {
87+
(bit ushr encodingShiftAmount)
88+
} else {
89+
(bit shl encodingShiftAmount.absoluteValue)
90+
}
91+
92+
decodedByte = decodedByte or bitForDecodedByte
93+
94+
if (i % 8 == 7) {
95+
buf.writeByte(decodedByte.toByte())
96+
decodedByte = 0
97+
}
98+
}
99+
return ULID(buf.readByteString())
56100
}
57101

58-
// ULIDS are nice in that we are byte-aligned but also don't have to worry about padding (I think?)
59-
internal fun encodeCrockfordBase32(data: ByteArray) =
102+
internal fun encodeCrockfordBase32(bytes: ByteString) =
60103
buildString(ENCODED_ULID_BYTE_SIZE) {
104+
require(bytes.size == ULID_BYTE_SIZE) { "ULID should have $ULID_BYTE_SIZE bytes, but had ${bytes.size}" }
61105
var prevByteIdx = -1
62106
var encodingIdx = 0
63107
var currentByte = 0
64108

65109
// iterate over every bit
66-
for (i in 0 until data.size * 8) {
110+
for (i in 0 until bytes.size * 8) {
67111
val byteIdx = i / 8
68112
// determine the current byte we are working in
69113
if (prevByteIdx != byteIdx) {
70114
prevByteIdx = byteIdx
71-
currentByte = data[byteIdx].toInt()
115+
currentByte = bytes[byteIdx].toInt()
72116
}
73117

74118
// this is what bit we are looking at in the byte we are encoding
@@ -89,17 +133,17 @@ public value class ULID private constructor(
89133
// shift to the left by the absolute value instead
90134
val bitForEncodingIdx =
91135
if (encodingShiftAmount >= 0) {
92-
encodingIdx or (bit shr encodingShiftAmount)
136+
(bit ushr encodingShiftAmount)
93137
} else {
94-
encodingIdx or (bit shl encodingShiftAmount.absoluteValue)
138+
(bit shl encodingShiftAmount.absoluteValue)
95139
}
96140

97141
// mask-in the shifted bit
98142
encodingIdx = encodingIdx or bitForEncodingIdx
99143

100144
// if we reach the 5 bit mark (accounting for any front-padding), or are at the end, do the lookup and add the char to the string
101-
if ((i + ULID_ENCODING_FRONT_PADDING) % 5 == 4 || i == data.size * 8 - 1) {
102-
append(ENCODING_CHARS[encodingIdx % ENCODING_CHARS.size])
145+
if ((i + ULID_ENCODING_FRONT_PADDING) % 5 == 4 || i == bytes.size * 8 - 1) {
146+
append(ENCODING_CHARS[encodingIdx])
103147
encodingIdx = 0
104148
}
105149
}

kulid/src/commonTest/kotlin/dev/phillipslabs/kulid/TestULID.kt

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package dev.phillipslabs.kulid
22

33
import kotlinx.datetime.Clock
4+
import kotlinx.io.bytestring.ByteString
45
import kotlin.test.Test
56
import kotlin.test.assertEquals
67
import kotlin.test.assertFailsWith
@@ -10,33 +11,33 @@ class TestULID {
1011
@Test
1112
fun correctCrockfordEncoding() {
1213
// equivalent to max-time a ULID supports
13-
val bytes = ByteArray(6) { -1 }
14+
val bytes = ByteString(ByteArray(ULID_BYTE_SIZE) { -1 })
1415

1516
val encoded = ULID.encodeCrockfordBase32(bytes)
16-
assertEquals("7ZZZZZZZZZ", encoded)
17+
assertEquals("7ZZZZZZZZZZZZZZZZZZZZZZZZZ", encoded)
1718
}
1819

1920
@Test
2021
fun correctTimestampBitsForMaxULID() {
2122
val maxTimeULID = ULID.generate(MAX_TIME)
22-
assertEquals(ULID.MAX.value.take(10), maxTimeULID.value.take(10))
23-
assertTrue { maxTimeULID.value <= ULID.MAX.value }
23+
assertEquals(ULID.MAX.toString().take(10), maxTimeULID.toString().take(10))
24+
assertTrue { maxTimeULID < ULID.MAX }
2425
}
2526

2627
@Test
2728
fun correctTimestampBitsForMinULID() {
2829
val minTimeULID = ULID.generate(0L)
29-
assertEquals(ULID.MIN.value.take(10), minTimeULID.value.take(10))
30-
assertTrue { minTimeULID.value >= ULID.MIN.value }
30+
assertEquals(ULID.MIN.toString().take(10), minTimeULID.toString().take(10))
31+
assertTrue { minTimeULID >= ULID.MIN }
3132
}
3233

3334
@Test
3435
fun ulidForCurrentTimeValid() {
3536
val now = Clock.System.now().toEpochMilliseconds()
3637
val ulid = ULID.generate(now)
37-
assertTrue("ULID ${ulid.value} for timestamp $now outside of expected bounds!") {
38-
ulid.value <= ULID.MAX.value &&
39-
ulid.value >= ULID.MIN.value
38+
assertTrue("ULID $ulid for timestamp $now outside of expected bounds!") {
39+
ulid <= ULID.MAX &&
40+
ulid >= ULID.MIN
4041
}
4142
}
4243

@@ -53,4 +54,10 @@ class TestULID {
5354
ULID.generate(tooLargeTimestamp)
5455
}
5556
}
57+
58+
@Test
59+
fun testCorrectParsingOfULIDString() {
60+
val ulid = ULID.fromString("01EAWYQD59KTN275S079C9ESX7")
61+
assertEquals("01EAWYQD59KTN275S079C9ESX7", ulid.toString())
62+
}
5663
}

0 commit comments

Comments
 (0)