feat(storage): extend Collation with Collation and compare

This commit is contained in:
dualfroz committed 2026-09-04 16:52:39 +02:00
1 parent 6303c8d538
commit 6d68777852
4 files changed
+152 -4

No files matched your search

@@ -0,0 +1,54 @@
package kestrel.storage
/**
* A text comparison sequence, selected by the `COLLATE` operator (`expr COLLATE name`) - see
* [kestrel.sql.ast.CollateExpression]. Only the three built-in sequences SQLite ships are
* supported (no `CREATE COLLATION`, no user-defined comparator); [Binary] is what every text
* comparison already used before COLLATE existed, so it stays the default everywhere a caller
* does not thread a collation through at all.
*/
enum class Collation(val sqlName: String) {
/** Plain UTF-16 code-unit order - [String.compareTo] - identical to this engine's pre-COLLATE behavior. */
Binary("BINARY"),
/**
* Case-insensitive, but only for ASCII `A-Z`/`a-z` - matching SQLite's own NOCASE exactly
* (not full Unicode case folding, which would depend on locale for a handful of characters
* like Turkish "I"). Folds both operands to uppercase ASCII before comparing.
*/
NoCase("NOCASE"),
/**
* Ignores trailing ASCII space characters, matching SQLite's built-in RTRIM collation.
* Leading and interior whitespace still participates in the comparison.
*/
Rtrim("RTRIM");
fun compare(left: String, right: String): Int = canonicalize(left).compareTo(canonicalize(right))
/**
* [value] rewritten so that plain [String.equals]/[String.hashCode]/[Comparable.compareTo] on
* the result matches this collation's own [compare] - shared by [compare] itself and by every
* equality-based dedup (`GROUP BY`, `DISTINCT`, an aggregate's own `DISTINCT`) that needs a
* hashable key rather than a pairwise comparison. [Binary] returns [value] unchanged, so folding
* a value under it and using the result as a key is indistinguishable from the pre-COLLATE
* behavior of comparing/hashing the value directly.
*/
fun canonicalize(value: String): String = when (this) {
Binary -> value
NoCase -> foldAsciiCase(value)
Rtrim -> value.trimEnd(' ')
}
private fun foldAsciiCase(value: String): String {
val builder = StringBuilder(value.length)
for (character in value) {
builder.append(if (character in 'a'..'z') character.uppercaseChar() else character)
}
return builder.toString()
}
companion object {
fun fromName(name: String): Collation? = entries.find { it.sqlName.equals(name, ignoreCase = true) }
}
}
@@ -0,0 +1,28 @@
package kestrel.storage
import java.math.BigDecimal
import java.math.RoundingMode
import kestrel.KestrelException
/**
* Shared DECIMAL(precision, scale) rules used both when a value is coerced into a
* DECIMAL column (INSERT/UPDATE) and when it is produced by an explicit CAST. Kept as
* a single place so both call sites agree on rounding mode and overflow behavior.
*/
object DecimalColumns {
const val DEFAULT_PRECISION = 18
const val DEFAULT_SCALE = 2
fun normalize(raw: BigDecimal, precision: Int?, scale: Int?, context: String): BigDecimal {
val effectiveScale = scale ?: DEFAULT_SCALE
val effectivePrecision = precision ?: DEFAULT_PRECISION
val rounded = raw.setScale(effectiveScale, RoundingMode.HALF_UP)
val digits = rounded.unscaledValue().abs().toString().length
if (digits > effectivePrecision) {
throw KestrelException(
"$context: $rounded has more than $effectivePrecision digits for DECIMAL($effectivePrecision, $effectiveScale)"
)
}
return rounded
}
}
+39 -1
View File
@@ -1,7 +1,10 @@
package kestrel.storage
import java.math.BigDecimal
import java.time.LocalDate
enum class ColumnType {
Integer, Text, Boolean, Real;
Integer, Text, Boolean, Real, Decimal, Date;
val displayName: String
get() = when (this) {
@@ -9,6 +12,8 @@ enum class ColumnType {
Text -> "TEXT"
Boolean -> "BOOLEAN"
Real -> "REAL"
Decimal -> "DECIMAL"
Date -> "DATE"
}
companion object {
@@ -17,11 +22,20 @@ enum class ColumnType {
"TEXT" -> Text
"BOOLEAN" -> Boolean
"REAL" -> Real
"DECIMAL" -> Decimal
"DATE" -> Date
else -> null
}
}
}
/** Renders a type with its DECIMAL(precision[, scale]) parameters, if any were declared. */
fun ColumnType.displayWith(precision: Int?, scale: Int?): String = when {
this != ColumnType.Decimal || precision == null -> displayName
scale == null -> "$displayName($precision)"
else -> "$displayName($precision, $scale)"
}
sealed class Value {
abstract val type: ColumnType?
@@ -41,6 +55,30 @@ sealed class Value {
override val type get() = ColumnType.Boolean
}
/**
* Arbitrary-precision fixed-point number. Equality and hashing compare by numeric
* value (via [BigDecimal.compareTo]) rather than by representation, so `1.5` and
* `1.50` are the same value for GROUP BY/DISTINCT/UNION dedup purposes, matching
* standard SQL DECIMAL semantics instead of Java's stricter [BigDecimal.equals].
*/
class DecimalValue(val value: BigDecimal) : Value() {
override val type get() = ColumnType.Decimal
override fun equals(other: Any?): Boolean =
other is DecimalValue && value.compareTo(other.value) == 0
override fun hashCode(): Int {
val canonical = if (value.signum() == 0) BigDecimal.ZERO else value.stripTrailingZeros()
return canonical.hashCode()
}
override fun toString(): String = "DecimalValue(value=$value)"
}
data class DateValue(val value: LocalDate) : Value() {
override val type get() = ColumnType.Date
}
object NullValue : Value() {
override val type: ColumnType? get() = null
}
@@ -1,14 +1,26 @@
package kestrel.storage
import java.math.BigDecimal
object ValueOrdering : Comparator<Value> {
override fun compare(left: Value, right: Value): Int = when {
override fun compare(left: Value, right: Value): Int = compare(left, right, Collation.Binary)
/**
* Same ordering as the plain two-argument [compare], except two [Value.TextValue]s are
* compared through [collation] instead of always using [String.compareTo] - see
* [kestrel.engine.compareOrderKeyValues], the only caller that ever passes a non-[Collation.Binary]
* value (an explicit `ORDER BY ... COLLATE name`).
*/
fun compare(left: Value, right: Value, collation: Collation): Int = when {
left is Value.NullValue && right is Value.NullValue -> 0
left is Value.NullValue -> -1
right is Value.NullValue -> 1
left is Value.IntegerValue && right is Value.IntegerValue -> left.value.compareTo(right.value)
left is Value.DecimalValue || right is Value.DecimalValue -> compareAsDecimal(left, right)
isNumeric(left) && isNumeric(right) -> asDouble(left).compareTo(asDouble(right))
left is Value.TextValue && right is Value.TextValue -> left.value.compareTo(right.value)
left is Value.TextValue && right is Value.TextValue -> collation.compare(left.value, right.value)
left is Value.BooleanValue && right is Value.BooleanValue -> left.value.compareTo(right.value)
left is Value.DateValue && right is Value.DateValue -> left.value.compareTo(right.value)
else -> rank(left).compareTo(rank(right))
}
@@ -20,11 +32,27 @@ object ValueOrdering : Comparator<Value> {
else -> 0.0
}
/** Falls back to [rank] ordering (never throws) when one side isn't itself numeric. */
private fun compareAsDecimal(left: Value, right: Value): Int {
val a = asBigDecimalOrNull(left) ?: return rank(left).compareTo(rank(right))
val b = asBigDecimalOrNull(right) ?: return rank(left).compareTo(rank(right))
return a.compareTo(b)
}
private fun asBigDecimalOrNull(value: Value): BigDecimal? = when (value) {
is Value.DecimalValue -> value.value
is Value.IntegerValue -> BigDecimal(value.value)
is Value.RealValue -> BigDecimal.valueOf(value.value)
else -> null
}
private fun rank(value: Value): Int = when (value) {
is Value.NullValue -> 0
is Value.BooleanValue -> 1
is Value.IntegerValue -> 2
is Value.RealValue -> 2
is Value.TextValue -> 3
is Value.DecimalValue -> 2
is Value.DateValue -> 3
is Value.TextValue -> 4
}
}