Skip to content

Commit

Permalink
feat: EXPOSED-32 Support string function CHAR_LENGTH
Browse files Browse the repository at this point in the history
Rationale for:

- Function Name: Opted to use `CharLength` instead of `Length` to follow
the ANSI SQL standards, even though all dialects support a function with
a variation on the name 'length'. Also, LENGTH() specifically returns the
length of the string measured in bytes in MySQL and MariaDB, whereas all
versions of CHAR_LENGTH() return the expected single-character count.

- Expression Type: The function only accepts string expressions, so an
explicit cast is required if a number needs to be provided to the function.
Of the supported databases, only MySQL, MariaDB, and Sqlite support a
numerical value argument for implicit casting by the database.
  • Loading branch information
bog-walk committed May 4, 2023
1 parent 815a504 commit 3b22144
Show file tree
Hide file tree
Showing 7 changed files with 69 additions and 9 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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<T : String?>(
val expr: Expression<T>
) : Function<Int?>(IntegerColumnType()) {
override fun toQueryBuilder(queryBuilder: QueryBuilder): Unit = currentDialect.functionProvider.charLength(expr, queryBuilder)
}

/**
* Represents an SQL function that converts [expr] to lower case.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <T : String?> Expression<T>.charLength(): CharLength<T> = CharLength(this)

/** Converts this string expression to lower case. */
fun <T : String?> Expression<T>.lowerCase(): LowerCase<T> = LowerCase(this)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <T : String?> charLength(expr: Expression<T>, queryBuilder: QueryBuilder): Unit = queryBuilder {
append("CHAR_LENGTH(", expr, ")")
}

/**
* SQL function that extracts a substring from the specified string expression.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ internal object OracleFunctionProvider : FunctionProvider() {
*/
override fun random(seed: Int?): String = "dbms_random.value"

override fun <T : String?> charLength(expr: Expression<T>, queryBuilder: QueryBuilder) = queryBuilder {
append("LENGTH(", expr, ")")
}

override fun <T : String?> substring(
expr: Expression<T>,
start: Expression<Int>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <T : String?> charLength(expr: Expression<T>, queryBuilder: QueryBuilder) = queryBuilder {
append("LEN(", expr, ")")
}

override fun <T : String?> groupConcat(expr: GroupConcat<T>, queryBuilder: QueryBuilder) {
val tr = TransactionManager.current()
return when {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ internal object SQLiteDataTypeProvider : DataTypeProvider() {
}

internal object SQLiteFunctionProvider : FunctionProvider() {
override fun <T : String?> charLength(expr: Expression<T>, queryBuilder: QueryBuilder) = queryBuilder {
append("LENGTH(", expr, ")")
}

override fun <T : String?> substring(
expr: Expression<T>,
start: Expression<Int>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {

Expand Down Expand Up @@ -261,15 +261,9 @@ class FunctionsTests : DatabaseTestsBase() {
}

@Test
fun testLengthWithCount01() {
class LengthFunction<T : ExpressionWithColumnType<String>>(val exp: T) : Function<Int>(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()
Expand All @@ -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, _ ->
Expand Down

0 comments on commit 3b22144

Please sign in to comment.