From ec0cac19d44b4f9a715ced17c7ebe7e44636d9fa Mon Sep 17 00:00:00 2001 From: Alexander Ioffe Date: Thu, 4 Dec 2025 09:37:30 -0500 Subject: [PATCH 1/3] JSON support for other JDBC databases --- .../controller/jdbc/JdbcControllers.kt | 49 +++++++++++++--- .../exoquery/controller/jdbc/JdbcEncoders.kt | 10 +++- .../io/exoquery/sql/mysql/JsonSpecSimple.kt | 57 +++++++++++++++++++ .../src/test/resources/db/h2-schema.sql | 5 ++ .../src/test/resources/db/mysql-schema.sql | 5 ++ .../src/test/resources/db/oracle-schema.sql | 5 ++ .../src/test/resources/db/sqlite-schema.sql | 5 ++ .../test/resources/db/sqlserver-schema.sql | 5 ++ 8 files changed, 131 insertions(+), 10 deletions(-) create mode 100644 terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/mysql/JsonSpecSimple.kt diff --git a/controller-jdbc/src/main/kotlin/io/exoquery/controller/jdbc/JdbcControllers.kt b/controller-jdbc/src/main/kotlin/io/exoquery/controller/jdbc/JdbcControllers.kt index f586ddb..77c5bc6 100644 --- a/controller-jdbc/src/main/kotlin/io/exoquery/controller/jdbc/JdbcControllers.kt +++ b/controller-jdbc/src/main/kotlin/io/exoquery/controller/jdbc/JdbcControllers.kt @@ -32,8 +32,8 @@ object JdbcControllers { // Postgrees comes with its own default encoders, to exclude them need to override this property direct override val encodingConfig = encodingConfig.copy( - additionalEncoders = encodingConfig.additionalEncoders + AdditionalPostgresEncoding.encoders, - additionalDecoders = encodingConfig.additionalDecoders + AdditionalPostgresEncoding.decoders + additionalEncoders = encodingConfig.additionalEncoders + JsonObjectEncoding.encoders, + additionalDecoders = encodingConfig.additionalDecoders + JsonObjectEncoding.decoders ) // Postgres does not support Types.TIME_WITH_TIMEZONE as a JDBC type but does have a `TIME WITH TIMEZONE` datatype this is puzzling. @@ -59,8 +59,8 @@ object JdbcControllers { override val encodingConfig = encodingConfig.copy( - additionalEncoders = encodingConfig.additionalEncoders + AdditionalPostgresEncoding.encoders, - additionalDecoders = encodingConfig.additionalDecoders + AdditionalPostgresEncoding.decoders + additionalEncoders = encodingConfig.additionalEncoders + JsonObjectEncoding.encoders, + additionalDecoders = encodingConfig.additionalDecoders + JsonObjectEncoding.decoders ) companion object { } @@ -68,7 +68,7 @@ object JdbcControllers { open class H2( override val database: DataSource, - override val encodingConfig: JdbcEncodingConfig = JdbcEncodingConfig.Default + encodingConfig: JdbcEncodingConfig = JdbcEncodingConfig.Default ): JdbcController(database) { override val encodingApi: JdbcSqlEncoding = object: JavaSqlEncoding, @@ -76,12 +76,18 @@ object JdbcControllers { JavaTimeEncoding by JdbcTimeEncoding(), JavaUuidEncoding by JdbcUuidObjectEncoding {} + override val encodingConfig = + encodingConfig.copy( + additionalEncoders = encodingConfig.additionalEncoders + JsonTextEncoding.encoders, + additionalDecoders = encodingConfig.additionalDecoders + JsonTextEncoding.decoders + ) + companion object { } } open class Mysql( override val database: DataSource, - override val encodingConfig: JdbcEncodingConfig = JdbcEncodingConfig.Default + encodingConfig: JdbcEncodingConfig = JdbcEncodingConfig.Default ): JdbcController(database) { override val encodingApi: JdbcSqlEncoding = object : JavaSqlEncoding, @@ -95,12 +101,19 @@ object JdbcControllers { override val jdbcTypeOfOffsetTime = Types.TIME override val jdbcTypeOfOffsetDateTime = Types.TIMESTAMP } + + override val encodingConfig = + encodingConfig.copy( + additionalEncoders = encodingConfig.additionalEncoders + JsonObjectEncoding.encoders, + additionalDecoders = encodingConfig.additionalDecoders + JsonObjectEncoding.decoders + ) + companion object { } } open class Sqlite( override val database: DataSource, - override val encodingConfig: JdbcEncodingConfig = JdbcEncodingConfig.Default + encodingConfig: JdbcEncodingConfig = JdbcEncodingConfig.Default ): JdbcController(database) { override val encodingApi: JdbcSqlEncoding = object : JavaSqlEncoding, @@ -108,6 +121,12 @@ object JdbcControllers { JavaTimeEncoding by JdbcTimeEncodingLegacy, JavaUuidEncoding by JdbcUuidStringEncoding {} + override val encodingConfig = + encodingConfig.copy( + additionalEncoders = encodingConfig.additionalEncoders + JsonTextEncoding.encoders, + additionalDecoders = encodingConfig.additionalDecoders + JsonTextEncoding.decoders + ) + protected override open suspend fun runActionReturningScoped(act: ControllerActionReturning, options: JdbcExecutionOptions): Flow = flowWithConnection(options) { val conn = localConnection() @@ -159,7 +178,7 @@ object JdbcControllers { open class Oracle( override val database: DataSource, - override val encodingConfig: JdbcEncodingConfig = JdbcEncodingConfig.Default + encodingConfig: JdbcEncodingConfig = JdbcEncodingConfig.Default ): JdbcController(database) { override val encodingApi: JdbcSqlEncoding = object : JavaSqlEncoding, @@ -167,6 +186,12 @@ object JdbcControllers { JavaTimeEncoding by OracleTimeEncoding, JavaUuidEncoding by JdbcUuidStringEncoding {} + override val encodingConfig = + encodingConfig.copy( + additionalEncoders = encodingConfig.additionalEncoders + JsonTextEncoding.encoders, + additionalDecoders = encodingConfig.additionalDecoders + JsonTextEncoding.decoders + ) + object OracleTimeEncoding: JdbcTimeEncoding() { // Normally it is Types.TIME by in that case Oracle truncates the milliseconds override val jdbcTypeOfLocalTime = Types.TIMESTAMP @@ -193,7 +218,7 @@ object JdbcControllers { open class SqlServer( override val database: DataSource, - override val encodingConfig: JdbcEncodingConfig = JdbcEncodingConfig.Default + encodingConfig: JdbcEncodingConfig = JdbcEncodingConfig.Default ): JdbcController(database) { override val encodingApi: JdbcSqlEncoding = object : JavaSqlEncoding, @@ -201,6 +226,12 @@ object JdbcControllers { JavaTimeEncoding by SqlServerTimeEncoding, JavaUuidEncoding by JdbcUuidStringEncoding {} + override val encodingConfig = + encodingConfig.copy( + additionalEncoders = encodingConfig.additionalEncoders + JsonTextEncoding.encoders, + additionalDecoders = encodingConfig.additionalDecoders + JsonTextEncoding.decoders + ) + object SqlServerTimeEncoding: JdbcTimeEncoding() { // Override java.util.Date encoding to use TIMESTAMP_WITH_TIMEZONE for DATETIMEOFFSET columns override val JDateEncoder: JdbcEncoderAny = JdbcEncoderAny(Types.TIMESTAMP_WITH_TIMEZONE, java.util.Date::class) { ctx, v, i -> diff --git a/controller-jdbc/src/main/kotlin/io/exoquery/controller/jdbc/JdbcEncoders.kt b/controller-jdbc/src/main/kotlin/io/exoquery/controller/jdbc/JdbcEncoders.kt index 0b8bf6d..486bac6 100644 --- a/controller-jdbc/src/main/kotlin/io/exoquery/controller/jdbc/JdbcEncoders.kt +++ b/controller-jdbc/src/main/kotlin/io/exoquery/controller/jdbc/JdbcEncoders.kt @@ -64,7 +64,7 @@ open class JdbcBasicEncoding: override val ByteArrayDecoder: JdbcDecoderAny = JdbcDecoderAny(ByteArray::class) { ctx, i -> ctx.row.getBytes(i) } } -object AdditionalPostgresEncoding { +object JsonObjectEncoding { val SqlJsonEncoder: JdbcEncoder = JdbcEncoderAny(Types.OTHER, SqlJson::class) { ctx, v, i -> ctx.stmt.setObject(i, v.value, Types.OTHER) } val SqlJsonDecoder: JdbcDecoder = JdbcDecoderAny(SqlJson::class) { ctx, i -> SqlJson(ctx.row.getString(i)) } @@ -72,6 +72,14 @@ object AdditionalPostgresEncoding { val decoders = setOf(SqlJsonDecoder) } +object JsonTextEncoding { + val SqlJsonEncoder: JdbcEncoder = JdbcEncoderAny(Types.VARCHAR, SqlJson::class) { ctx, v, i -> ctx.stmt.setObject(i, v.value, Types.VARCHAR) } + val SqlJsonDecoder: JdbcDecoder = JdbcDecoderAny(SqlJson::class) { ctx, i -> SqlJson(ctx.row.getString(i)) } + + val encoders = setOf(SqlJsonEncoder) + val decoders = setOf(SqlJsonDecoder) +} + object AdditionalJdbcEncoding { val BigDecimalEncoder: JdbcEncoderAny = JdbcEncoderAny(Types.NUMERIC, BigDecimal::class) { ctx, v, i -> ctx.stmt.setBigDecimal(i, v) } val BigDecimalDecoder: JdbcDecoderAny = JdbcDecoderAny(BigDecimal::class) { ctx, i -> ctx.row.getBigDecimal(i) } diff --git a/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/mysql/JsonSpecSimple.kt b/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/mysql/JsonSpecSimple.kt new file mode 100644 index 0000000..d393676 --- /dev/null +++ b/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/mysql/JsonSpecSimple.kt @@ -0,0 +1,57 @@ +package io.exoquery.sql.mysql + +import io.exoquery.controller.JsonValue +import io.exoquery.controller.SqlJsonValue +import io.exoquery.sql.* +import io.exoquery.sql.Sql +import io.exoquery.controller.jdbc.JdbcControllers +import io.exoquery.controller.runOn +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class JsonSpec: FreeSpec({ + val ds = TestDatabases.postgres + val ctx by lazy { + JdbcControllers.Postgres(ds) + } + + beforeEach { + ds.run("DELETE FROM JsonbExample") + ds.run("DELETE FROM JsonbExample2") + ds.run("DELETE FROM JsonbExample3") + ds.run("DELETE FROM JsonExample") + } + + "SqlJsonValue annotation works on" - { + "inner data class" - { + @SqlJsonValue + @Serializable + data class MyPerson(val name: String, val age: Int) + + @Serializable + data class Example(val id: Int, val value: MyPerson) + + val je = Example(1, MyPerson("Alice", 30)) + + "should encode in json (with explicit serializer) and decode" { + Sql("INSERT INTO JsonExample (id, value) VALUES (1, ${Param.withSer(je.value, MyPerson.serializer())})").action().runOn(ctx) + Sql("SELECT id, value FROM JsonExample").queryOf().runOn(ctx) shouldBe listOf(je) + } + } + + "annotated field" { + @Serializable + data class MyPerson(val name: String, val age: Int) + + @Serializable + data class Example(val id: Int, @SqlJsonValue val value: MyPerson) + + val je = Example(1, MyPerson("Joe", 123)) + Sql("""INSERT INTO JsonbExample (id, value) VALUES (1, '{"name":"Joe", "age":123}')""").action().runOn(ctx) + val customers = Sql("SELECT id, value FROM JsonbExample").queryOf().runOn(ctx) + customers shouldBe listOf(je) + } + } + +}) diff --git a/terpal-sql-jdbc/src/test/resources/db/h2-schema.sql b/terpal-sql-jdbc/src/test/resources/db/h2-schema.sql index ea22ed2..84b7b68 100644 --- a/terpal-sql-jdbc/src/test/resources/db/h2-schema.sql +++ b/terpal-sql-jdbc/src/test/resources/db/h2-schema.sql @@ -81,3 +81,8 @@ CREATE TABLE IF NOT EXISTS JavaTestEntity( javaUtilDateOpt TIMESTAMP, uuidOpt UUID ); + +CREATE TABLE IF NOT EXISTS JsonExample( + id IDENTITY, + "value" VARCHAR(255) +); diff --git a/terpal-sql-jdbc/src/test/resources/db/mysql-schema.sql b/terpal-sql-jdbc/src/test/resources/db/mysql-schema.sql index 9789869..d88484a 100644 --- a/terpal-sql-jdbc/src/test/resources/db/mysql-schema.sql +++ b/terpal-sql-jdbc/src/test/resources/db/mysql-schema.sql @@ -85,3 +85,8 @@ CREATE TABLE JavaTestEntity( javaUtilDateOpt DATETIME, uuidOpt VARCHAR(255) ); + +CREATE TABLE JsonExample( + id INT PRIMARY KEY, + value JSON +); diff --git a/terpal-sql-jdbc/src/test/resources/db/oracle-schema.sql b/terpal-sql-jdbc/src/test/resources/db/oracle-schema.sql index 3a8d13f..212969b 100644 --- a/terpal-sql-jdbc/src/test/resources/db/oracle-schema.sql +++ b/terpal-sql-jdbc/src/test/resources/db/oracle-schema.sql @@ -80,3 +80,8 @@ CREATE TABLE JavaTestEntity( javaUtilDateOpt TIMESTAMP, uuidOpt VARCHAR(36) ); + +CREATE TABLE JsonExample( + id INT PRIMARY KEY, + value VARCHAR(255) +); diff --git a/terpal-sql-jdbc/src/test/resources/db/sqlite-schema.sql b/terpal-sql-jdbc/src/test/resources/db/sqlite-schema.sql index 70c7c3f..c42d70b 100644 --- a/terpal-sql-jdbc/src/test/resources/db/sqlite-schema.sql +++ b/terpal-sql-jdbc/src/test/resources/db/sqlite-schema.sql @@ -81,3 +81,8 @@ CREATE TABLE IF NOT EXISTS JavaTestEntity( javaUtilDateOpt BIGINT, uuidOpt VARCHAR(36) ); + +CREATE TABLE JsonExample( + id INT PRIMARY KEY, + value VARCHAR +); diff --git a/terpal-sql-jdbc/src/test/resources/db/sqlserver-schema.sql b/terpal-sql-jdbc/src/test/resources/db/sqlserver-schema.sql index 4f77a0f..5ef56ee 100644 --- a/terpal-sql-jdbc/src/test/resources/db/sqlserver-schema.sql +++ b/terpal-sql-jdbc/src/test/resources/db/sqlserver-schema.sql @@ -81,3 +81,8 @@ CREATE TABLE JavaTestEntity( javaUtilDateOpt DATETIMEOFFSET, -- java.util.Date i.e. legacy date with time zone uuidOpt VARCHAR(255) ); + +CREATE TABLE JsonExample( + id INT PRIMARY KEY, + value VARCHAR +); From 278e27c36770ecd983ec4fb497c73787548cf498 Mon Sep 17 00:00:00 2001 From: Alexander Ioffe Date: Thu, 4 Dec 2025 09:53:48 -0500 Subject: [PATCH 2/3] Fix up support for other DBs --- .../controller/jdbc/JdbcControllers.kt | 4 +- .../io/exoquery/sql/h2/JsonSpecSimple.kt | 54 +++++++++++++++++++ .../io/exoquery/sql/mysql/JsonSpecSimple.kt | 11 ++-- .../io/exoquery/sql/oracle/JsonSpecSimple.kt | 54 +++++++++++++++++++ .../io/exoquery/sql/sqlite/JsonSpecSimple.kt | 54 +++++++++++++++++++ .../exoquery/sql/sqlserver/JsonSpecSimple.kt | 54 +++++++++++++++++++ .../test/resources/db/sqlserver-schema.sql | 2 +- 7 files changed, 223 insertions(+), 10 deletions(-) create mode 100644 terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/h2/JsonSpecSimple.kt create mode 100644 terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/oracle/JsonSpecSimple.kt create mode 100644 terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/sqlite/JsonSpecSimple.kt create mode 100644 terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/sqlserver/JsonSpecSimple.kt diff --git a/controller-jdbc/src/main/kotlin/io/exoquery/controller/jdbc/JdbcControllers.kt b/controller-jdbc/src/main/kotlin/io/exoquery/controller/jdbc/JdbcControllers.kt index 77c5bc6..0c5c593 100644 --- a/controller-jdbc/src/main/kotlin/io/exoquery/controller/jdbc/JdbcControllers.kt +++ b/controller-jdbc/src/main/kotlin/io/exoquery/controller/jdbc/JdbcControllers.kt @@ -104,8 +104,8 @@ object JdbcControllers { override val encodingConfig = encodingConfig.copy( - additionalEncoders = encodingConfig.additionalEncoders + JsonObjectEncoding.encoders, - additionalDecoders = encodingConfig.additionalDecoders + JsonObjectEncoding.decoders + additionalEncoders = encodingConfig.additionalEncoders + JsonTextEncoding.encoders, + additionalDecoders = encodingConfig.additionalDecoders + JsonTextEncoding.decoders ) companion object { } diff --git a/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/h2/JsonSpecSimple.kt b/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/h2/JsonSpecSimple.kt new file mode 100644 index 0000000..bee59c0 --- /dev/null +++ b/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/h2/JsonSpecSimple.kt @@ -0,0 +1,54 @@ +package io.exoquery.sql.h2 + +import io.exoquery.controller.JsonValue +import io.exoquery.controller.SqlJsonValue +import io.exoquery.sql.* +import io.exoquery.sql.Sql +import io.exoquery.controller.jdbc.JdbcControllers +import io.exoquery.controller.runOn +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class JsonSpec: FreeSpec({ + val ds = TestDatabases.h2 + val ctx by lazy { + JdbcControllers.H2(ds) + } + + beforeEach { + ds.run("DELETE FROM JsonExample") + } + + "SqlJsonValue annotation works on" - { + "inner data class" - { + @SqlJsonValue + @Serializable + data class MyPerson(val name: String, val age: Int) + + @Serializable + data class Example(val id: Int, val value: MyPerson) + + val je = Example(1, MyPerson("Alice", 30)) + + "should encode in json (with explicit serializer) and decode" { + Sql("INSERT INTO JsonExample (id, \"value\") VALUES (1, ${Param.withSer(je.value, MyPerson.serializer())})").action().runOn(ctx) + Sql("SELECT id, \"value\" FROM JsonExample").queryOf().runOn(ctx) shouldBe listOf(je) + } + } + + "annotated field" { + @Serializable + data class MyPerson(val name: String, val age: Int) + + @Serializable + data class Example(val id: Int, @SqlJsonValue val value: MyPerson) + + val je = Example(1, MyPerson("Joe", 123)) + Sql("""INSERT INTO JsonExample (id, "value") VALUES (1, '{"name":"Joe", "age":123}')""").action().runOn(ctx) + val customers = Sql("SELECT id, \"value\" FROM JsonExample").queryOf().runOn(ctx) + customers shouldBe listOf(je) + } + } + +}) diff --git a/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/mysql/JsonSpecSimple.kt b/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/mysql/JsonSpecSimple.kt index d393676..67177cc 100644 --- a/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/mysql/JsonSpecSimple.kt +++ b/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/mysql/JsonSpecSimple.kt @@ -11,15 +11,12 @@ import io.kotest.matchers.shouldBe import kotlinx.serialization.Serializable class JsonSpec: FreeSpec({ - val ds = TestDatabases.postgres + val ds = TestDatabases.mysql val ctx by lazy { - JdbcControllers.Postgres(ds) + JdbcControllers.Mysql(ds) } beforeEach { - ds.run("DELETE FROM JsonbExample") - ds.run("DELETE FROM JsonbExample2") - ds.run("DELETE FROM JsonbExample3") ds.run("DELETE FROM JsonExample") } @@ -48,8 +45,8 @@ class JsonSpec: FreeSpec({ data class Example(val id: Int, @SqlJsonValue val value: MyPerson) val je = Example(1, MyPerson("Joe", 123)) - Sql("""INSERT INTO JsonbExample (id, value) VALUES (1, '{"name":"Joe", "age":123}')""").action().runOn(ctx) - val customers = Sql("SELECT id, value FROM JsonbExample").queryOf().runOn(ctx) + Sql("""INSERT INTO JsonExample (id, value) VALUES (1, '{"name":"Joe", "age":123}')""").action().runOn(ctx) + val customers = Sql("SELECT id, value FROM JsonExample").queryOf().runOn(ctx) customers shouldBe listOf(je) } } diff --git a/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/oracle/JsonSpecSimple.kt b/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/oracle/JsonSpecSimple.kt new file mode 100644 index 0000000..931350f --- /dev/null +++ b/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/oracle/JsonSpecSimple.kt @@ -0,0 +1,54 @@ +package io.exoquery.sql.oracle + +import io.exoquery.controller.JsonValue +import io.exoquery.controller.SqlJsonValue +import io.exoquery.sql.* +import io.exoquery.sql.Sql +import io.exoquery.controller.jdbc.JdbcControllers +import io.exoquery.controller.runOn +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class JsonSpec: FreeSpec({ + val ds = TestDatabases.oracle + val ctx by lazy { + JdbcControllers.Oracle(ds) + } + + beforeEach { + ds.run("DELETE FROM JsonExample") + } + + "SqlJsonValue annotation works on" - { + "inner data class" - { + @SqlJsonValue + @Serializable + data class MyPerson(val name: String, val age: Int) + + @Serializable + data class Example(val id: Int, val value: MyPerson) + + val je = Example(1, MyPerson("Alice", 30)) + + "should encode in json (with explicit serializer) and decode" { + Sql("INSERT INTO JsonExample (id, value) VALUES (1, ${Param.withSer(je.value, MyPerson.serializer())})").action().runOn(ctx) + Sql("SELECT id, value FROM JsonExample").queryOf().runOn(ctx) shouldBe listOf(je) + } + } + + "annotated field" { + @Serializable + data class MyPerson(val name: String, val age: Int) + + @Serializable + data class Example(val id: Int, @SqlJsonValue val value: MyPerson) + + val je = Example(1, MyPerson("Joe", 123)) + Sql("""INSERT INTO JsonExample (id, value) VALUES (1, '{"name":"Joe", "age":123}')""").action().runOn(ctx) + val customers = Sql("SELECT id, value FROM JsonExample").queryOf().runOn(ctx) + customers shouldBe listOf(je) + } + } + +}) diff --git a/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/sqlite/JsonSpecSimple.kt b/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/sqlite/JsonSpecSimple.kt new file mode 100644 index 0000000..1f70d9e --- /dev/null +++ b/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/sqlite/JsonSpecSimple.kt @@ -0,0 +1,54 @@ +package io.exoquery.sql.sqlite + +import io.exoquery.controller.JsonValue +import io.exoquery.controller.SqlJsonValue +import io.exoquery.sql.* +import io.exoquery.sql.Sql +import io.exoquery.controller.jdbc.JdbcControllers +import io.exoquery.controller.runOn +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class JsonSpec: FreeSpec({ + val ds = TestDatabases.sqlite + val ctx by lazy { + JdbcControllers.Sqlite(ds) + } + + beforeEach { + ds.run("DELETE FROM JsonExample") + } + + "SqlJsonValue annotation works on" - { + "inner data class" - { + @SqlJsonValue + @Serializable + data class MyPerson(val name: String, val age: Int) + + @Serializable + data class Example(val id: Int, val value: MyPerson) + + val je = Example(1, MyPerson("Alice", 30)) + + "should encode in json (with explicit serializer) and decode" { + Sql("INSERT INTO JsonExample (id, value) VALUES (1, ${Param.withSer(je.value, MyPerson.serializer())})").action().runOn(ctx) + Sql("SELECT id, value FROM JsonExample").queryOf().runOn(ctx) shouldBe listOf(je) + } + } + + "annotated field" { + @Serializable + data class MyPerson(val name: String, val age: Int) + + @Serializable + data class Example(val id: Int, @SqlJsonValue val value: MyPerson) + + val je = Example(1, MyPerson("Joe", 123)) + Sql("""INSERT INTO JsonExample (id, value) VALUES (1, '{"name":"Joe", "age":123}')""").action().runOn(ctx) + val customers = Sql("SELECT id, value FROM JsonExample").queryOf().runOn(ctx) + customers shouldBe listOf(je) + } + } + +}) diff --git a/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/sqlserver/JsonSpecSimple.kt b/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/sqlserver/JsonSpecSimple.kt new file mode 100644 index 0000000..7482909 --- /dev/null +++ b/terpal-sql-jdbc/src/test/kotlin/io/exoquery/sql/sqlserver/JsonSpecSimple.kt @@ -0,0 +1,54 @@ +package io.exoquery.sql.sqlserver + +import io.exoquery.controller.JsonValue +import io.exoquery.controller.SqlJsonValue +import io.exoquery.sql.* +import io.exoquery.sql.Sql +import io.exoquery.controller.jdbc.JdbcControllers +import io.exoquery.controller.runOn +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class JsonSpec: FreeSpec({ + val ds = TestDatabases.sqlServer + val ctx by lazy { + JdbcControllers.SqlServer(ds) + } + + beforeEach { + ds.run("DELETE FROM JsonExample") + } + + "SqlJsonValue annotation works on" - { + "inner data class" - { + @SqlJsonValue + @Serializable + data class MyPerson(val name: String, val age: Int) + + @Serializable + data class Example(val id: Int, val value: MyPerson) + + val je = Example(1, MyPerson("Alice", 30)) + + "should encode in json (with explicit serializer) and decode" { + Sql("INSERT INTO JsonExample (id, \"value\") VALUES (1, ${Param.withSer(je.value, MyPerson.serializer())})").action().runOn(ctx) + Sql("SELECT id, \"value\" FROM JsonExample").queryOf().runOn(ctx) shouldBe listOf(je) + } + } + + "annotated field" { + @Serializable + data class MyPerson(val name: String, val age: Int) + + @Serializable + data class Example(val id: Int, @SqlJsonValue val value: MyPerson) + + val je = Example(1, MyPerson("Joe", 123)) + Sql("""INSERT INTO JsonExample (id, "value") VALUES (1, '{"name":"Joe", "age":123}')""").action().runOn(ctx) + val customers = Sql("SELECT id, \"value\" FROM JsonExample").queryOf().runOn(ctx) + customers shouldBe listOf(je) + } + } + +}) diff --git a/terpal-sql-jdbc/src/test/resources/db/sqlserver-schema.sql b/terpal-sql-jdbc/src/test/resources/db/sqlserver-schema.sql index 5ef56ee..4b96708 100644 --- a/terpal-sql-jdbc/src/test/resources/db/sqlserver-schema.sql +++ b/terpal-sql-jdbc/src/test/resources/db/sqlserver-schema.sql @@ -84,5 +84,5 @@ CREATE TABLE JavaTestEntity( CREATE TABLE JsonExample( id INT PRIMARY KEY, - value VARCHAR + value VARCHAR(255) ); From 949a9502054039705f9a8f4bdadf430d34d92154 Mon Sep 17 00:00:00 2001 From: Alexander Ioffe Date: Thu, 4 Dec 2025 10:40:26 -0500 Subject: [PATCH 3/3] Implement Json columns for R2dbc --- .../controller/r2dbc/R2dbcControllers.kt | 38 +++++++++--- ...Encoding.kt => R2dbcJsonObjectEncoding.kt} | 11 +++- .../io/exoquery/r2dbc/h2/JsonSpecSimple.kt | 58 +++++++++++++++++++ .../io/exoquery/r2dbc/mysql/JsonSpecSimple.kt | 58 +++++++++++++++++++ .../exoquery/r2dbc/oracle/JsonSpecSimple.kt | 58 +++++++++++++++++++ .../r2dbc/sqlserver/JsonSpecSimple.kt | 58 +++++++++++++++++++ .../src/test/resources/db/h2-schema.sql | 5 ++ 7 files changed, 278 insertions(+), 8 deletions(-) rename controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/{R2dbcPostgresAdditionalEncoding.kt => R2dbcJsonObjectEncoding.kt} (53%) create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/JsonSpecSimple.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/JsonSpecSimple.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/JsonSpecSimple.kt create mode 100644 terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/sqlserver/JsonSpecSimple.kt diff --git a/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcControllers.kt b/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcControllers.kt index 1a808b2..ff15733 100644 --- a/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcControllers.kt +++ b/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcControllers.kt @@ -11,15 +11,15 @@ import io.r2dbc.spi.Row import io.r2dbc.spi.Statement object R2dbcControllers { - class Postgres( + open class Postgres( encodingConfig: R2dbcEncodingConfig = R2dbcEncodingConfig.Default(), override val connectionFactory: ConnectionFactory ): R2dbcController(encodingConfig,connectionFactory) { override val encodingConfig = encodingConfig.copy( - additionalEncoders = encodingConfig.additionalEncoders + R2dbcPostgresAdditionalEncoding.encoders, - additionalDecoders = encodingConfig.additionalDecoders + R2dbcPostgresAdditionalEncoding.decoders + additionalEncoders = encodingConfig.additionalEncoders + R2dbcJsonObjectEncoding.encoders, + additionalDecoders = encodingConfig.additionalDecoders + R2dbcJsonObjectEncoding.decoders ) override val encodingApi: R2dbcSqlEncoding = @@ -32,11 +32,17 @@ object R2dbcControllers { changePlaceholdersIn(sql) { index -> "$${index + 1}" } } - class SqlServer( + open class SqlServer( encodingConfig: R2dbcEncodingConfig = R2dbcEncodingConfig.Default(), override val connectionFactory: ConnectionFactory ): R2dbcController(encodingConfig,connectionFactory) { + override val encodingConfig = + encodingConfig.copy( + additionalEncoders = encodingConfig.additionalEncoders + R2dbcJsonTextEncoding.encoders, + additionalDecoders = encodingConfig.additionalDecoders + R2dbcJsonTextEncoding.decoders + ) + override val encodingApi: R2dbcSqlEncoding = object: JavaSqlEncoding, BasicEncoding by R2dbcBasicEncoding, @@ -54,11 +60,17 @@ object R2dbcControllers { changePlaceholdersIn(sql) { index -> "@Param${index + startingStatementIndex.value}" } } - class Mysql( + open class Mysql( encodingConfig: R2dbcEncodingConfig = R2dbcEncodingConfig.Default(), override val connectionFactory: ConnectionFactory ): R2dbcController(encodingConfig, connectionFactory) { + override val encodingConfig = + encodingConfig.copy( + additionalEncoders = encodingConfig.additionalEncoders + R2dbcJsonTextEncoding.encoders, + additionalDecoders = encodingConfig.additionalDecoders + R2dbcJsonTextEncoding.decoders + ) + override val encodingApi: R2dbcSqlEncoding = object: JavaSqlEncoding, BasicEncoding by R2dbcBasicEncoding, @@ -69,11 +81,17 @@ object R2dbcControllers { override fun changePlaceholders(sql: String): String = sql } - class H2( + open class H2( encodingConfig: R2dbcEncodingConfig = R2dbcEncodingConfig.Default(), override val connectionFactory: ConnectionFactory ): R2dbcController(encodingConfig, connectionFactory) { + override val encodingConfig = + encodingConfig.copy( + additionalEncoders = encodingConfig.additionalEncoders + R2dbcJsonTextEncoding.encoders, + additionalDecoders = encodingConfig.additionalDecoders + R2dbcJsonTextEncoding.decoders + ) + override val startingResultRowIndex: StartingIndex get() = StartingIndex.Zero override val encodingApi: R2dbcSqlEncoding = @@ -86,11 +104,17 @@ object R2dbcControllers { changePlaceholdersIn(sql) { index -> "$${index + 1}" } } - class Oracle( + open class Oracle( encodingConfig: R2dbcEncodingConfig = R2dbcEncodingConfig.Default(), override val connectionFactory: ConnectionFactory ): R2dbcController(encodingConfig, connectionFactory) { + override val encodingConfig = + encodingConfig.copy( + additionalEncoders = encodingConfig.additionalEncoders + R2dbcJsonTextEncoding.encoders, + additionalDecoders = encodingConfig.additionalDecoders + R2dbcJsonTextEncoding.decoders + ) + override val encodingApi: R2dbcSqlEncoding = object: JavaSqlEncoding, BasicEncoding by R2dbcBasicEncodingOracle, diff --git a/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcPostgresAdditionalEncoding.kt b/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcJsonObjectEncoding.kt similarity index 53% rename from controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcPostgresAdditionalEncoding.kt rename to controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcJsonObjectEncoding.kt index 44740d3..5fb22b9 100644 --- a/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcPostgresAdditionalEncoding.kt +++ b/controller-r2dbc/src/main/kotlin/io/exoquery/controller/r2dbc/R2dbcJsonObjectEncoding.kt @@ -2,7 +2,7 @@ package io.exoquery.controller.r2dbc import io.exoquery.controller.SqlJson -object R2dbcPostgresAdditionalEncoding { +object R2dbcJsonObjectEncoding { private const val NA = 0 val SqlJsonEncoder: R2dbcEncoderAny = R2dbcEncoderAny(NA, SqlJson::class) { ctx, v, i -> ctx.stmt.bind(i, io.r2dbc.postgresql.codec.Json.of(v.value)) } val SqlJsonDecoder: R2dbcDecoderAny = R2dbcDecoderAny(SqlJson::class) { ctx, i -> SqlJson(ctx.row.get(i, io.r2dbc.postgresql.codec.Json::class.java).asString()) } @@ -10,3 +10,12 @@ object R2dbcPostgresAdditionalEncoding { val encoders: Set> = setOf(SqlJsonEncoder) val decoders: Set> = setOf(SqlJsonDecoder) } + +object R2dbcJsonTextEncoding { + private const val NA = 0 + val SqlJsonEncoder: R2dbcEncoderAny = R2dbcEncoderAny(NA, SqlJson::class) { ctx, v, i -> ctx.stmt.bind(i, v.value) } + val SqlJsonDecoder: R2dbcDecoderAny = R2dbcDecoderAny(SqlJson::class) { ctx, i -> SqlJson(ctx.row.get(i, String::class.java)) } + + val encoders: Set> = setOf(SqlJsonEncoder) + val decoders: Set> = setOf(SqlJsonDecoder) +} diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/JsonSpecSimple.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/JsonSpecSimple.kt new file mode 100644 index 0000000..c9cfa22 --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/h2/JsonSpecSimple.kt @@ -0,0 +1,58 @@ +package io.exoquery.r2dbc.h2 + +import io.exoquery.controller.SqlJsonValue +import io.exoquery.controller.TerpalSqlUnsafe +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.controller.runActionsUnsafe +import io.exoquery.controller.runOn +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.sql.Param +import io.exoquery.sql.Sql +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class JsonSpec: FreeSpec({ + val cf = TestDatabasesR2dbc.h2 + val ctx: R2dbcController by lazy { R2dbcControllers.H2(connectionFactory = cf) } + + @OptIn(TerpalSqlUnsafe::class) + suspend fun runActions(actions: String) = ctx.runActionsUnsafe(actions) + + beforeEach { + runActions("DELETE FROM JsonExample") + } + + "SqlJsonValue annotation works on" - { + "inner data class" - { + @SqlJsonValue + @Serializable + data class MyPerson(val name: String, val age: Int) + + @Serializable + data class Example(val id: Int, val value: MyPerson) + + val je = Example(1, MyPerson("Alice", 30)) + + "should encode in json (with explicit serializer) and decode" { + Sql("INSERT INTO JsonExample (id, \"value\") VALUES (1, ${Param.withSer(je.value, MyPerson.serializer())})").action().runOn(ctx) + Sql("SELECT id, \"value\" FROM JsonExample").queryOf().runOn(ctx) shouldBe listOf(je) + } + } + + "annotated field" { + @Serializable + data class MyPerson(val name: String, val age: Int) + + @Serializable + data class Example(val id: Int, @SqlJsonValue val value: MyPerson) + + val je = Example(1, MyPerson("Joe", 123)) + Sql("""INSERT INTO JsonExample (id, "value") VALUES (1, '{"name":"Joe", "age":123}')""").action().runOn(ctx) + val customers = Sql("SELECT id, \"value\" FROM JsonExample").queryOf().runOn(ctx) + customers shouldBe listOf(je) + } + } + +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/JsonSpecSimple.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/JsonSpecSimple.kt new file mode 100644 index 0000000..ed1abf1 --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/mysql/JsonSpecSimple.kt @@ -0,0 +1,58 @@ +package io.exoquery.r2dbc.mysql + +import io.exoquery.controller.SqlJsonValue +import io.exoquery.controller.TerpalSqlUnsafe +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.controller.runActionsUnsafe +import io.exoquery.controller.runOn +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.sql.Param +import io.exoquery.sql.Sql +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class JsonSpec: FreeSpec({ + val cf = TestDatabasesR2dbc.mysql + val ctx: R2dbcController by lazy { R2dbcControllers.Mysql(connectionFactory = cf) } + + @OptIn(TerpalSqlUnsafe::class) + suspend fun runActions(actions: String) = ctx.runActionsUnsafe(actions) + + beforeEach { + runActions("DELETE FROM JsonExample") + } + + "SqlJsonValue annotation works on" - { + "inner data class" - { + @SqlJsonValue + @Serializable + data class MyPerson(val name: String, val age: Int) + + @Serializable + data class Example(val id: Int, val value: MyPerson) + + val je = Example(1, MyPerson("Alice", 30)) + + "should encode in json (with explicit serializer) and decode" { + Sql("INSERT INTO JsonExample (id, value) VALUES (1, ${Param.withSer(je.value, MyPerson.serializer())})").action().runOn(ctx) + Sql("SELECT id, value FROM JsonExample").queryOf().runOn(ctx) shouldBe listOf(je) + } + } + + "annotated field" { + @Serializable + data class MyPerson(val name: String, val age: Int) + + @Serializable + data class Example(val id: Int, @SqlJsonValue val value: MyPerson) + + val je = Example(1, MyPerson("Joe", 123)) + Sql("""INSERT INTO JsonExample (id, value) VALUES (1, '{"name":"Joe", "age":123}')""").action().runOn(ctx) + val customers = Sql("SELECT id, value FROM JsonExample").queryOf().runOn(ctx) + customers shouldBe listOf(je) + } + } + +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/JsonSpecSimple.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/JsonSpecSimple.kt new file mode 100644 index 0000000..15a7da9 --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/oracle/JsonSpecSimple.kt @@ -0,0 +1,58 @@ +package io.exoquery.r2dbc.oracle + +import io.exoquery.controller.SqlJsonValue +import io.exoquery.controller.TerpalSqlUnsafe +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.controller.runActionsUnsafe +import io.exoquery.controller.runOn +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.sql.Param +import io.exoquery.sql.Sql +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class JsonSpec: FreeSpec({ + val cf = TestDatabasesR2dbc.oracle + val ctx: R2dbcController by lazy { R2dbcControllers.Oracle(connectionFactory = cf) } + + @OptIn(TerpalSqlUnsafe::class) + suspend fun runActions(actions: String) = ctx.runActionsUnsafe(actions) + + beforeEach { + runActions("DELETE FROM JsonExample") + } + + "SqlJsonValue annotation works on" - { + "inner data class" - { + @SqlJsonValue + @Serializable + data class MyPerson(val name: String, val age: Int) + + @Serializable + data class Example(val id: Int, val value: MyPerson) + + val je = Example(1, MyPerson("Alice", 30)) + + "should encode in json (with explicit serializer) and decode" { + Sql("INSERT INTO JsonExample (id, value) VALUES (1, ${Param.withSer(je.value, MyPerson.serializer())})").action().runOn(ctx) + Sql("SELECT id, value FROM JsonExample").queryOf().runOn(ctx) shouldBe listOf(je) + } + } + + "annotated field" { + @Serializable + data class MyPerson(val name: String, val age: Int) + + @Serializable + data class Example(val id: Int, @SqlJsonValue val value: MyPerson) + + val je = Example(1, MyPerson("Joe", 123)) + Sql("""INSERT INTO JsonExample (id, value) VALUES (1, '{"name":"Joe", "age":123}')""").action().runOn(ctx) + val customers = Sql("SELECT id, value FROM JsonExample").queryOf().runOn(ctx) + customers shouldBe listOf(je) + } + } + +}) diff --git a/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/sqlserver/JsonSpecSimple.kt b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/sqlserver/JsonSpecSimple.kt new file mode 100644 index 0000000..c9c6145 --- /dev/null +++ b/terpal-sql-r2dbc/src/test/kotlin/io/exoquery/r2dbc/sqlserver/JsonSpecSimple.kt @@ -0,0 +1,58 @@ +package io.exoquery.r2dbc.sqlserver + +import io.exoquery.controller.SqlJsonValue +import io.exoquery.controller.TerpalSqlUnsafe +import io.exoquery.controller.r2dbc.R2dbcController +import io.exoquery.controller.r2dbc.R2dbcControllers +import io.exoquery.controller.runActionsUnsafe +import io.exoquery.controller.runOn +import io.exoquery.r2dbc.TestDatabasesR2dbc +import io.exoquery.sql.Param +import io.exoquery.sql.Sql +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.Serializable + +class JsonSpec: FreeSpec({ + val cf = TestDatabasesR2dbc.sqlServer + val ctx: R2dbcController by lazy { R2dbcControllers.SqlServer(connectionFactory = cf) } + + @OptIn(TerpalSqlUnsafe::class) + suspend fun runActions(actions: String) = ctx.runActionsUnsafe(actions) + + beforeEach { + runActions("DELETE FROM JsonExample") + } + + "SqlJsonValue annotation works on" - { + "inner data class" - { + @SqlJsonValue + @Serializable + data class MyPerson(val name: String, val age: Int) + + @Serializable + data class Example(val id: Int, val value: MyPerson) + + val je = Example(1, MyPerson("Alice", 30)) + + "should encode in json (with explicit serializer) and decode" { + Sql("INSERT INTO JsonExample (id, \"value\") VALUES (1, ${Param.withSer(je.value, MyPerson.serializer())})").action().runOn(ctx) + Sql("SELECT id, \"value\" FROM JsonExample").queryOf().runOn(ctx) shouldBe listOf(je) + } + } + + "annotated field" { + @Serializable + data class MyPerson(val name: String, val age: Int) + + @Serializable + data class Example(val id: Int, @SqlJsonValue val value: MyPerson) + + val je = Example(1, MyPerson("Joe", 123)) + Sql("""INSERT INTO JsonExample (id, "value") VALUES (1, '{"name":"Joe", "age":123}')""").action().runOn(ctx) + val customers = Sql("SELECT id, \"value\" FROM JsonExample").queryOf().runOn(ctx) + customers shouldBe listOf(je) + } + } + +}) diff --git a/terpal-sql-r2dbc/src/test/resources/db/h2-schema.sql b/terpal-sql-r2dbc/src/test/resources/db/h2-schema.sql index ac268a9..a74a84b 100644 --- a/terpal-sql-r2dbc/src/test/resources/db/h2-schema.sql +++ b/terpal-sql-r2dbc/src/test/resources/db/h2-schema.sql @@ -74,3 +74,8 @@ CREATE TABLE IF NOT EXISTS JavaTestEntity( javaUtilDateOpt TIMESTAMP, uuidOpt UUID ); + +CREATE TABLE IF NOT EXISTS JsonExample( + id IDENTITY, + "value" VARCHAR(255) +);