feat(codec): add KeyCodec and encodeKey
This commit is contained in:
1 parent
fccb54cd70
commit
38be0b0cf8
1 file changed
+214
@@ -0,0 +1,214 @@
|
||||
package kestrel.store.codec
|
||||
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import java.time.LocalDate
|
||||
import kestrel.storage.Value
|
||||
|
||||
/**
|
||||
* Order-preserving byte encoding for index keys: unsigned lexicographic
|
||||
* comparison of the encoded bytes agrees with SQL value ordering, so a
|
||||
* B+Tree can use plain `ByteArray` comparison instead of decoding values.
|
||||
*
|
||||
* Numeric values sort by type tag first, then by magnitude: same-type
|
||||
* comparisons (INTEGER-INTEGER, REAL-REAL, DECIMAL-DECIMAL) are exact, but a
|
||||
* mixed INTEGER/REAL/DECIMAL comparison is ordered by tag rather than by
|
||||
* numeric value. Index columns are schema-typed in practice, so this does not
|
||||
* affect single-column indexes; it is a deliberate scope cut versus the full
|
||||
* cross-type semantics of [kestrel.storage.ValueOrdering].
|
||||
*
|
||||
* DECIMAL uses a normalized "scientific notation" encoding: a sign byte, an
|
||||
* offset-binary exponent (so larger magnitude sorts later), then the
|
||||
* significant digits (trailing zeros stripped, one mapped byte per digit,
|
||||
* terminated by a byte no digit can produce). Two decimals compare equal
|
||||
* under this scheme exactly when [BigDecimal.compareTo] says they are equal
|
||||
* (`1.5` and `1.50` included), and it holds arbitrary precision - unlike
|
||||
* REAL's key encoding this never rounds through a `Double`. Negative values
|
||||
* bit-complement the whole magnitude encoding, which reverses byte-order
|
||||
* comparison so a more-negative number still sorts first.
|
||||
*/
|
||||
object KeyCodec {
|
||||
private const val TAG_NULL = 0
|
||||
private const val TAG_BOOLEAN = 1
|
||||
private const val TAG_INTEGER = 2
|
||||
private const val TAG_REAL = 3
|
||||
private const val TAG_TEXT = 4
|
||||
private const val TAG_DECIMAL = 5
|
||||
private const val TAG_DATE = 6
|
||||
|
||||
private const val TEXT_ESCAPE_NUL = 0xFF
|
||||
private const val TEXT_TERMINATOR_HIGH = 0x00
|
||||
private const val TEXT_TERMINATOR_LOW = 0x00
|
||||
|
||||
private const val DECIMAL_SIGN_NEGATIVE = 0
|
||||
private const val DECIMAL_SIGN_ZERO = 1
|
||||
private const val DECIMAL_SIGN_POSITIVE = 2
|
||||
private const val DECIMAL_DIGIT_TERMINATOR = 0
|
||||
|
||||
fun encodeKey(values: List<Value>): ByteArray {
|
||||
val writer = ByteWriter()
|
||||
for (value in values) encodeInto(writer, value)
|
||||
return writer.toByteArray()
|
||||
}
|
||||
|
||||
fun encode(value: Value): ByteArray {
|
||||
val writer = ByteWriter()
|
||||
encodeInto(writer, value)
|
||||
return writer.toByteArray()
|
||||
}
|
||||
|
||||
fun decodeKey(bytes: ByteArray): List<Value> {
|
||||
val reader = ByteReader(bytes)
|
||||
val result = ArrayList<Value>()
|
||||
while (reader.hasRemaining()) {
|
||||
result.add(decodeOne(reader))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Unsigned lexicographic comparison, matching how the bytes are compared on disk. */
|
||||
fun compare(a: ByteArray, b: ByteArray): Int {
|
||||
val n = minOf(a.size, b.size)
|
||||
for (i in 0 until n) {
|
||||
val diff = (a[i].toInt() and 0xFF) - (b[i].toInt() and 0xFF)
|
||||
if (diff != 0) return diff
|
||||
}
|
||||
return a.size - b.size
|
||||
}
|
||||
|
||||
private fun encodeInto(writer: ByteWriter, value: Value) {
|
||||
when (value) {
|
||||
Value.NullValue -> writer.writeByte(TAG_NULL)
|
||||
is Value.BooleanValue -> {
|
||||
writer.writeByte(TAG_BOOLEAN)
|
||||
writer.writeByte(if (value.value) 1 else 0)
|
||||
}
|
||||
is Value.IntegerValue -> {
|
||||
writer.writeByte(TAG_INTEGER)
|
||||
writer.writeFixedLong(value.value xor Long.MIN_VALUE)
|
||||
}
|
||||
is Value.RealValue -> {
|
||||
writer.writeByte(TAG_REAL)
|
||||
writer.writeFixedLong(encodeOrderedDouble(value.value))
|
||||
}
|
||||
is Value.TextValue -> {
|
||||
writer.writeByte(TAG_TEXT)
|
||||
writeEscapedText(writer, value.value)
|
||||
}
|
||||
is Value.DecimalValue -> {
|
||||
writer.writeByte(TAG_DECIMAL)
|
||||
writeDecimalMagnitudeSign(writer, value.value)
|
||||
}
|
||||
is Value.DateValue -> {
|
||||
writer.writeByte(TAG_DATE)
|
||||
writer.writeFixedLong(value.value.toEpochDay() xor Long.MIN_VALUE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeOne(reader: ByteReader): Value = when (val tag = reader.readByte()) {
|
||||
TAG_NULL -> Value.NullValue
|
||||
TAG_BOOLEAN -> Value.BooleanValue(reader.readByte() != 0)
|
||||
TAG_INTEGER -> Value.IntegerValue(reader.readFixedLong() xor Long.MIN_VALUE)
|
||||
TAG_REAL -> Value.RealValue(decodeOrderedDouble(reader.readFixedLong()))
|
||||
TAG_TEXT -> Value.TextValue(readEscapedText(reader))
|
||||
TAG_DECIMAL -> Value.DecimalValue(readDecimalMagnitudeSign(reader))
|
||||
TAG_DATE -> Value.DateValue(LocalDate.ofEpochDay(reader.readFixedLong() xor Long.MIN_VALUE))
|
||||
else -> throw CodecException("unknown key tag $tag")
|
||||
}
|
||||
|
||||
private fun writeDecimalMagnitudeSign(writer: ByteWriter, decimal: BigDecimal) {
|
||||
when {
|
||||
decimal.signum() == 0 -> writer.writeByte(DECIMAL_SIGN_ZERO)
|
||||
decimal.signum() > 0 -> {
|
||||
writer.writeByte(DECIMAL_SIGN_POSITIVE)
|
||||
writeDecimalMagnitude(writer, decimal, invert = false)
|
||||
}
|
||||
else -> {
|
||||
writer.writeByte(DECIMAL_SIGN_NEGATIVE)
|
||||
writeDecimalMagnitude(writer, decimal.negate(), invert = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeDecimalMagnitude(writer: ByteWriter, magnitude: BigDecimal, invert: Boolean) {
|
||||
val canonical = magnitude.stripTrailingZeros()
|
||||
val digits = canonical.unscaledValue().toString()
|
||||
val exponent = (digits.length - canonical.scale()).toLong()
|
||||
val body = ByteWriter()
|
||||
body.writeFixedLong(exponent xor Long.MIN_VALUE)
|
||||
for (digit in digits) body.writeByte((digit - '0') + 1)
|
||||
body.writeByte(DECIMAL_DIGIT_TERMINATOR)
|
||||
val bytes = body.toByteArray()
|
||||
writer.writeBytes(if (invert) ByteArray(bytes.size) { (bytes[it].toInt() xor 0xFF).toByte() } else bytes)
|
||||
}
|
||||
|
||||
private fun readDecimalMagnitudeSign(reader: ByteReader): BigDecimal = when (val sign = reader.readByte()) {
|
||||
DECIMAL_SIGN_ZERO -> BigDecimal.ZERO
|
||||
DECIMAL_SIGN_POSITIVE -> readDecimalMagnitude(reader, invert = false)
|
||||
DECIMAL_SIGN_NEGATIVE -> readDecimalMagnitude(reader, invert = true).negate()
|
||||
else -> throw CodecException("unknown decimal sign tag $sign")
|
||||
}
|
||||
|
||||
private fun readDecimalMagnitude(reader: ByteReader, invert: Boolean): BigDecimal {
|
||||
fun nextByte(): Int {
|
||||
val raw = reader.readByte()
|
||||
return if (invert) raw xor 0xFF else raw
|
||||
}
|
||||
var exponentBits = 0L
|
||||
repeat(8) { exponentBits = (exponentBits shl 8) or (nextByte().toLong() and 0xFF) }
|
||||
val exponent = exponentBits xor Long.MIN_VALUE
|
||||
val digits = StringBuilder()
|
||||
while (true) {
|
||||
val next = nextByte()
|
||||
if (next == DECIMAL_DIGIT_TERMINATOR) break
|
||||
digits.append('0' + (next - 1))
|
||||
}
|
||||
if (digits.isEmpty()) return BigDecimal.ZERO
|
||||
val unscaled = BigInteger(digits.toString())
|
||||
val scale = (digits.length - exponent).toInt()
|
||||
return BigDecimal(unscaled, scale)
|
||||
}
|
||||
|
||||
private fun encodeOrderedDouble(value: Double): Long {
|
||||
val bits = java.lang.Double.doubleToLongBits(value)
|
||||
return if (bits < 0) bits.inv() else bits xor Long.MIN_VALUE
|
||||
}
|
||||
|
||||
private fun decodeOrderedDouble(transformed: Long): Double {
|
||||
val bits = if (transformed < 0) transformed xor Long.MIN_VALUE else transformed.inv()
|
||||
return java.lang.Double.longBitsToDouble(bits)
|
||||
}
|
||||
|
||||
private fun writeEscapedText(writer: ByteWriter, text: String) {
|
||||
for (byte in text.toByteArray(Charsets.UTF_8)) {
|
||||
val unsigned = byte.toInt() and 0xFF
|
||||
if (unsigned == 0x00) {
|
||||
writer.writeByte(0x00)
|
||||
writer.writeByte(TEXT_ESCAPE_NUL)
|
||||
} else {
|
||||
writer.writeByte(unsigned)
|
||||
}
|
||||
}
|
||||
writer.writeByte(TEXT_TERMINATOR_HIGH)
|
||||
writer.writeByte(TEXT_TERMINATOR_LOW)
|
||||
}
|
||||
|
||||
private fun readEscapedText(reader: ByteReader): String {
|
||||
val out = ByteWriter()
|
||||
while (true) {
|
||||
val byte = reader.readByte()
|
||||
if (byte != 0x00) {
|
||||
out.writeByte(byte)
|
||||
continue
|
||||
}
|
||||
val next = reader.readByte()
|
||||
if (next == TEXT_ESCAPE_NUL) {
|
||||
out.writeByte(0x00)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return String(out.toByteArray(), Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user