Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: EXPOSED-69 Extend json support to H2, Oracle (text) and DAO #1766

Merged
merged 1 commit into from
Jun 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions exposed-core/api/exposed-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -1129,6 +1129,7 @@ public class org/jetbrains/exposed/sql/JsonColumnType : org/jetbrains/exposed/sq
public fun <init> (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
public final fun getDeserialize ()Lkotlin/jvm/functions/Function1;
public final fun getSerialize ()Lkotlin/jvm/functions/Function1;
public fun nonNullValueToString (Ljava/lang/Object;)Ljava/lang/String;
public synthetic fun notNullValueToDB (Ljava/lang/Object;)Ljava/lang/Object;
public fun notNullValueToDB (Ljava/lang/Object;)Ljava/lang/String;
public fun setParameter (Lorg/jetbrains/exposed/sql/statements/api/PreparedStatementApi;ILjava/lang/Object;)V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -924,20 +924,30 @@ open class JsonColumnType<T : Any>(

override fun valueFromDB(value: Any): Any {
return when (value) {
is String -> deserialize(value)
is PGobject -> deserialize(value.value!!)
else -> deserialize(value as String)
is ByteArray -> deserialize(value.decodeToString())
else -> value
}
}

@Suppress("UNCHECKED_CAST")
override fun notNullValueToDB(value: Any) = serialize(value as T)

override fun nonNullValueToString(value: Any): String {
return when (currentDialect) {
is H2Dialect -> "JSON '${notNullValueToDB(value)}'"
else -> super.nonNullValueToString(value)
}
}

override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) {
val parameterValue = when (currentDialect) {
is PostgreSQLDialect -> PGobject().apply {
type = sqlType()
this.value = value as String?
}
is H2Dialect -> (value as String).encodeToByteArray()
else -> value
}
super.setParameter(stmt, index, parameterValue)
Expand All @@ -954,7 +964,10 @@ class JsonBColumnType<T : Any>(
serialize: (T) -> String,
deserialize: (String) -> T
) : JsonColumnType<T>(serialize, deserialize) {
override fun sqlType(): String = currentDialect.dataTypeProvider.jsonBType()
override fun sqlType(): String = when (currentDialect) {
is H2Dialect -> H2DataTypeProvider.jsonBType()
else -> currentDialect.dataTypeProvider.jsonBType()
}
}

// Date/Time columns
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package org.jetbrains.exposed.sql.vendors

import org.intellij.lang.annotations.Language
import org.jetbrains.exposed.exceptions.UnsupportedByDialectException
import org.jetbrains.exposed.exceptions.throwUnsupportedException
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.TransactionManager
Expand All @@ -15,8 +14,7 @@ internal object H2DataTypeProvider : DataTypeProvider() {
override fun uuidType(): String = "UUID"
override fun dateTimeType(): String = "DATETIME(9)"

override fun jsonType(): String =
throw UnsupportedByDialectException("This vendor does not support non-binary text JSON data type", currentDialect)
override fun jsonBType(): String = "JSON"

override fun hexToDb(hexString: String): String = "X'$hexString'"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package org.jetbrains.exposed.sql.vendors

import org.jetbrains.exposed.exceptions.UnsupportedByDialectException
import org.jetbrains.exposed.exceptions.throwUnsupportedException
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.TransactionManager
Expand Down Expand Up @@ -37,11 +36,14 @@ internal object OracleDataTypeProvider : DataTypeProvider() {
override fun booleanFromStringToBoolean(value: String): Boolean = try {
value.toLong() != 0L
} catch (ex: NumberFormatException) {
error("Unexpected value of type Boolean: $value")
try {
value.lowercase().toBooleanStrict()
} catch (ex: IllegalArgumentException) {
error("Unexpected value of type Boolean: $value")
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered refactoring this to something similar to what's done in vendors/Mysql.kt:

override fun booleanFromStringToBoolean(value: String): Boolean = when (value) {
    "0" -> false
    "1" -> true
    else -> value.toBoolean()
}

But didn't want to exclude the possibility of DB returning multi-digit strings, like "000". This is the only workaround I could think of that attempts both extensions if 1 throws.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you tell me in what case it can happen?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@e5l To get a multi-digit string? Honestly, I'm not sure, that's just the assumption I'm making for why value.toLong() was used instead of the code in Mysql.kt.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I'm reading, boolean datatype in Oracle is a hot topic and only just got supported in 23c. But the standard recommendation seems to be 'Y'/'N' (char(1)), 0/1(number), or 'true'/'false'(varchar2), but maybe some people choose "0"/"1".

}

override fun jsonType(): String =
throw UnsupportedByDialectException("This vendor does not support non-binary text JSON data type", currentDialect)
override fun jsonType(): String = "VARCHAR2(4000)"

override fun processForDefaultValue(e: Expression<*>): String = when {
e is LiteralOp<*> && (e.columnType as? IDateColumnType)?.hasTimePart == false -> "DATE ${super.processForDefaultValue(e)}"
Expand Down Expand Up @@ -146,6 +148,18 @@ internal object OracleFunctionProvider : FunctionProvider() {
append(")")
}

override fun <T> jsonExtract(
expression: Expression<T>,
vararg path: String,
toScalar: Boolean,
queryBuilder: QueryBuilder
) = queryBuilder {
append(if (toScalar) "JSON_VALUE" else "JSON_QUERY")
append("(", expression, ", ")
path.appendTo { +"'$.$it'" }
append(")")
}

override fun update(
target: Table,
columnsAndValues: List<Pair<Column<*>, Any?>>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import org.jetbrains.exposed.sql.tests.shared.assertEquals
import org.junit.Test

class JsonBColumnTypeTests : DatabaseTestsBase() {
private val binaryJsonNotSupportedDB = listOf(TestDB.SQLITE, TestDB.SQLSERVER) + TestDB.allH2TestDB + TestDB.ORACLE
private val binaryJsonNotSupportedDB = listOf(TestDB.SQLITE, TestDB.SQLSERVER) + TestDB.ORACLE

@Test
fun testInsertAndSelect() {
Expand Down Expand Up @@ -35,4 +35,32 @@ class JsonBColumnTypeTests : DatabaseTestsBase() {
assertEquals(updatedData, tester.selectAll().single()[tester.jsonBColumn])
}
}

@Test
fun testDAOFunctionsWithJsonBColumn() {
val dataTable = JsonTestsData.JsonBTable
val dataEntity = JsonTestsData.JsonBEntity

withDb(excludeSettings = binaryJsonNotSupportedDB) { testDb ->
excludingH2Version1(testDb) {
SchemaUtils.create(dataTable)

val dataA = DataHolder(User("Admin", "Alpha"), 10, true, null)
val newUser = dataEntity.new {
jsonBColumn = dataA
}

assertEquals(dataA, dataEntity.findById(newUser.id)?.jsonBColumn)

val updatedUser = dataA.copy(logins = 99)
dataTable.update {
it[jsonBColumn] = updatedUser
}

assertEquals(updatedUser, dataEntity.all().single().jsonBColumn)

SchemaUtils.drop(dataTable)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,15 @@ import org.jetbrains.exposed.sql.tests.TestDB
import org.jetbrains.exposed.sql.tests.currentDialectTest
import org.jetbrains.exposed.sql.tests.shared.assertEquals
import org.jetbrains.exposed.sql.tests.shared.expectException
import org.jetbrains.exposed.sql.vendors.OracleDialect
import org.jetbrains.exposed.sql.vendors.PostgreSQLDialect
import org.jetbrains.exposed.sql.vendors.SQLServerDialect
import org.junit.Test

class JsonColumnTypeTests : DatabaseTestsBase() {
private val notYetSupportedDB = TestDB.allH2TestDB + TestDB.ORACLE

@Test
fun testInsertAndSelect() {
withJsonTable(exclude = notYetSupportedDB) { tester, _, _ ->
withJsonTable { tester, _, _ ->
val newData = DataHolder(User("Pro", "Alpha"), 999, true, "A")
val newId = tester.insertAndGetId {
it[jsonColumn] = newData
Expand All @@ -31,7 +30,7 @@ class JsonColumnTypeTests : DatabaseTestsBase() {

@Test
fun testUpdate() {
withJsonTable(exclude = notYetSupportedDB) { tester, _, data1 ->
withJsonTable { tester, _, data1 ->
assertEquals(data1, tester.selectAll().single()[tester.jsonColumn])

val updatedData = data1.copy(active = false)
Expand All @@ -45,9 +44,9 @@ class JsonColumnTypeTests : DatabaseTestsBase() {

@Test
fun testSelectWithSliceExtract() {
withJsonTable(exclude = notYetSupportedDB) { tester, user1, data1 ->
// SQLServer returns null if extracted JSON is not scalar
val requiresScalar = currentDialectTest is SQLServerDialect
withJsonTable(exclude = TestDB.allH2TestDB) { tester, user1, data1 ->
// SQLServer & Oracle return null if extracted JSON is not scalar
val requiresScalar = currentDialectTest is SQLServerDialect || currentDialectTest is OracleDialect
val isActive = tester.jsonColumn.jsonExtract<Boolean>("active", toScalar = requiresScalar)
val result1 = tester.slice(isActive).selectAll().singleOrNull()
assertEquals(data1.active, result1?.get(isActive))
Expand All @@ -65,7 +64,7 @@ class JsonColumnTypeTests : DatabaseTestsBase() {

@Test
fun testSelectWhereWithExtract() {
withJsonTable(exclude = notYetSupportedDB) { tester, _, data1 ->
withJsonTable(exclude = TestDB.allH2TestDB) { tester, _, data1 ->
val newId = tester.insertAndGetId {
it[jsonColumn] = data1.copy(logins = 1000)
}
Expand All @@ -87,7 +86,7 @@ class JsonColumnTypeTests : DatabaseTestsBase() {
fun testWithNonSerializableClass() {
data class Fake(val number: Int)

withDb(excludeSettings = notYetSupportedDB) { testDb ->
withDb { testDb ->
excludingH2Version1(testDb) {
expectException<SerializationException> {
// Throws with message: Serializer for class 'Fake' is not found.
Expand All @@ -99,4 +98,41 @@ class JsonColumnTypeTests : DatabaseTestsBase() {
}
}
}

@Test
fun testDAOFunctionsWithJsonColumn() {
val dataTable = JsonTestsData.JsonTable
val dataEntity = JsonTestsData.JsonEntity

withDb { testDb ->
excludingH2Version1(testDb) {
SchemaUtils.create(dataTable)

val dataA = DataHolder(User("Admin", "Alpha"), 10, true, null)
val newUser = dataEntity.new {
jsonColumn = dataA
}

assertEquals(dataA, dataEntity.findById(newUser.id)?.jsonColumn)

val updatedUser = dataA.copy(user = User("Lead", "Beta"))
dataTable.update {
it[jsonColumn] = updatedUser
}

assertEquals(updatedUser, dataEntity.all().single().jsonColumn)

if (testDb !in TestDB.allH2TestDB) {
dataEntity.new { jsonColumn = dataA }
val path = if (currentDialectTest is PostgreSQLDialect) arrayOf("user", "team") else arrayOf("user.team")
val userTeam = dataTable.jsonColumn.jsonExtract<String>(*path)
val userInTeamB = dataEntity.find { userTeam like "B%" }.single()

assertEquals(updatedUser, userInTeamB.jsonColumn)
}

SchemaUtils.drop(dataTable)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ package org.jetbrains.exposed.sql.tests.shared.types

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.Transaction
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.tests.DatabaseTestsBase
Expand All @@ -16,6 +20,18 @@ object JsonTestsData {
object JsonBTable : IntIdTable("j_b_table") {
val jsonBColumn = jsonb<DataHolder>("j_b_column", Json.Default)
}

class JsonEntity(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<JsonEntity>(JsonTable)

var jsonColumn by JsonTable.jsonColumn
}

class JsonBEntity(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<JsonBEntity>(JsonBTable)

var jsonBColumn by JsonBTable.jsonBColumn
}
}

fun DatabaseTestsBase.withJsonTable(
Expand All @@ -24,14 +40,18 @@ fun DatabaseTestsBase.withJsonTable(
) {
val tester = JsonTestsData.JsonTable

withTables(exclude, tester) { testDb ->
withDb(excludeSettings = exclude) { testDb ->
excludingH2Version1(testDb) {
SchemaUtils.create(tester)

val user1 = User("Admin", null)
val data1 = DataHolder(user1, 10, true, null)

tester.insert { it[jsonColumn] = data1 }

statement(tester, user1, data1)

SchemaUtils.drop(tester)
}
}
}
Expand All @@ -42,14 +62,18 @@ fun DatabaseTestsBase.withJsonBTable(
) {
val tester = JsonTestsData.JsonBTable

withTables(exclude, tester) { testDb ->
withDb(excludeSettings = exclude) { testDb ->
excludingH2Version1(testDb) {
SchemaUtils.create(tester)

val user1 = User("Admin", null)
val data1 = DataHolder(user1, 10, true, null)

tester.insert { it[jsonBColumn] = data1 }

statement(tester, user1, data1)

SchemaUtils.drop(tester)
}
}
}
Expand Down