diff --git a/src/main/kotlin/kestrel/storage/Collation.kt b/src/main/kotlin/kestrel/storage/Collation.kt new file mode 100644 index 0000000..f3f145c --- /dev/null +++ b/src/main/kotlin/kestrel/storage/Collation.kt @@ -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) } + } +} diff --git a/src/main/kotlin/kestrel/storage/DecimalColumns.kt b/src/main/kotlin/kestrel/storage/DecimalColumns.kt new file mode 100644 index 0000000..7e995ce --- /dev/null +++ b/src/main/kotlin/kestrel/storage/DecimalColumns.kt @@ -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 + } +} diff --git a/src/main/kotlin/kestrel/storage/Value.kt b/src/main/kotlin/kestrel/storage/Value.kt index da9aeb2..2799591 100644 --- a/src/main/kotlin/kestrel/storage/Value.kt +++ b/src/main/kotlin/kestrel/storage/Value.kt @@ -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 } diff --git a/src/main/kotlin/kestrel/storage/ValueOrdering.kt b/src/main/kotlin/kestrel/storage/ValueOrdering.kt index f7fb461..5b4bf63 100644 --- a/src/main/kotlin/kestrel/storage/ValueOrdering.kt +++ b/src/main/kotlin/kestrel/storage/ValueOrdering.kt @@ -1,14 +1,26 @@ package kestrel.storage +import java.math.BigDecimal + object ValueOrdering : Comparator { - 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 { 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 } }