feat(storage): extend Collation with Collation and compare
This commit is contained in:
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
package kestrel.storage
|
package kestrel.storage
|
||||||
|
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
enum class ColumnType {
|
enum class ColumnType {
|
||||||
Integer, Text, Boolean, Real;
|
Integer, Text, Boolean, Real, Decimal, Date;
|
||||||
|
|
||||||
val displayName: String
|
val displayName: String
|
||||||
get() = when (this) {
|
get() = when (this) {
|
||||||
@@ -9,6 +12,8 @@ enum class ColumnType {
|
|||||||
Text -> "TEXT"
|
Text -> "TEXT"
|
||||||
Boolean -> "BOOLEAN"
|
Boolean -> "BOOLEAN"
|
||||||
Real -> "REAL"
|
Real -> "REAL"
|
||||||
|
Decimal -> "DECIMAL"
|
||||||
|
Date -> "DATE"
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -17,11 +22,20 @@ enum class ColumnType {
|
|||||||
"TEXT" -> Text
|
"TEXT" -> Text
|
||||||
"BOOLEAN" -> Boolean
|
"BOOLEAN" -> Boolean
|
||||||
"REAL" -> Real
|
"REAL" -> Real
|
||||||
|
"DECIMAL" -> Decimal
|
||||||
|
"DATE" -> Date
|
||||||
else -> null
|
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 {
|
sealed class Value {
|
||||||
abstract val type: ColumnType?
|
abstract val type: ColumnType?
|
||||||
|
|
||||||
@@ -41,6 +55,30 @@ sealed class Value {
|
|||||||
override val type get() = ColumnType.Boolean
|
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() {
|
object NullValue : Value() {
|
||||||
override val type: ColumnType? get() = null
|
override val type: ColumnType? get() = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
package kestrel.storage
|
package kestrel.storage
|
||||||
|
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
object ValueOrdering : Comparator<Value> {
|
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 && right is Value.NullValue -> 0
|
||||||
left is Value.NullValue -> -1
|
left is Value.NullValue -> -1
|
||||||
right 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.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))
|
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.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))
|
else -> rank(left).compareTo(rank(right))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,11 +32,27 @@ object ValueOrdering : Comparator<Value> {
|
|||||||
else -> 0.0
|
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) {
|
private fun rank(value: Value): Int = when (value) {
|
||||||
is Value.NullValue -> 0
|
is Value.NullValue -> 0
|
||||||
is Value.BooleanValue -> 1
|
is Value.BooleanValue -> 1
|
||||||
is Value.IntegerValue -> 2
|
is Value.IntegerValue -> 2
|
||||||
is Value.RealValue -> 2
|
is Value.RealValue -> 2
|
||||||
is Value.TextValue -> 3
|
is Value.DecimalValue -> 2
|
||||||
|
is Value.DateValue -> 3
|
||||||
|
is Value.TextValue -> 4
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user