From 16da420d793496e248423fe06850d85a56814465 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 17:23:13 +0200 Subject: [PATCH] feat(function): add edge and pad to ScalarFunctions --- .../engine/function/ScalarFunctions.kt | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/src/main/kotlin/kestrel/engine/function/ScalarFunctions.kt b/src/main/kotlin/kestrel/engine/function/ScalarFunctions.kt index 665843f..5fc0a83 100644 --- a/src/main/kotlin/kestrel/engine/function/ScalarFunctions.kt +++ b/src/main/kotlin/kestrel/engine/function/ScalarFunctions.kt @@ -161,3 +161,91 @@ object ScalarFunctions { return Value.TextValue(if (start >= end) "" else source.substring(start, end)) } + /** `LEFT(str, n)`/`RIGHT(str, n)`: the first/last `n` characters, clamped to `[0, str.length]` (a non-positive `n` is empty, an `n` past the string's length is the whole string). */ + private fun edge(args: List, function: String, fromStart: Boolean): String { + val source = text(args[0], function) + val count = integer(args[1], function).coerceIn(0, source.length.toLong()).toInt() + return if (fromStart) source.substring(0, count) else source.substring(source.length - count) + } + + /** + * MySQL-compatible LPAD/RPAD: the result is always exactly [target length][Value.IntegerValue] + * characters (never longer) - a source already at or past that length is truncated to it (from + * the start, regardless of which side would have been padded), and a source shorter than it is + * padded by cycling [padString] (default a single space) until the target length is reached. + */ + private fun pad(args: List, function: String, left: Boolean): Value { + val source = text(args[0], function) + val targetLength = integer(args[1], function) + if (targetLength <= 0) return Value.TextValue("") + val length = targetLength.toInt() + if (length <= source.length) return Value.TextValue(source.substring(0, length)) + val padString = if (args.size == 3) text(args[2], function) else " " + if (padString.isEmpty()) throw KestrelException("function $function requires a non-empty pad string") + val needed = length - source.length + val padding = buildString { + while (this.length < needed) append(padString) + }.substring(0, needed) + return Value.TextValue(if (left) padding + source else source + padding) + } + + /** Two INTEGER operands with a non-negative exponent stay exact (repeated Long multiplication); anything else - a negative exponent or a REAL operand on either side - falls back to `Math.pow`. */ + private fun power(base: Value, exponent: Value): Value { + if (base is Value.IntegerValue && exponent is Value.IntegerValue && exponent.value >= 0) { + var result = 1L + repeat(exponent.value.toInt()) { result *= base.value } + return Value.IntegerValue(result) + } + return Value.RealValue(number(base, "POWER").pow(number(exponent, "POWER"))) + } + + /** Two INTEGER operands use exact `Long` remainder; a REAL operand on either side uses floating-point remainder instead. */ + private fun mod(dividend: Value, divisor: Value): Value { + if (dividend is Value.IntegerValue && divisor is Value.IntegerValue) { + if (divisor.value == 0L) throw KestrelException("division by zero") + return Value.IntegerValue(dividend.value % divisor.value) + } + val divisorNumber = number(divisor, "MOD") + if (divisorNumber == 0.0) throw KestrelException("division by zero") + return Value.RealValue(number(dividend, "MOD") % divisorNumber) + } + + private fun round(args: List): Value { + val digits = if (args.size == 2) { + (args[1] as? Value.IntegerValue)?.value?.toInt() + ?: throw KestrelException("function ROUND requires an INTEGER digit count") + } else { + 0 + } + if (args[0] is Value.IntegerValue && digits >= 0) return args[0] + val decimal = args[0] as? Value.DecimalValue + if (decimal != null) return Value.DecimalValue(decimal.value.setScale(digits, RoundingMode.HALF_UP)) + val value = number(args[0], "ROUND") + val factor = 10.0.pow(digits) + return Value.RealValue((value * factor).roundToLong() / factor) + } + + private fun sign(value: Value): Int = when (value) { + is Value.IntegerValue -> if (value.value > 0) 1 else if (value.value < 0) -1 else 0 + is Value.RealValue -> if (value.value > 0) 1 else if (value.value < 0) -1 else 0 + is Value.DecimalValue -> value.value.signum() + else -> throw KestrelException("function SIGN requires a numeric argument but got ${value.typeName}") + } + + /** Same digit-count/type handling as [round], but truncates toward zero (`RoundingMode.DOWN`) instead of rounding half-up. */ + private fun trunc(args: List): Value { + val digits = if (args.size == 2) { + (args[1] as? Value.IntegerValue)?.value?.toInt() + ?: throw KestrelException("function TRUNC requires an INTEGER digit count") + } else { + 0 + } + if (args[0] is Value.IntegerValue && digits >= 0) return args[0] + val decimal = args[0] as? Value.DecimalValue + if (decimal != null) return Value.DecimalValue(decimal.value.setScale(digits, RoundingMode.DOWN)) + val value = number(args[0], "TRUNC") + val factor = 10.0.pow(digits) + val scaled = value * factor + return Value.RealValue((if (scaled >= 0) floor(scaled) else ceil(scaled)) / factor) + } +}