Skip to content

Commit

Permalink
feat: EXPOSED-69 Extend json/json(b) support to H2, Oracle (text), an…
Browse files Browse the repository at this point in the history
…d DAO (#1766)

Add H2 v2 support and adjust unit tests to properly exclude version 1.

Add Oracle support for JSON in text format only. Adjust how BooleanColumnType
values from the database are handled, as string versions of the boolean are
returned by stored JSON booleans.

Add support for values returned from DB when DAO is used, as well as unit
tests.
  • Loading branch information
bog-walk authored Jun 22, 2023
1 parent a154fcc commit df2426c
Show file tree
Hide file tree
Showing 7 changed files with 135 additions and 21 deletions.
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")
}
}

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

0 comments on commit df2426c

Please sign in to comment.