diff --git a/src/main/kotlin/kestrel/store/codec/KeyCodec.kt b/src/main/kotlin/kestrel/store/codec/KeyCodec.kt new file mode 100644 index 0000000..89f81d3 --- /dev/null +++ b/src/main/kotlin/kestrel/store/codec/KeyCodec.kt @@ -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): 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 { + val reader = ByteReader(bytes) + val result = ArrayList() + 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) + } +}