feat(engine): extend Evaluator with evaluateBetween and evaluateLike

This commit is contained in:
dualfroz committed 2026-09-01 17:32:14 +02:00
1 parent b165621a2a
commit 644c83f436
1 file changed
+156 -5
+156 -5
View File
@@ -285,6 +285,62 @@ class Evaluator(
sawNull = true
continue
}
if (compareOperands(operand, candidate, collation) == 0) return Value.BooleanValue(!expression.negated)
}
return if (sawNull) Value.NullValue else Value.BooleanValue(expression.negated)
}
private fun evaluateBetween(expression: BetweenExpression): Value {
val operand = evaluate(expression.operand)
val low = evaluate(expression.low)
val high = evaluate(expression.high)
if (operand is Value.NullValue || low is Value.NullValue || high is Value.NullValue) return Value.NullValue
val withinRange = compareOperands(operand, low, pairCollation(expression.operand, expression.low)) >= 0 &&
compareOperands(operand, high, pairCollation(expression.operand, expression.high)) <= 0
return Value.BooleanValue(if (expression.negated) !withinRange else withinRange)
}
private fun evaluateLike(expression: LikeExpression): Value {
val operand = evaluate(expression.operand)
val pattern = evaluate(expression.pattern)
if (operand is Value.NullValue || pattern is Value.NullValue) return Value.NullValue
if (operand !is Value.TextValue || pattern !is Value.TextValue) {
throw KestrelException("LIKE requires text operands but got ${operand.typeName} and ${pattern.typeName}")
}
val matches = likeToRegex(pattern.value).matches(operand.value)
return Value.BooleanValue(if (expression.negated) !matches else matches)
}
private fun likeToRegex(pattern: String): Regex {
val builder = StringBuilder("(?s)")
for (character in pattern) {
when (character) {
'%' -> builder.append(".*")
'_' -> builder.append(".")
else -> builder.append(Regex.escape(character.toString()))
}
}
return Regex(builder.toString())
}
private fun evaluateCase(expression: CaseExpression): Value {
val operand = expression.operand?.let { evaluate(it) }
for (branch in expression.branches) {
val matched = if (operand != null) {
if (operand is Value.NullValue) {
false
} else {
val candidate = evaluate(branch.condition)
candidate !is Value.NullValue &&
compareOperands(operand, candidate, pairCollation(expression.operand!!, branch.condition)) == 0
}
} else {
asBooleanOrNull(evaluate(branch.condition)) == true
}
if (matched) return evaluate(branch.result)
}
return expression.elseExpression?.let { evaluate(it) } ?: Value.NullValue
}
private fun evaluateColumn(expression: ColumnExpression): Value {
val active = resolver ?: throw KestrelException("column '${expression.name}' is not allowed here")
@@ -298,6 +354,7 @@ class Evaluator(
UnaryOperator.Negate -> when (operand) {
is Value.IntegerValue -> Value.IntegerValue(-operand.value)
is Value.RealValue -> Value.RealValue(-operand.value)
is Value.DecimalValue -> Value.DecimalValue(-operand.value)
else -> throw KestrelException("unary minus requires a numeric operand but got ${operand.typeName}")
}
UnaryOperator.Not -> when (operand) {
@@ -317,7 +374,7 @@ class Evaluator(
return when (operator) {
BinaryOperator.Add, BinaryOperator.Subtract,
BinaryOperator.Multiply, BinaryOperator.Divide -> evaluateArithmetic(operator, left, right)
else -> evaluateComparison(operator, left, right)
else -> evaluateComparison(operator, left, right, pairCollation(expression.left, expression.right))
}
}
@@ -347,6 +404,12 @@ class Evaluator(
private fun evaluateArithmetic(operator: BinaryOperator, left: Value, right: Value): Value {
if (left is Value.NullValue || right is Value.NullValue) return Value.NullValue
if (left is Value.DateValue || right is Value.DateValue) {
return evaluateDateArithmetic(operator, left, right)
}
if (left is Value.DecimalValue || right is Value.DecimalValue) {
return evaluateDecimalArithmetic(operator, left, right)
}
if (!isNumeric(left) || !isNumeric(right)) {
throw KestrelException("arithmetic requires numeric operands but got ${left.typeName} and ${right.typeName}")
}
@@ -382,9 +445,9 @@ class Evaluator(
)
}
private fun evaluateComparison(operator: BinaryOperator, left: Value, right: Value): Value {
private fun evaluateComparison(operator: BinaryOperator, left: Value, right: Value, collation: Collation): Value {
if (left is Value.NullValue || right is Value.NullValue) return Value.NullValue
val ordering = compareOperands(left, right)
val ordering = compareOperands(left, right, collation)
val result = when (operator) {
BinaryOperator.Equal -> ordering == 0
BinaryOperator.NotEqual -> ordering != 0
@@ -397,14 +460,59 @@ class Evaluator(
return Value.BooleanValue(result)
}
private fun compareOperands(left: Value, right: Value): Int = when {
/**
* [collation] only ever changes the outcome for two [Value.TextValue]s - every other pairing
* ignores it, matching SQLite (COLLATE is meaningless outside of text comparison). Defaults to
* [Collation.Binary] (this engine's behavior before COLLATE existed) at every call site that
* has no [CollateExpression] to resolve one from.
*/
private fun compareOperands(left: Value, right: Value, collation: Collation = Collation.Binary): Int = when {
left is Value.IntegerValue && right is Value.IntegerValue -> left.value.compareTo(right.value)
left is Value.DecimalValue || right is Value.DecimalValue -> {
val a = decimalOf(left) ?: throw KestrelException("cannot compare ${left.typeName} with ${right.typeName}")
val b = decimalOf(right) ?: throw KestrelException("cannot compare ${left.typeName} with ${right.typeName}")
a.compareTo(b)
}
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 -> throw KestrelException("cannot compare ${left.typeName} with ${right.typeName}")
}
/** The [Collation] an explicit `COLLATE` on [expression] itself names, or `null` when it is not a [CollateExpression] at all. */
private fun explicitCollationOf(expression: Expression): Collation? = (expression as? CollateExpression)?.collation
/**
* [expression]'s own declared `COLLATE` (see [kestrel.catalog.Column.collation]) when it is a
* bare [ColumnExpression] whose [resolver] reports one - `null` for anything else, including a
* column that never declared one at all ([ColumnResolver.declaredCollation] reporting
* [Collation.Binary], its class default, is treated the exact same as reporting nothing: a
* column that never opted into a non-default sequence should not out-rank an operand on the
* *other* side of a comparison that did, purely by virtue of appearing on the left - see
* [pairCollation]).
*/
private fun columnDeclaredCollationOf(expression: Expression): Collation? =
(expression as? ColumnExpression)?.let { resolver?.declaredCollation(it) }?.takeIf { it != Collation.Binary }
/**
* The [Collation] [expression] compares with on its own, combining both of [pairCollation]'s
* tiers into one - used where there is only a single operand to consult
* ([evaluateIn]/[evaluateInSubquery]'s list/subquery operand), which can still tell "no COLLATE
* here at all" apart from "explicitly COLLATE BINARY" (both [explicitCollationOf] and
* [columnDeclaredCollationOf] return `null` only for the former).
*/
private fun collationOf(expression: Expression): Collation? = explicitCollationOf(expression) ?: columnDeclaredCollationOf(expression)
/**
* Delegates to the shared [kestrel.engine.pairCollation] (SQLite's two-tier binary-comparison
* rule) bound to this evaluator's own [resolver] - see that function's doc for the precedence
* rule itself and why it must stay two separate passes rather than one combined
* [collationOf]-per-side check.
*/
private fun pairCollation(left: Expression, right: Expression): Collation =
kestrel.engine.pairCollation(left, right) { resolver?.declaredCollation(it) }
private fun isNumeric(value: Value): Boolean = value is Value.IntegerValue || value is Value.RealValue
private fun asDouble(value: Value): Double = when (value) {
@@ -412,4 +520,47 @@ class Evaluator(
is Value.RealValue -> value.value
else -> error("not a numeric value")
}
private fun decimalOf(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 evaluateDateArithmetic(operator: BinaryOperator, left: Value, right: Value): Value = when {
left is Value.DateValue && right is Value.DateValue -> when (operator) {
BinaryOperator.Subtract -> Value.IntegerValue(left.value.toEpochDay() - right.value.toEpochDay())
else -> throw KestrelException("DATE ${operator.symbol} DATE is not supported; only subtraction (day difference) is")
}
left is Value.DateValue && right is Value.IntegerValue -> when (operator) {
BinaryOperator.Add -> Value.DateValue(left.value.plusDays(right.value))
BinaryOperator.Subtract -> Value.DateValue(left.value.minusDays(right.value))
else -> throw KestrelException("DATE only supports + and - with an INTEGER day offset")
}
left is Value.IntegerValue && right is Value.DateValue && operator == BinaryOperator.Add ->
Value.DateValue(right.value.plusDays(left.value))
else -> throw KestrelException("arithmetic requires numeric operands but got ${left.typeName} and ${right.typeName}")
}
private fun evaluateDecimalArithmetic(operator: BinaryOperator, left: Value, right: Value): Value {
val a = decimalOf(left) ?: throw KestrelException("arithmetic requires numeric operands but got ${left.typeName} and ${right.typeName}")
val b = decimalOf(right) ?: throw KestrelException("arithmetic requires numeric operands but got ${left.typeName} and ${right.typeName}")
return Value.DecimalValue(
when (operator) {
BinaryOperator.Add -> a + b
BinaryOperator.Subtract -> a - b
BinaryOperator.Multiply -> a * b
BinaryOperator.Divide -> {
if (b.signum() == 0) throw KestrelException("division by zero")
a.divide(b, maxOf(a.scale(), b.scale(), MIN_DIVISION_SCALE), RoundingMode.HALF_UP)
}
else -> error("unreachable arithmetic operator")
}
)
}
private companion object {
const val MIN_DIVISION_SCALE = 10
}
}