Skip to content

Commit

Permalink
fix: EXPOSED-108 Incorrect mapping for UInt data type (#1809)
Browse files Browse the repository at this point in the history
Currently, when attempting to insert a UInt value outside of the range [0, 2,147,483,647],
Exposed truncates the value by calling value.toInt() before sending it to the
DB, causing overflow. The value is stored successfully as a negative number because
all databases (except MySQL and MariaDB) don't support unsigned types natively,
which means Exposed is actually mapping to 4-byte INT, which accepts
the range [-2,147,483,648, 2,147,483,647].

Change the default mapping to the next higher-up integer data type BIGINT (technically
a 8-byte storage type) and remove the truncation conversions so that an accurate
value is sent/received to/from the database. To ensure that the intended behavior
cannot be overriden using exec() directly, a check constraint is auto-applied to
the column when registered if the database is not MySQL/MariaDB.
  • Loading branch information
bog-walk authored Jul 31, 2023
1 parent 64ecf91 commit 7a61852
Show file tree
Hide file tree
Showing 4 changed files with 90 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -333,32 +333,34 @@ class IntegerColumnType : ColumnType() {

/**
* Numeric column for storing unsigned 4-byte integers.
*
* **Note:** If the database being used is not MySQL or MariaDB, this column will use the database's
* 8-byte integer type with a check constraint that ensures storage of only values
* between 0 and [UInt.MAX_VALUE] inclusive.
*/
class UIntegerColumnType : ColumnType() {
override fun sqlType(): String = currentDialect.dataTypeProvider.uintegerType()
override fun valueFromDB(value: Any): UInt {
return when (value) {
is UInt -> value
is Int -> value.takeIf { it >= 0 }?.toUInt()
is Number -> value.toLong().takeIf { it >= 0 && it <= UInt.MAX_VALUE.toLong() }?.toUInt()
is Int -> value.toUInt()
is Number -> value.toLong().toUInt()
is String -> value.toUInt()
else -> error("Unexpected value of type Int: $value of ${value::class.qualifiedName}")
} ?: error("Negative value but type is UInt: $value")
}
}

override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) {
val v = when {
value is UInt && currentDialect is MysqlDialect -> value.toLong()
value is UInt -> value.toInt()
val v = when (value) {
is UInt -> value.toLong()
else -> value
}
super.setParameter(stmt, index, v)
}

override fun notNullValueToDB(value: Any): Any {
val v = when {
value is UInt && currentDialect is MysqlDialect -> value.toLong()
value is UInt -> value.toInt()
val v = when (value) {
is UInt -> value.toLong()
else -> value
}
return super.notNullValueToDB(v)
Expand Down
11 changes: 9 additions & 2 deletions exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Table.kt
Original file line number Diff line number Diff line change
Expand Up @@ -504,8 +504,15 @@ open class Table(name: String = "") : ColumnSet(), DdlAware {
/** Creates a numeric column, with the specified [name], for storing 4-byte integers. */
fun integer(name: String): Column<Int> = registerColumn(name, IntegerColumnType())

/** Creates a numeric column, with the specified [name], for storing 4-byte unsigned integers. */
fun uinteger(name: String): Column<UInt> = registerColumn(name, UIntegerColumnType())
/** Creates a numeric column, with the specified [name], for storing 4-byte unsigned integers.
*
* **Note:** If the database being used is not MySQL or MariaDB, this column will use the database's
* 8-byte integer type with a check constraint that ensures storage of only values
* between 0 and [UInt.MAX_VALUE] inclusive.
*/
fun uinteger(name: String): Column<UInt> = registerColumn<UInt>(name, UIntegerColumnType()).apply {
check("$generatedCheckPrefix$name") { it.between(0u, UInt.MAX_VALUE) }
}

/** Creates a numeric column, with the specified [name], for storing 8-byte integers. */
fun long(name: String): Column<Long> = registerColumn(name, LongColumnType())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,11 @@ abstract class DataTypeProvider {
/** Numeric type for storing 4-byte integers. */
open fun integerType(): String = "INT"

/** Numeric type for storing 4-byte unsigned integers. */
open fun uintegerType(): String = "INT"
/** Numeric type for storing 4-byte unsigned integers.
*
* **Note:** If the database being used is not MySQL or MariaDB, this will represent the 8-byte integer type.
*/
open fun uintegerType(): String = "BIGINT"

/** Numeric type for storing 4-byte integers, marked as auto-increment. */
open fun integerAutoincType(): String = "INT AUTO_INCREMENT"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,71 @@ class UnsignedColumnTypeTests : DatabaseTestsBase() {
}
}

@Test
fun testUIntWithCheckConstraint() {
withTables(UIntTable) {
val ddlEnding = if (currentDialectTest is MysqlDialect) {
"(uint INT UNSIGNED NOT NULL)"
} else {
"CHECK (uint BETWEEN 0 and ${UInt.MAX_VALUE}))"
}
assertTrue(UIntTable.ddl.single().endsWith(ddlEnding, ignoreCase = true))

val number = 3_221_225_471u
assertTrue(number in Int.MAX_VALUE.toUInt()..UInt.MAX_VALUE)

UIntTable.insert { it[unsignedInt] = number }

val result = UIntTable.selectAll()
assertEquals(number, result.single()[UIntTable.unsignedInt])

// test that column itself blocks same out-of-range value that compiler blocks
assertFailAndRollback("Check constraint violation (or out-of-range error in MySQL/MariaDB)") {
val tableName = UIntTable.nameInDatabaseCase()
val columnName = UIntTable.unsignedInt.nameInDatabaseCase()
val outOfRangeValue = UInt.MAX_VALUE.toLong() + 1L
exec("""INSERT INTO $tableName ($columnName) VALUES ($outOfRangeValue)""")
}
}
}

@Test
fun testPreviousUIntColumnTypeWorksWithNewBigIntType() {
// Oracle was already previously constrained to NUMBER(13)
withDb(excludeSettings = listOf(TestDB.MYSQL, TestDB.MARIADB, TestDB.ORACLE)) { testDb ->
try {
val tableName = UIntTable.nameInDatabaseCase()
val columnName = UIntTable.unsignedInt.nameInDatabaseCase()
// create table using previous column type INT
exec("""CREATE TABLE ${addIfNotExistsIfSupported()}$tableName ($columnName INT NOT NULL)""")

val number1 = Int.MAX_VALUE.toUInt()
UIntTable.insert { it[unsignedInt] = number1 }

val result1 = UIntTable.select { UIntTable.unsignedInt eq number1 }.count()
assertEquals(1, result1)

// INT maps to INTEGER in SQLite, so it will not throw OoR error
if (testDb != TestDB.SQLITE) {
val number2 = Int.MAX_VALUE.toUInt() + 1u
assertFailAndRollback("Out-of-range (OoR) error") {
UIntTable.insert { it[unsignedInt] = number2 }
assertEquals(0, UIntTable.select { UIntTable.unsignedInt less 0u }.count())
}

// modify column to now have BIGINT type
exec(UIntTable.unsignedInt.modifyStatement().first())
UIntTable.insert { it[unsignedInt] = number2 }

val result2 = UIntTable.selectAll().map { it[UIntTable.unsignedInt] }
assertEqualCollections(listOf(number1, number2), result2)
}
} finally {
SchemaUtils.drop(UIntTable)
}
}
}

@Test
fun testULongColumnType() {
withTables(ULongTable) {
Expand Down

0 comments on commit 7a61852

Please sign in to comment.