diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Function.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Function.kt index 1898e58f38..73a05fda38 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Function.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Function.kt @@ -67,6 +67,15 @@ class Random( // String Functions +/** + * Represents an SQL function that returns the length of [expr], measured in characters, or `null` if [expr] is null. + */ +class CharLength( + val expr: Expression +) : Function(IntegerColumnType()) { + override fun toQueryBuilder(queryBuilder: QueryBuilder): Unit = currentDialect.functionProvider.charLength(expr, queryBuilder) +} + /** * Represents an SQL function that converts [expr] to lower case. */ diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SQLExpressionBuilder.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SQLExpressionBuilder.kt index 9a0c75e327..5dc0db9dd9 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SQLExpressionBuilder.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SQLExpressionBuilder.kt @@ -16,6 +16,9 @@ import kotlin.internal.LowPriorityInOverloadResolution // String Functions +/** Returns the length of this string expression, measured in characters, or `null` if this expression is null. */ +fun Expression.charLength(): CharLength = CharLength(this) + /** Converts this string expression to lower case. */ fun Expression.lowerCase(): LowerCase = LowerCase(this) diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/Default.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/Default.kt index e4ddc9781c..8b97f9c3ef 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/Default.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/Default.kt @@ -148,6 +148,16 @@ abstract class FunctionProvider { // String functions + /** + * SQL function that returns the length of [expr], measured in characters, or `null` if [expr] is null. + * + * @param expr String expression to count characters in. + * @param queryBuilder Query builder to append the SQL function to. + */ + open fun charLength(expr: Expression, queryBuilder: QueryBuilder): Unit = queryBuilder { + append("CHAR_LENGTH(", expr, ")") + } + /** * SQL function that extracts a substring from the specified string expression. * diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/OracleDialect.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/OracleDialect.kt index ec50b62225..b8a3ccf8a5 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/OracleDialect.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/OracleDialect.kt @@ -55,6 +55,10 @@ internal object OracleFunctionProvider : FunctionProvider() { */ override fun random(seed: Int?): String = "dbms_random.value" + override fun charLength(expr: Expression, queryBuilder: QueryBuilder) = queryBuilder { + append("LENGTH(", expr, ")") + } + override fun substring( expr: Expression, start: Expression, diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/SQLServerDialect.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/SQLServerDialect.kt index 520cc490f5..b0812ee16c 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/SQLServerDialect.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/SQLServerDialect.kt @@ -57,6 +57,10 @@ internal object SQLServerFunctionProvider : FunctionProvider() { override fun random(seed: Int?): String = if (seed != null) "RAND($seed)" else "RAND(CHECKSUM(NEWID()))" + override fun charLength(expr: Expression, queryBuilder: QueryBuilder) = queryBuilder { + append("LEN(", expr, ")") + } + override fun groupConcat(expr: GroupConcat, queryBuilder: QueryBuilder) { val tr = TransactionManager.current() return when { diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/SQLiteDialect.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/SQLiteDialect.kt index a17b7ac0d1..4edf7a865a 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/SQLiteDialect.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/SQLiteDialect.kt @@ -19,6 +19,10 @@ internal object SQLiteDataTypeProvider : DataTypeProvider() { } internal object SQLiteFunctionProvider : FunctionProvider() { + override fun charLength(expr: Expression, queryBuilder: QueryBuilder) = queryBuilder { + append("LENGTH(", expr, ")") + } + override fun substring( expr: Expression, start: Expression, diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/functions/FunctionsTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/functions/FunctionsTests.kt index a4037979c6..6f4c444bc5 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/functions/FunctionsTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/functions/FunctionsTests.kt @@ -4,7 +4,6 @@ import org.jetbrains.exposed.crypt.Algorithms import org.jetbrains.exposed.crypt.Encryptor import org.jetbrains.exposed.dao.id.IntIdTable import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.Function import org.jetbrains.exposed.sql.SqlExpressionBuilder.concat import org.jetbrains.exposed.sql.tests.DatabaseTestsBase import org.jetbrains.exposed.sql.tests.TestDB @@ -22,6 +21,7 @@ import org.jetbrains.exposed.sql.vendors.h2Mode import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull class FunctionsTests : DatabaseTestsBase() { @@ -261,15 +261,9 @@ class FunctionsTests : DatabaseTestsBase() { } @Test - fun testLengthWithCount01() { - class LengthFunction>(val exp: T) : Function(IntegerColumnType()) { - override fun toQueryBuilder(queryBuilder: QueryBuilder) = queryBuilder { - if (currentDialectTest is SQLServerDialect) append("LEN(", exp, ')') - else append("LENGTH(", exp, ')') - } - } + fun testCharLengthWithSum() { withCitiesAndUsers { cities, _, _ -> - val sumOfLength = LengthFunction(cities.name).sum() + val sumOfLength = CharLength(cities.name).sum() val expectedValue = cities.selectAll().sumOf { it[cities.name].length } val results = cities.slice(sumOfLength).selectAll().toList() @@ -278,6 +272,38 @@ class FunctionsTests : DatabaseTestsBase() { } } + @Test + fun testCharLengthWithEdgeCaseStrings() { + val testTable = object : Table("test_table") { + val nullString = varchar("null_string", 32).nullable() + val emptyString = varchar("empty_string", 32).nullable() + } + + withTables(testTable) { + testTable.insert { + it[nullString] = null + it[emptyString] = "" + } + val helloWorld = "こんにちは世界" // each character is a 3-byte character + + val nullLength = testTable.nullString.charLength() + val emptyLength = testTable.emptyString.charLength() + val multiByteLength = CharLength(stringLiteral(helloWorld)) + + // Oracle treats empty strings as null + val isOracleDialect = currentDialectTest is OracleDialect || + currentDialectTest.h2Mode == H2Dialect.H2CompatibilityMode.Oracle + val expectedEmpty = if (isOracleDialect) null else 0 + // char_length should return single-character count, not total byte count + val expectedMultibyte = helloWorld.length + + val result = testTable.slice(nullLength, emptyLength, multiByteLength).selectAll().single() + assertNull(result[nullLength]) + assertEquals(expectedEmpty, result[emptyLength]) + assertEquals(expectedMultibyte, result[multiByteLength]) + } + } + @Test fun testSelectCase01() { withCitiesAndUsers { _, users, _ ->